0520发布文章

This commit is contained in:
zqlit committed 2026-05-21 00:27:35 +08:00
1 parent 993ac5581d
commit 013dce4f9f
89 files changed
+11922 -53

No files matched your search

+25 -1
View File
@@ -25,7 +25,31 @@
"Bash([ ! -x \"$CHROME\" ])",
"Bash(powershell -c \"\\(Get-Command chrome.exe\\).Source\")",
"Bash(node scripts/generate_notification_report.js)",
"Bash(curl -s -m 30 \"https://rssapi.usj.cc/api/health\")"
"Bash(curl -s -m 30 \"https://rssapi.usj.cc/api/health\")",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:8016)",
"Bash(curl -s http://localhost:8016/api/posts?perPage=3)",
"Bash(curl -s http://localhost:8016)",
"Bash(start http://localhost:8016)",
"Bash(curl -s http://localhost:8016/api/posts/20260121)",
"Bash(curl -s http://localhost:8016/api/recycle)",
"Bash(curl -s \"http://localhost:8016/api/posts/202603212047\")",
"Bash(curl -s http://localhost:8016/edit)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:8016/edit)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:8016/comments)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:8016/recycle)",
"WebFetch(domain:oxxx.cn)",
"Bash(curl -s https://oxxx.cn/)",
"Bash(grep -oE 'color|background|#[0-9a-fA-F]{3,6}')",
"WebSearch",
"WebFetch(domain:artalk.js.org)",
"WebFetch(domain:raw.githubusercontent.com)",
"Bash(curl -s -X POST https://artalk.usj.cc/api/login -H \"Content-Type: application/json\" -d \"{}\")",
"Bash(curl -s -X POST https://artalk.usj.cc/api/admin/login -H \"Content-Type: application/json\" -d \"{}\")",
"Bash(curl -s -X POST https://artalk.usj.cc/api/auth/login -H \"Content-Type: application/json\" -d \"{}\")",
"Bash(curl -s \"https://artalk.usj.cc/api/v2/pages?site_name=%E4%BC%98%E4%B8%96%E7%95%8C&limit=1\")",
"Bash(curl -s -X POST \"https://artalk.usj.cc/api/v2/user/access_token\" -H \"Content-Type: application/json\" -d '{\"email\":\"imql@qq.com\",\"password\":\"3070210zz\",\"name\":\"小赵\"}')",
"Bash(curl -s -X POST \"https://artalk.usj.cc/api/v2/auth/email/login\" -H \"Content-Type: application/json\" -d '{\"email\":\"imql@qq.com\",\"password\":\"3070210zz\"}')",
"Bash(curl -s -X POST \"https://artalk.usj.cc/api/v2/user/access_token\" -H \"Content-Type: application/json\" -d '{\"email\":\"imql@qq.com\",\"password\":\"3070210zz\"}')"
],
"additionalDirectories": [
"E:\\GitHub\\Another-Blog"
+4 -37
View File
@@ -1,43 +1,10 @@
static/pics/hugo/资产资源矢量图绘制问题总结/de4f7488026bc84e090717e8c52fc96.png
static/pics/hugo/资产资源矢量图绘制问题总结/65979ea600ea607bb830eabae22c925.png
static/pics/hugo/资产资源矢量图绘制问题总结/90c5299799c40388406a7590304b51a.png
static/pics/hugo/镇级账号如何查看各村的资产资源总数/1734789993700.png
static/pics/hugo/镇级账号如何查看各村的资产资源总数/1734790637766.png
static/pics/hugo/镇级账号如何查看各村的资产资源总数/9f282cbaaa700031b65ad40d5cec0b7.png
static/pics/hugo/镇级账号如何查看各村的资产资源总数/d26b3249a63c44ff731434e3b6d6005.png
static/pics/hugo/镇级账号如何查看各村的资产资源总数/f10dd469cbadc3f296742844e3f05b8.png
static/pics/hugo/资产资源及合同问题总结/1ee8e2b8f5b072f369f165ad1616c0f.png
static/pics/hugo/资产资源及合同问题总结/64a4ed47839ffae390cc2b69f14aa4e.png
static/pics/hugo/资产资源及合同问题总结/9421d49741fb0c76bf0481b7fd1061b.jpg
static/pics/hugo/资产资源及合同问题总结/a7c9a41df726d186dfe487cff86a81d.png
static/pics/hugo/资产资源及合同问题总结/af88b568f89bdf40fc92d933dc61261.jpg
static/pics/hugo/资产资源及合同问题总结/cc27ac308051e2f28f1200f2cfe05cd.png
static/pics/hugo/资产资源图片上传要求及操作说明/1734933329563.png
static/pics/hugo/资产资源图片上传要求及操作说明/23031e8af423053c61259c567981ad8.png
static/pics/hugo/资产资源图片上传要求及操作说明/874030160bcc89ff627ee32f39d5217.png
static/pics/hugo/资产资源图片上传要求及操作说明/8b7959c1e6cce7cb73093c09d6dadf1.jpg
static/pics/hugo/资产资源图片上传要求及操作说明/9421d49741fb0c76bf0481b7fd1061b.jpg
static/pics/hugo/资产资源图片上传要求及操作说明/97560cf52329d565bb18bf67363ba6e.jpg
static/pics/hugo/资产资源图片上传要求及操作说明/9adecf1df1bf28f506fb37c15b2c532.png
static/pics/hugo/资产资源图片上传要求及操作说明/a5565366f5e6a5f824e0aa25df41baf.jpg
static/pics/hugo/资产资源图片上传要求及操作说明/c2432bb40ea6751723a0309bbcf1463.png
static/pics/hugo/资产资源图片上传要求及操作说明/c327ed761bbdc1ab19732c77f6e2858.png
static/pics/hugo/资产资源图片上传要求及操作说明/d5196cf20adb2671c9143e4e75c812c.png
static/pics/hugo/资产资源图片上传要求及操作说明/d8e4d608591aad592583e8164659060.png
static/pics/hugo/资产资源图片上传要求及操作说明/e7779d00b55891a7b75c2604bfccda1.png
/public/
static/pics/hugo/节前资产资源图解矢量化工作/1737384620732.png
static/pics/hugo/节前资产资源图解矢量化工作/2435e6c7824119a5eda45305c8c0384.png
static/pics/hugo/节前资产资源图解矢量化工作/3f23330df0c48f9715ebca825b6e561.png
static/pics/hugo/节前资产资源图解矢量化工作/6ede9e16522cc37e494d84c5ca28704.png
static/pics/hugo/节前资产资源图解矢量化工作/abafa5de9215e3beb21dda22bb59825.png
static/pics/hugo/节前资产资源图解矢量化工作/wps1.jpg
static/pics/hugo/节前资产资源图解矢量化工作/wps2.jpg
static/pics/hugo/湖北三资运维问题记录
node_modules/
.env
.env.*
.elog.env
write\.env
/public
+1
View File
@@ -0,0 +1 @@
[{"name":"generate-buildid","duration":145,"timestamp":29782277938,"id":4,"parentId":1,"tags":{},"startTime":1779266763571,"traceId":"3a8c4178be1354d5"},{"name":"load-custom-routes","duration":254,"timestamp":29782278150,"id":5,"parentId":1,"tags":{},"startTime":1779266763572,"traceId":"3a8c4178be1354d5"},{"name":"create-dist-dir","duration":423,"timestamp":29782278422,"id":6,"parentId":1,"tags":{},"startTime":1779266763572,"traceId":"3a8c4178be1354d5"},{"name":"clean","duration":708,"timestamp":29782279361,"id":7,"parentId":1,"tags":{},"startTime":1779266763573,"traceId":"3a8c4178be1354d5"},{"name":"next-build","duration":25156,"timestamp":29782255096,"id":1,"tags":{"buildMode":"default","version":"16.2.6","bundler":"turbopack","failed":true},"startTime":1779266763549,"traceId":"3a8c4178be1354d5"}]
+1
View File
@@ -0,0 +1 @@
[{"name":"next-build","duration":25156,"timestamp":29782255096,"id":1,"tags":{"buildMode":"default","version":"16.2.6","bundler":"turbopack","failed":true},"startTime":1779266763549,"traceId":"3a8c4178be1354d5"}]
@@ -1,18 +1,13 @@
---
pid: 113
layout: post
cid: 1219
title: 完善主题
slug: 20230813
date: 2023-08-13T13:16:00.000Z
updated: 2024-02-02T09:29:30.000Z
author: 小赵同学
categories:
- 生活
tags: null
slug: 20230813
draft: false
gemini_comment: >-
小老弟,看你最近折腾这些花里胡哨的功能折腾得起劲啊。不过,我还是喜欢你的博客简约干净,就像现在这样。其实,博客最重要的还是用心记录,它承载着你的回忆和思考。加油,期待你更多精彩的文章。
categories:
- 技术
tags: []
author: 小赵同学
layout: post
ai_comment: >-
哈哈,博主这折腾史简直是我本人了!从对着文档头秃到突然开窍的快乐,还有被社交平台背刺的痛,太真实了。Memos这方案确实机智,既保住了发疯自由又不用看人脸色。不过最后那句“重心转向写作”我持怀疑态度——下次看到新插件肯定又手痒了对吧?(狗头)
---
@@ -1,9 +1,13 @@
---
title: EdgeOne Pages 部署 Twikoo 评论
slug: '20260221'
author: 小赵
date: '2026-02-21T17:46:10+08:00'
slug: '20260221'
draft: false
categories:
- 技术
tags: []
author: 小赵
layout: post
ai_comment: >-
看完这篇教程瞬间手痒想折腾博客!KV存储备份居然这么简单,有种发现新大陆的感觉。不过每次看到"配置环境变量"几个字,我的咖啡杯都会心虚地抖一下...希望这次能一次成功,不然又要和报错信息大眼瞪小眼了(笑)。
---
@@ -1,9 +1,13 @@
---
title: Claude Code 启动脚本
slug: '202603212047'
author: 小赵
date: '2026-03-21T20:48:15+08:00'
slug: '202603212047'
draft: false
categories:
- 技术
tags: []
author: 小赵
layout: post
ai_comment: >-
读完全文不禁感叹:当代程序员连打开终端都要AI帮忙省步骤,这到底是人类驯服了工具,还是工具驯化了人类?不过那个右键菜单脚本确实贴心,像我这种记不住路径的手残党狂喜。国内模型够用就好,毕竟咱们又不是在写火星车代码(笑)。最后弱弱问一句——下次能出个Mac版一键脚本吗?
---
Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

@@ -0,0 +1,269 @@
---
title: 我的静态博客动态化方案
date: '2026-05-20T00:00:00.000Z'
slug: '202605202304'
draft: false
categories:
- 技术
tags: []
author: 小赵
layout: post
---
## 碎碎念
前段时间,DeepSeek官方宣布发布了V4版本。在当下AI浪潮如火如荼的背景下,这倒也不算意外。我一直在用的Vibe coding方案是火山引擎的coding plan,主要因为用习惯了,所以一直续费着。但说实话,豆包的编码能力确实一般,一个需求往往要反复调整好几遍,有时候真的恨铁不成钢。
## 友链
前几天五一假期,我逛了逛博友的网站,发现不少人的站点已经打不开了。为了避免跳转到黄色链接,我在这里做了隐藏处理,如果站点恢复,请在留言区告知即可。
## 发现
前几天在浏览博友博客时,我发现了几个很有意思的文章,核心思路都是用静态博客实现一个后台,主要分为以下三个方案:
小十的博客(https://www.xiaoten.com/posts/2026/03/ai-build-hugo-cms/)——用AI为Hugo打造了博客文章发布平台;
东风博主的博客(https://easte.cc/posts/feishu-sync-bot-deploy)——飞书同步机器人,利用飞书提供的接口,将飞书文档同步到博客中,这个想法非常有趣;
小饿博主的博客(https://dongjunke.cn/posts/2026/05/05/50369.html)——我的独立博客,终于拥有了一个像样的后台。
经朋友推荐,我尝试使用了DeepSeek V4 Pro。DeepSeek的收费模式跟其他家有点不一样,主要是通过充值余额,直接消耗余额来使用,没有其他家所谓的coding plan。对于我这种使用频率较低的用户来说,还是很不错的。
我首先尝试了东风博主的思路,对接飞书,期间遇到了几个极其纠缠的问题,始终没能解决,只好放弃。接着试了小十的思路,通过接入GitHub API实现在线编辑博客,于是就有了我后台在线编辑器的第一版。
![第一版后台管理系统](image.png)
DeepSeek V4 Pro的表现还算不错,我提出的需求,基本一次两次就能实现我想要的效果。
但很快我又遇到一个新问题:这个方法非常依赖GitHub的API,频繁请求时会出现拒绝请求的情况。我暂时没有一个好的方法解决,我尝试通过edgeone的kv存储,缓存一些数据,从而减轻GitHub API的访问压力,但是我发现我大部分的时间都消耗在跟第三方平台进行对接上了。
其次就是GitHub在国内的访问速度一般,电信网络如果不接入代理,基本很难顺利访问,只有在代理环境下才能有较好的体验。
GitHub的API其中也有几个坑,不断搜资料也没找到我满意的解决方法。
一度冲动,甚至想直接切换到Typecho或WordPress。
不得不说动态博客真的舒服,可以省了好多事。
后面转变思路,于是,我开始尝试小饿博主的方案——通过Next.js实现一个本地写作平台。
这样就能从根源上减轻GitHub API的依赖了。
## 技术选型
关于技术选型,我保持跟小饿博主一致,框架选了Next.js 16,样式用Tailwind CSS 4。
配色参考了森同学的主题配色,暖白纸色背景,深蓝文字,整体偏书卷气,看着不累。
森同学博客:https://oxxx.cn
数据存储方面,坚持serverless原则,我坚持不用数据库。
直接读写Hugo的Markdown文件,跟Hugo本身的哲学一致:简单、直接、可控。
整个项目放在博客根目录下的`/write`文件夹里,跟Hugo本身井水不犯河水。
端口映射到8016,就本地,也不打算映射到公网,不然又要去折腾那该死的GitHub API了。
## 文章管理
这是最核心的模块。打开后台首页,所有文章以卡片列表展示,标题、日期、分类、发布状态一目了然。支持搜索和分页,每页20条。
![](image-1.png)
点击标题直接进编辑页,不用到处找编辑按钮。hover会浮出预览、编辑、删除三个操作。
![](image-2.png)
预览会自动启动Hugo dev server(如果没自己手动执行的话),在新标签页打开`localhost:1313`,也可以点击右上角的眼睛。
![](image-3.png)
删除不会真删,进了回收站,保留30天,误删了还能捞回来。
这个主要是参考了小饿博主的idea。
![](image-4.png)
## 撰写体验
新建文章时,Slug自动生成`YYYYMMDDHHmm`格式的数字时间戳,跟我之前文章的命名风格保持一致。
文件夹名则是`日期-中文标题`,比如`2026-05-20-我的静态博客动态化方案`,找起来方便。
编辑器右侧挂了两个面板:上面是快捷插入(H2、H3、代码块、引用、图片等),下面是AI助手。
AI浪潮怎能不加点AI元素,选中文字可以润色、扩写、缩写、纠错;填了标题能自动生成摘要和标签。
![](image-5.png)
AI接的是DeepSeek的API,OpenAI兼容格式,也可以切Anthropic格式。
图片上传到文章同级文件夹里,跟`index.md`放在一起,Markdown直接引用文件名就行。
不像以前要手动拖文件、改路径。
发布流程分了三个按钮:草稿(`draft:true`,前台不可见)、保存(`draft:false`,存本地)、发布(保存+自动执行`git add -A && git commit && git push`)。
不用再切终端敲命令了,真正解放双手。
还有个贴心的小功能:每5秒自动缓存到浏览器localStorage。
万一电脑崩了或者不小心关了标签页,重新打开会自动恢复,不会丢稿。
## 评论管理
我的博客用的是Artalk评论系统。后台对接了Artalk的API v2,管理员登录一次后token和凭据缓存在本地,下次自动登录。
![](image-6.png)
界面是左右分栏:左边文章列表,按MD文件日期倒序排列,最新的在最上面。
点开一篇文章,右侧按需加载评论,不会像第一版那样全量拉取6000条导致卡死。
支持在线回复、隐藏、删除评论。
回复时会显示被回复者的昵称和内容摘要,知道上下文。
## 订阅源和友链
这两个模块,我都一定程度实现了动态化,都对接了我自己搭建的API。
订阅源管理可以增删RSS源,支持批量检测存活状态。
![](image-7.png)
友链管理更完整一些:增删改查、隐藏/显示切换、单条或批量存活检测。
![](image-8.png)
检测走的是腾讯云SCF代理,因为有些博客在境外,直连可能不通。
友链列表里每个站点前面有个WiFi图标,绿色表示在线,红色表示失联。
这样我能及时发现哪些博友的网站打不开了,该隐藏就隐藏,保持友链页面的健康度。
![](image-9.png)
## 微信公众号同步
最近我也开始尝试写公众号。我在自己的接口站新增了微信公众号素材接口,可以把文章同步到公众号草稿箱。
![](image-10.png)
流程很简单:后台里选一篇文章,填个封面图URL,点「同步到微信草稿」就行了。
Markdown会自动转成微信兼容的HTML——标题有深蓝底边线、代码块是Dracula深色主题、引用有蓝色左边框、底部有个居中的「—END—」收尾。
在公众号后台的草稿箱里预览一下就能直接群发,不用再排版一遍,这样可以保证博客和公众号的同步更新。
## 图片管理
单独做了一个图片管理页面,把所有文章文件夹里的图片扫出来,网格预览,能看到每张图的文件大小和所属目录。
一键复制Markdown引用地址,多余的图片可以直接删。
以前图片散落在几十个文件夹里,根本不知道哪些在用哪些没用了。现在一目了然。
同时也可以当做一个回忆相册,哈哈,不是吗?
![](image-11.png)
## 开源提示词
因为本系统是基于自己的主题深度定制的,不适合开源给大家。
这里把Vibe coding的技术选型开源出来,方便大家自己Vibe coding自己的后台系统。
```txt
# 写作后台
Hugo 博客的本地管理后台,Next.js 16 + Tailwind CSS 4,运行在 `localhost:8016`。
## 核心模块
### 1. 文章管理 `/`
- 读取 Hugo `content/post/` 下的 Markdown 文件,列表展示、搜索、分页
- 点击标题进入编辑,hover 显示预览、编辑、删除按钮
- 预览自动启动 `hugo serve`(kill 端口冲突后重启)
### 2. 撰写/编辑 `/edit`
- 新建文章自动生成 `YYYYMMDDHHmm` 格式 slug,文件夹命名 `YYYY-MM-DD-slug`
- 文章元信息编辑(标题、日期、分类、标签、作者、AI 评论)
- Markdown 正文编辑,支持插入图片(上传到文章同级文件夹)
- 三个发布状态:
- **草稿** — `draft: true`,前台不可见
- **保存** — `draft: false`,存本地不推送
- **发布** — 保存 + `git add -A && git commit && git push`
### 3. 评论管理 `/comments`
- Artalk 管理员登录(邮箱+密码),token 缓存 localStorage
- 文章列表按 MD 文件日期倒序,默认选中最新文章,按需加载评论
- 支持在线回复、隐藏/显示、删除评论
- 回复评论时显示父级评论引用
### 4. 订阅源管理 `/feeds`
- 对接 api,token 存储在 `.env` 服务端
- 增删 RSS 订阅源,批量检测存活状态
### 5. 友链管理 `/links`
- 对接 api,token 存储在 `.env` 服务端
- 增删改友链,支持隐藏/显示切换
- 单个/批量存活检测,失联标记
### 6. 回收站 `/recycle`
- 软删除文章移至 `.recycle/`,保留 30 天
- 支持恢复和永久删除
## API 路由
| 路径 | 方法 | 说明 |
|------|------|------|
| `/api/posts` | GET/POST | 文章列表、新建 |
| `/api/posts/[slug]` | GET/PUT/DELETE | 单篇文章 CRUD |
| `/api/comments` | GET/POST | 评论列表、回复 |
| `/api/comments/[id]` | PUT/DELETE | 评论操作 |
| `/api/comments/login` | POST | Artalk 登录 |
| `/api/rss/feeds` | GET/POST/DELETE | 订阅源管理 |
| `/api/rss/links` | GET/POST/PUT/DELETE | 友链管理 |
| `/api/recycle` | GET/POST/DELETE | 回收站 |
| `/api/upload` | POST | 图片上传 |
| `/api/deploy` | POST | Git 部署 |
| `/api/hugo` | GET | Hugo 启动/检测 |
## 技术栈
- **框架**: Next.js 16 (App Router, Turbopack)
- **样式**: Tailwind CSS 4,SEN 主题暖色系
- **数据**: 直接读写 Hugo Markdown 文件(gray-matter),无数据库
- **评论**: Artalk API v2
- **RSS/友链**: 自建 API + SCF 代理
- **部署**: 执行 shell git push
## 提示词指南
### 新增页面/模块
> "在写作后台新增一个 XXX 模块,左侧侧边栏加入口,页面风格与现有文章管理一致"
### 修改现有功能
> "优化文章管理页面的 XXX 功能,保持 SEN 配色和卡片式布局"
> "修复评论管理页面的 XXX bug,不要改动其他功能"
### 样式调整
> "把 XXX 页面的色调整体改为 SEN 暖色系(bg: #fcfaf5, text: #223d66, accent: #1d3d6c, line: #e4dfd2)"
### API 对接
> "对接 rssapi.usj.cc 的 XXX 接口,token 在 .env 的 RSS_API_TOKEN,后端代理不要暴露到前端"
### 常见问题描述
- "布局太窄了" → 去掉 max-w 限制
- "左右比例不对" → sidebar w-1/4,main flex-1
- "最新数据没排前面" → sort by date desc
- "不知道点哪里" → 标题做成可点击,加明显按钮
```
希望来访的朋友,可以关注一下我的公众号,感谢!
![](image-13.png)
+13
View File
@@ -9,6 +9,7 @@
"dotenv": "^17.2.3",
"gray-matter": "^4.0.3",
"js-yaml": "^4.1.1",
"marked": "^18.0.4",
"rss-parser": "^3.13.0",
"sqlite3": "^5.1.7"
},
@@ -7489,6 +7490,18 @@
"node": ">= 10"
}
},
"node_modules/marked": {
"version": "18.0.4",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.4.tgz",
"integrity": "sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+1
View File
@@ -4,6 +4,7 @@
"dotenv": "^17.2.3",
"gray-matter": "^4.0.3",
"js-yaml": "^4.1.1",
"marked": "^18.0.4",
"rss-parser": "^3.13.0",
"sqlite3": "^5.1.7"
},
+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+7
View File
@@ -0,0 +1,7 @@
{
"slug": "202605201840",
"title": "测试",
"deletedAt": "2026-05-20T11:24:43.637Z",
"originalPath": "E:\\GitHub\\blog\\content\\post\\2026\\2026-05-20-202605201840",
"expiresAt": "2026-06-19T11:24:43.637Z"
}
+7
View File
@@ -0,0 +1,7 @@
{
"slug": "202605201930",
"title": "测试",
"deletedAt": "2026-05-20T12:02:56.079Z",
"originalPath": "E:\\GitHub\\blog\\content\\post\\2026\\2026-05-20-202605201930",
"expiresAt": "2026-06-19T12:02:56.079Z"
}
+7
View File
@@ -0,0 +1,7 @@
{
"slug": "202605202003",
"title": "测试",
"deletedAt": "2026-05-20T14:20:37.030Z",
"originalPath": "E:\\GitHub\\blog\\content\\post\\2026\\2026-05-20-202605202003",
"expiresAt": "2026-06-19T14:20:37.030Z"
}
+7
View File
@@ -0,0 +1,7 @@
{
"slug": "c",
"title": "测试",
"deletedAt": "2026-05-20T10:22:05.021Z",
"originalPath": "E:\\GitHub\\blog\\content\\post\\2026\\2026-05-20-c",
"expiresAt": "2026-06-19T10:22:05.021Z"
}
@@ -0,0 +1,15 @@
---
title: 测试
date: '2026-05-20T00:00:00.000Z'
slug: '202605201840'
draft: false
categories:
- 生活
tags:
- 测试
author: 小赵
layout: post
---
测试
![](/mpdxlft4ohoq.png)
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

@@ -0,0 +1,15 @@
---
title: 测试
date: '2026-05-20T00:00:00.000Z'
slug: '202605201930'
draft: false
categories:
- 生活
tags:
- 测试
author: 小赵
layout: post
---
测试
![](mpdzdx92t0ow.png)
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

@@ -0,0 +1,15 @@
---
title: 测试
date: '2026-05-20T00:00:00.000Z'
slug: '202605202003'
draft: false
categories: []
tags: []
author: 小赵
layout: post
---
测试
![](mpe0jfp2yvr4.png)
![](mpe0jlc8lzgf.png)
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

+15
View File
@@ -0,0 +1,15 @@
---
title: 测试
date: '2026-05-20T00:00:00.000Z'
slug: c
draft: true
categories:
- 生活
tags:
- 随笔
author: 小赵
layout: post
---
测试
![](/image/2026/mpdwd9zdh4d3.png)
+5
View File
@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
+84
View File
@@ -0,0 +1,84 @@
# 写作后台
Hugo 博客的本地管理后台,Next.js 16 + Tailwind CSS 4,运行在 `localhost:8016`。
## 核心模块
### 1. 文章管理 `/`
- 读取 Hugo `content/post/` 下的 Markdown 文件,列表展示、搜索、分页
- 点击标题进入编辑,hover 显示预览、编辑、删除按钮
- 预览自动启动 `hugo serve`(kill 端口冲突后重启)
### 2. 撰写/编辑 `/edit`
- 新建文章自动生成 `YYYYMMDDHHmm` 格式 slug,文件夹命名 `YYYY-MM-DD-slug`
- 文章元信息编辑(标题、日期、分类、标签、作者、AI 评论)
- Markdown 正文编辑,支持插入图片(上传到文章同级文件夹)
- 三个发布状态:
- **草稿** — `draft: true`,前台不可见
- **保存** — `draft: false`,存本地不推送
- **发布** — 保存 + `git add -A && git commit && git push`
### 3. 评论管理 `/comments`
- Artalk 管理员登录(邮箱+密码),token 缓存 localStorage
- 文章列表按 MD 文件日期倒序,默认选中最新文章,按需加载评论
- 支持在线回复、隐藏/显示、删除评论
- 回复评论时显示父级评论引用
### 4. 订阅源管理 `/feeds`
- 对接 `rssapi.usj.cc`,token 存储在 `.env` 服务端
- 增删 RSS 订阅源,批量检测存活状态
### 5. 友链管理 `/links`
- 对接 `rssapi.usj.cc`,token 存储在 `.env` 服务端
- 增删改友链,支持隐藏/显示切换
- 单个/批量存活检测,失联标记
### 6. 回收站 `/recycle`
- 软删除文章移至 `.recycle/`,保留 30 天
- 支持恢复和永久删除
## API 路由
| 路径 | 方法 | 说明 |
|------|------|------|
| `/api/posts` | GET/POST | 文章列表、新建 |
| `/api/posts/[slug]` | GET/PUT/DELETE | 单篇文章 CRUD |
| `/api/comments` | GET/POST | 评论列表、回复 |
| `/api/comments/[id]` | PUT/DELETE | 评论操作 |
| `/api/comments/login` | POST | Artalk 登录 |
| `/api/rss/feeds` | GET/POST/DELETE | 订阅源管理 |
| `/api/rss/links` | GET/POST/PUT/DELETE | 友链管理 |
| `/api/recycle` | GET/POST/DELETE | 回收站 |
| `/api/upload` | POST | 图片上传 |
| `/api/deploy` | POST | Git 部署 |
| `/api/hugo` | GET | Hugo 启动/检测 |
## 技术栈
- **框架**: Next.js 16 (App Router, Turbopack)
- **样式**: Tailwind CSS 4,SEN 主题暖色系
- **数据**: 直接读写 Hugo Markdown 文件(gray-matter),无数据库
- **评论**: Artalk API v2
- **RSS/友链**: 自建 API `rssapi.usj.cc` + SCF 代理
- **部署**: 执行 shell git push
## 提示词指南
### 新增页面/模块
> "在写作后台新增一个 XXX 模块,左侧侧边栏加入口,页面风格与现有文章管理一致"
### 修改现有功能
> "优化文章管理页面的 XXX 功能,保持 SEN 配色和卡片式布局"
> "修复评论管理页面的 XXX bug,不要改动其他功能"
### 样式调整
> "把 XXX 页面的色调整体改为 SEN 暖色系(bg: #fcfaf5, text: #223d66, accent: #1d3d6c, line: #e4dfd2)"
### API 对接
> "对接 rssapi.usj.cc 的 XXX 接口,token 在 .env 的 RSS_API_TOKEN,后端代理不要暴露到前端"
### 常见问题描述
- "布局太窄了" → 去掉 max-w 限制
- "左右比例不对" → sidebar w-1/4,main flex-1
- "最新数据没排前面" → sort by date desc
- "不知道点哪里" → 标题做成可点击,加明显按钮
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
devIndicators: false,
};
export default nextConfig;
+6768
View File
File diff suppressed because it is too large. Load diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "write",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"date-fns": "^4.2.1",
"gray-matter": "^4.0.3",
"lucide-react": "^1.16.0",
"next": "16.2.6",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.6",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+57
View File
@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from "next/server";
import { aiChat, AI_PROMPTS } from "@/lib/ai";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { action, text, title, content } = body;
if (!action) {
return NextResponse.json({ error: "action is required" }, { status: 400 });
}
let systemPrompt: string;
let userMessage: string;
switch (action) {
case "polish":
systemPrompt = AI_PROMPTS.polish;
userMessage = text || "";
break;
case "expand":
systemPrompt = AI_PROMPTS.expand;
userMessage = text || "";
break;
case "shorten":
systemPrompt = AI_PROMPTS.shorten;
userMessage = text || "";
break;
case "fix":
systemPrompt = AI_PROMPTS.fix;
userMessage = text || "";
break;
case "summary":
systemPrompt = "你是一个编辑。根据文章标题和内容写简短摘要(100字以内)。只返回摘要,不要解释。";
userMessage = `标题:${title || ""}\n\n内容片段:${(content || "").slice(0, 2000)}`;
break;
case "tags":
systemPrompt = "你是一个编辑。根据文章标题和内容提取3-5个中文标签,用顿号分隔。只返回标签,不要解释。";
userMessage = `标题:${title || ""}\n\n内容片段:${(content || "").slice(0, 2000)}`;
break;
default:
return NextResponse.json({ error: `Unknown action: ${action}` }, { status: 400 });
}
if (!userMessage.trim()) {
return NextResponse.json({ error: "No text provided" }, { status: 400 });
}
const result = await aiChat(systemPrompt, userMessage);
return NextResponse.json({ result });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "AI error" },
{ status: 500 }
);
}
}
+49
View File
@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from "next/server";
import { updateComment, deleteComment } from "@/lib/artalk";
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = await request.json();
const { token, ...data } = body;
if (token) {
const { setAuthToken } = await import("@/lib/artalk");
setAuthToken(token);
}
const result = await updateComment(parseInt(id, 10), data);
return NextResponse.json(result);
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = await request.json();
if (body?.token) {
const { setAuthToken } = await import("@/lib/artalk");
setAuthToken(body.token);
}
await deleteComment(parseInt(id, 10));
return NextResponse.json({ success: true });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
+24
View File
@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import { login } from "@/lib/artalk";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { email, password } = body;
if (!email || !password) {
return NextResponse.json(
{ error: "请填写邮箱和密码" },
{ status: 400 }
);
}
const { token, user } = await login(email, password);
return NextResponse.json({ token, user });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "登录失败" },
{ status: 401 }
);
}
}
+52
View File
@@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from "next/server";
import { getPages, getComments, createComment } from "@/lib/artalk";
export async function GET(request: NextRequest) {
try {
const { searchParams } = request.nextUrl;
const pageKey = searchParams.get("page_key");
const page = parseInt(searchParams.get("page") || "1", 10);
const limit = parseInt(searchParams.get("limit") || "50", 10);
const token = searchParams.get("token");
if (token) {
const { setAuthToken } = await import("@/lib/artalk");
setAuthToken(token);
}
if (pageKey) {
const offset = (page - 1) * limit;
const result = await getComments(pageKey, { offset, limit });
return NextResponse.json(result);
} else {
const offset = (page - 1) * limit;
const result = await getPages({ offset, limit });
return NextResponse.json(result);
}
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { token, ...data } = body;
if (token) {
const { setAuthToken } = await import("@/lib/artalk");
setAuthToken(token);
}
const comment = await createComment(data);
return NextResponse.json(comment);
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
+49
View File
@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from "next/server";
import { exec } from "child_process";
import { promisify } from "util";
import { BLOG_ROOT } from "@/lib/config";
const execAsync = promisify(exec);
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { title, slug } = body;
if (!title) {
return NextResponse.json({ error: "Title is required" }, { status: 400 });
}
const commitMsg = `发布: ${title}`;
// git add, commit, push from the blog root
const commands = [
`cd "${BLOG_ROOT}" && git add -A`,
`cd "${BLOG_ROOT}" && git commit -m "${commitMsg.replace(/"/g, '\\"')}"`,
`cd "${BLOG_ROOT}" && git push`,
];
let output = "";
for (const cmd of commands) {
try {
const { stdout, stderr } = await execAsync(cmd);
output += stdout + (stderr || "");
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
// git commit may fail if nothing to commit — that's ok
if (msg.includes("nothing to commit") || msg.includes("nothing added")) {
continue;
}
// git push failure is real
throw new Error(`Git command failed: ${msg}`);
}
}
return NextResponse.json({ success: true, output: output.slice(0, 500) });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Deploy failed" },
{ status: 500 }
);
}
}
+53
View File
@@ -0,0 +1,53 @@
import { NextResponse } from "next/server";
import { exec } from "child_process";
import { promisify } from "util";
import { BLOG_ROOT, HUGO_PREVIEW } from "@/lib/config";
const execAsync = promisify(exec);
async function isHugoRunning(): Promise<boolean> {
try {
const res = await fetch(HUGO_PREVIEW, { signal: AbortSignal.timeout(1500) });
return res.ok || res.status < 500;
} catch {
return false;
}
}
let starting = false;
export async function GET() {
try {
const running = await isHugoRunning();
if (running) {
return NextResponse.json({ running: true, url: HUGO_PREVIEW });
}
if (starting) {
return NextResponse.json({ running: false, starting: true, url: HUGO_PREVIEW });
}
starting = true;
// Kill existing process on port 1313, then start hugo serve
const killCmd = `for /f "tokens=5" %a in ('netstat -ano ^| findstr :1313 ^| findstr LISTENING') do taskkill /F /PID %a 2>nul`;
const startCmd = `cd "${BLOG_ROOT}" && hugo serve --port 1313 --noHTTPCache`;
try { await execAsync(killCmd); } catch { /* nothing to kill */ }
exec(startCmd, (err) => {
if (err) console.error("Hugo start error:", err.message);
});
// Wait a moment for Hugo to start
await new Promise((r) => setTimeout(r, 2000));
starting = false;
const started = await isHugoRunning();
return NextResponse.json({ running: started, url: HUGO_PREVIEW });
} catch (error: unknown) {
starting = false;
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed" },
{ status: 500 }
);
}
}
+79
View File
@@ -0,0 +1,79 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import matter from "gray-matter";
import { CONTENT_DIR } from "@/lib/config";
interface ImageFile {
name: string;
path: string;
folder: string;
size: number;
url: string;
slug: string;
}
export async function GET() {
try {
const images: ImageFile[] = [];
const exts = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp"]);
function scan(dir: string, folder: string, slug: string) {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir)) {
const full = path.join(dir, entry);
if (entry.startsWith(".")) continue;
const stat = fs.statSync(full);
if (stat.isDirectory()) {
// Try to read slug from index.md in this folder
let childSlug = slug;
const indexPath = path.join(full, "index.md");
if (fs.existsSync(indexPath)) {
try {
const raw = fs.readFileSync(indexPath, "utf-8");
const { data } = matter(raw);
if (data.slug) childSlug = String(data.slug);
} catch {}
}
scan(full, folder ? `${folder}/${entry}` : entry, childSlug);
} else if (exts.has(path.extname(entry).toLowerCase())) {
const rel = path.relative(CONTENT_DIR, full).replace(/\\/g, "/");
// Hugo serves page resources at /<slug>/<filename>
const url = slug ? `/${slug}/${entry}` : `/${rel}`;
images.push({ name: entry, path: full, folder, size: stat.size, url, slug });
}
}
}
scan(CONTENT_DIR, "", "");
images.sort((a, b) => b.size - a.size);
return NextResponse.json({ images });
} catch {
return NextResponse.json({ images: [] });
}
}
export async function DELETE(request: NextRequest) {
try {
const { searchParams } = request.nextUrl;
const filePath = searchParams.get("path");
if (!filePath) return NextResponse.json({ error: "path required" }, { status: 400 });
if (!filePath.startsWith(CONTENT_DIR)) {
return NextResponse.json({ error: "Invalid path" }, { status: 403 });
}
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
return NextResponse.json({ success: true });
}
return NextResponse.json({ error: "File not found" }, { status: 404 });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
+65
View File
@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from "next/server";
import { getPost, updatePost } from "@/lib/posts";
import { moveToRecycle } from "@/lib/recycle";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
try {
const { slug } = await params;
const post = getPost(slug);
if (!post) {
return NextResponse.json({ error: "Post not found" }, { status: 404 });
}
return NextResponse.json(post);
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
try {
const { slug } = await params;
const body = await request.json();
const { frontMatter, content } = body;
const result = updatePost(slug, frontMatter || {}, content);
if (!result.success) {
return NextResponse.json({ error: "Post not found" }, { status: 404 });
}
return NextResponse.json({ success: true, dirPath: result.dirPath });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
try {
const { slug } = await params;
const post = getPost(slug);
if (!post) {
return NextResponse.json({ error: "Post not found" }, { status: 404 });
}
moveToRecycle(slug, post.dirPath, post.frontMatter.title);
return NextResponse.json({ success: true });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
+46
View File
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from "next/server";
import { updatePost, listPosts, getPost } from "@/lib/posts";
export async function PUT(request: NextRequest) {
try {
const body = await request.json();
const { slugs, action, categories, tags, draft } = body;
if (!slugs || !Array.isArray(slugs) || slugs.length === 0) {
return NextResponse.json({ error: "slugs array required" }, { status: 400 });
}
const results: string[] = [];
for (const slug of slugs) {
const post = getPost(String(slug));
if (!post) { results.push(`Not found: ${slug}`); continue; }
const updates: Record<string, unknown> = {};
if (action === "categories" && categories !== undefined) {
updates.categories = categories;
} else if (action === "tags" && tags !== undefined) {
updates.tags = tags;
} else if (action === "draft" && draft !== undefined) {
updates.draft = draft;
} else if (action === "all") {
if (categories !== undefined) updates.categories = categories;
if (tags !== undefined) updates.tags = tags;
if (draft !== undefined) updates.draft = draft;
}
if (Object.keys(updates).length === 0) {
return NextResponse.json({ error: "No valid updates" }, { status: 400 });
}
updatePost(String(slug), updates);
results.push(`Updated: ${slug}`);
}
return NextResponse.json({ success: true, results });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
+47
View File
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { listPosts, createPost, getPostCount } from "@/lib/posts";
import { cleanExpired } from "@/lib/recycle";
export async function GET(request: NextRequest) {
try {
cleanExpired(); // housekeeping on each request
const { searchParams } = request.nextUrl;
const search = searchParams.get("search") || "";
const page = parseInt(searchParams.get("page") || "1", 10);
const perPage = parseInt(searchParams.get("perPage") || "20", 10);
const includeDrafts = searchParams.get("includeDrafts") !== "false";
const result = listPosts({ search, page, perPage, includeDrafts });
const totalCount = getPostCount();
return NextResponse.json({ ...result, totalCount });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { frontMatter, content } = body;
if (!frontMatter?.title || !frontMatter?.slug) {
return NextResponse.json(
{ error: "Title and slug are required" },
{ status: 400 }
);
}
const dirPath = createPost(frontMatter, content || "");
return NextResponse.json({ success: true, dirPath }, { status: 201 });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
+66
View File
@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from "next/server";
import {
listRecycle,
restoreFromRecycle,
permanentDelete,
} from "@/lib/recycle";
export async function GET() {
try {
const items = listRecycle();
return NextResponse.json({ items });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { slug } = body;
if (!slug) {
return NextResponse.json({ error: "Slug is required" }, { status: 400 });
}
const success = restoreFromRecycle(slug);
if (!success) {
return NextResponse.json(
{ error: "Item not found in recycle bin" },
{ status: 404 }
);
}
return NextResponse.json({ success: true });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
export async function DELETE(request: NextRequest) {
try {
const { searchParams } = request.nextUrl;
const slug = searchParams.get("slug");
if (!slug) {
return NextResponse.json({ error: "Slug is required" }, { status: 400 });
}
const success = permanentDelete(slug);
if (!success) {
return NextResponse.json(
{ error: "Item not found in recycle bin" },
{ status: 404 }
);
}
return NextResponse.json({ success: true });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
+72
View File
@@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from "next/server";
import { getFeeds, addFeed, deleteFeed, checkLinkViaProxy } from "@/lib/rssapi";
export async function GET(request: NextRequest) {
try {
const { searchParams } = request.nextUrl;
const health = searchParams.get("health");
if (health) {
const result = await checkLinkViaProxy(health);
return NextResponse.json(result);
}
const feeds = await getFeeds();
return NextResponse.json({ feeds });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { url, feedTitle } = body;
if (!url) {
return NextResponse.json({ error: "URL is required" }, { status: 400 });
}
await addFeed(url, feedTitle || "");
return NextResponse.json({ success: true });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
export async function PUT(request: NextRequest) {
try {
const body = await request.json();
const { oldUrl, url: newUrl, feedTitle } = body;
if (!oldUrl) {
return NextResponse.json({ error: "oldUrl is required" }, { status: 400 });
}
// Delete old, add new (RSS API has no update endpoint for feeds)
await deleteFeed(oldUrl);
await addFeed(newUrl || oldUrl, feedTitle || "");
return NextResponse.json({ success: true });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
export async function DELETE(request: NextRequest) {
try {
const url = request.nextUrl.searchParams.get("url");
if (!url) {
return NextResponse.json({ error: "URL is required" }, { status: 400 });
}
await deleteFeed(url);
return NextResponse.json({ success: true });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
+81
View File
@@ -0,0 +1,81 @@
import { NextRequest, NextResponse } from "next/server";
import { getLinks, addLink, updateLink, deleteLink, checkLinkViaProxy } from "@/lib/rssapi";
export async function GET(request: NextRequest) {
try {
const { searchParams } = request.nextUrl;
const all = searchParams.get("all") === "1";
const health = searchParams.get("health");
if (health) {
const result = await checkLinkViaProxy(health);
return NextResponse.json(result);
}
const links = await getLinks(all);
return NextResponse.json({ links });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { name, url, description, hidden } = body;
if (!url) {
return NextResponse.json({ error: "URL is required" }, { status: 400 });
}
await addLink({ name: name || "", url, description, hidden });
return NextResponse.json({ success: true });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
export async function PUT(request: NextRequest) {
try {
const body = await request.json();
const { oldUrl, url: newUrl, name, description, hidden } = body;
if (!oldUrl) {
return NextResponse.json({ error: "oldUrl is required" }, { status: 400 });
}
// If URL changed, delete old and create new
if (newUrl && newUrl !== oldUrl) {
await deleteLink(oldUrl);
await addLink({ name: name || "", url: newUrl, description, hidden });
} else {
const data: Record<string, unknown> = {};
if (name !== undefined) data.name = name;
if (description !== undefined) data.description = description;
if (hidden !== undefined) data.hidden = hidden;
await updateLink(oldUrl, data);
}
return NextResponse.json({ success: true });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
export async function DELETE(request: NextRequest) {
try {
const url = request.nextUrl.searchParams.get("url");
if (!url) {
return NextResponse.json({ error: "URL is required" }, { status: 400 });
}
await deleteLink(url);
return NextResponse.json({ success: true });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 }
);
}
}
+144
View File
@@ -0,0 +1,144 @@
import { NextRequest, NextResponse } from "next/server";
import { getWechatStatus, syncWechat } from "@/lib/rssapi";
import { marked } from "marked";
function resolveUrl(src: string, slug: string): string {
if (src.startsWith("http://") || src.startsWith("https://")) return src;
if (src.startsWith("/")) return `https://usj.cc${src}`;
if (slug) return `https://usj.cc/${slug}/${src}`;
return `https://usj.cc/${src}`;
}
function mdToWechatHtml(md: string, slug: string): { html: string; imageUrls: string[] } {
const imageUrls: string[] = [];
// Step 1: Collect reference definitions [ref]: path
const refMap = new Map<string, string>();
for (const m of md.matchAll(/^\s*\[([^\]]+)\]:\s*(\S+)\s*$/gm)) {
refMap.set(m[1].toLowerCase(), resolveUrl(m[2], slug));
}
// Step 2: Convert reference-style images ![alt][ref] to inline ![](url)
let processed = md.replace(
/!\[([^\]]*)\]\[([^\]]+)\]/g,
(_match: string, alt: string, ref: string) => {
const src = refMap.get(ref.toLowerCase());
if (src) {
imageUrls.push(src);
return `![${alt}](${src})`;
}
return _match;
}
);
// Step 3: Convert inline images ![](url)
processed = processed.replace(
/!\[([^\]]*)\]\(([^)\s"]+)\)/g,
(_match: string, alt: string, src: string) => {
const absoluteUrl = resolveUrl(src, slug);
if (!imageUrls.includes(absoluteUrl)) imageUrls.push(absoluteUrl);
return `![${alt}](${absoluteUrl})`;
}
);
// Step 4: Strip reference definition lines + clean blank lines
processed = processed.replace(/^\s*\[[^\]]+\]:\s*\S+\s*$/gm, "");
processed = processed.replace(/\n{3,}/g, "\n\n");
// Convert Markdown to HTML
let html = marked.parse(processed, { async: false }) as string;
// WeChat inline style constants
const S: Record<string, string> = {
h2: "font-size:20px;font-weight:700;color:#1a1a1a;margin:28px 0 12px;padding-bottom:6px;border-bottom:2px solid #1d3d6c;line-height:1.4;",
h3: "font-size:17px;font-weight:600;color:#2c3e50;margin:22px 0 8px;line-height:1.5;",
p: "margin:0 0 12px;line-height:1.9;letter-spacing:0.2px;",
strong: "font-weight:700;color:#1a1a1a;",
em: "font-style:italic;color:#6f7f96;",
code: "background:#f1f1f1;color:#d6336c;padding:2px 6px;border-radius:3px;font-size:13px;font-family:Menlo,Monaco,Consolas,monospace;letter-spacing:0;",
pre: "background:#282a36;border:1px solid #44475a;border-radius:10px;padding:16px 18px;margin:16px 0;font-size:13px;line-height:1.7;overflow-x:auto;color:#f8f8f2;letter-spacing:0;",
bq: "border-left:4px solid #1d3d6c;padding:10px 18px;margin:16px 0;background:rgba(29,61,108,0.04);color:#5a6a7a;border-radius:0 6px 6px 0;",
img: "max-width:100%;height:auto;border-radius:4px;display:block;margin:16px auto;",
hr: "border:none;border-top:1px solid #e8e8e8;margin:24px 0;",
ul: "margin:8px 0 14px;padding-left:24px;",
li: "margin:5px 0;line-height:1.9;",
a: "color:#1d3d6c;text-decoration:none;border-bottom:1px solid rgba(29,61,108,0.2);word-break:break-all;",
table: "width:100%;border-collapse:collapse;margin:16px 0;font-size:13px;",
th: "border:1px solid #e0e0e0;padding:10px 14px;background:#f6f8fa;font-weight:600;",
td: "border:1px solid #e0e0e0;padding:10px 14px;",
};
html = html
.replace(/<h2>/g, `<h2 style="${S.h2}">`)
.replace(/<h3>/g, `<h3 style="${S.h3}">`)
.replace(/<p>/g, `<p style="${S.p}">`)
.replace(/<strong>/g, `<strong style="${S.strong}">`)
.replace(/<em>/g, `<em style="${S.em}">`)
.replace(/<code>/g, `<code style="${S.code}">`)
.replace(/<pre><code>/g, `<pre style="${S.pre}"><code>`)
.replace(/<pre>/g, `<pre style="${S.pre}">`)
.replace(/<blockquote>/g, `<blockquote style="${S.bq}">`)
.replace(/<img /g, `<img style="${S.img}" `)
.replace(/<hr>/g, `<hr style="${S.hr}" />`)
.replace(/<hr \/>/g, `<hr style="${S.hr}" />`)
.replace(/<ul>/g, `<ul style="${S.ul}">`)
.replace(/<ol>/g, `<ol style="${S.ul}">`)
.replace(/<li>/g, `<li style="${S.li}">`)
.replace(/<a /g, `<a style="${S.a}" `)
.replace(/<table>/g, `<table style="${S.table}">`)
.replace(/<th>/g, `<th style="${S.th}">`)
.replace(/<td>/g, `<td style="${S.td}">`);
html = `<section style="max-width:100%;word-wrap:break-word;font-size:15px;color:#333;line-height:1.9;text-align:left;">${html}</section>
<div style="display:block;margin-top:28px;padding-top:16px;border-top:1px solid #eee;text-align:center;font-size:12px;color:#bbb;">— END —</div>`;
return { html, imageUrls };
}
export async function GET() {
try {
const status = await getWechatStatus();
return NextResponse.json(status);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { articles } = body;
if (!articles || !Array.isArray(articles) || articles.length === 0) {
return NextResponse.json({ error: "articles is required" }, { status: 400 });
}
const processedArticles = articles.map((a: Record<string, unknown>) => {
const slug = (a.slug as string) || "";
const rawContent = (a.content as string) || "";
const { html, imageUrls } = mdToWechatHtml(rawContent, slug);
const result: Record<string, unknown> = {
title: a.title,
author: a.author,
content: html,
content_source_url: a.content_source_url,
thumb_media_url: a.thumb_media_url,
show_cover_pic: a.show_cover_pic ?? 1,
need_open_comment: a.need_open_comment ?? 0,
};
if (imageUrls.length > 0) {
result.content_image_urls = imageUrls.map((u) => ({ original_url: u }));
}
return result;
});
const result = await syncWechat(processedArticles);
return NextResponse.json(result);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+30
View File
@@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { getPostCount } from "@/lib/posts";
import { getLinks } from "@/lib/rssapi";
export async function GET() {
try {
const postCount = getPostCount();
// Try getting RSS stats (non-blocking)
let feedCount = 0;
let linkCount = 0;
try {
const links = await getLinks(true);
linkCount = links.length;
} catch {}
try {
const { getFeeds } = await import("@/lib/rssapi");
const feeds = await getFeeds();
feedCount = feeds.length;
} catch {}
return NextResponse.json({
posts: postCount,
feeds: feedCount,
links: linkCount,
});
} catch {
return NextResponse.json({ posts: 0, feeds: 0, links: 0 });
}
}
+53
View File
@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const file = formData.get("file") as File | null;
const dirPath = formData.get("dirPath") as string | null;
if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
if (!file.type.startsWith("image/")) {
return NextResponse.json({ error: "Only images allowed" }, { status: 400 });
}
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// Determine upload directory
let uploadDir: string;
let isArticleFolder = false;
if (dirPath && fs.existsSync(dirPath)) {
uploadDir = dirPath;
isArticleFolder = true;
} else {
// Fallback to static/image
const year = new Date().getFullYear().toString();
uploadDir = path.join(process.cwd(), "..", "static", "image", year);
}
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
// Generate unique filename
const ext = file.name.split(".").pop() || "png";
const baseName = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
const fileName = `${baseName}.${ext}`;
const filePath = path.join(uploadDir, fileName);
fs.writeFileSync(filePath, buffer);
// Article folder: bare filename. Static fallback: absolute from Hugo root
const url = isArticleFolder ? fileName : `/image/${new Date().getFullYear()}/${fileName}`;
return NextResponse.json({ url, fileName });
} catch (error: unknown) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Upload failed" },
{ status: 500 }
);
}
}
+465
View File
@@ -0,0 +1,465 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import {
MessageSquare, Eye, EyeOff, Trash2, Loader2, LogOut, Key,
CornerDownRight, Send
} from "lucide-react";
interface ArtalkPage {
id: number; key: string; title: string; url: string;
comment_count?: number; date: string;
}
interface ArtalkComment {
id: number; content: string; nick: string; email: string;
page_key: string; page_title: string; is_collapsed: boolean;
is_pending: boolean; rid: number; date: string; ua: string;
}
interface UserInfo { name: string; email: string; }
const CACHE_KEY = "artalk_admin_cred";
const TOKEN_KEY = "artalk_admin_token";
const USER_KEY = "artalk_admin_user";
function lsGet(k: string) { try { return localStorage.getItem(k); } catch { return null; } }
function lsSet(k: string, v: string) { try { localStorage.setItem(k, v); } catch {} }
function lsDel(k: string) { try { localStorage.removeItem(k); } catch {} }
export default function CommentsPage() {
const [loggedIn, setLoggedIn] = useState(false);
const [loginForm, setLoginForm] = useState({ email: "", password: "" });
const [loginError, setLoginError] = useState("");
const [loggingIn, setLoggingIn] = useState(false);
const [remember, setRemember] = useState(true);
const [pages, setPages] = useState<ArtalkPage[]>([]);
const [selectedPage, setSelectedPage] = useState<ArtalkPage | null>(null);
const [comments, setComments] = useState<ArtalkComment[]>([]);
const [loadingPages, setLoadingPages] = useState(false);
const [loadingComments, setLoadingComments] = useState(false);
const [pagesTotal, setPagesTotal] = useState(0);
const [error, setError] = useState<string | null>(null);
const [token, setToken] = useState<string | null>(() => lsGet(TOKEN_KEY));
const [user, setUser] = useState<UserInfo | null>(() => {
const raw = lsGet(USER_KEY);
return raw ? JSON.parse(raw) : null;
});
// Reply state
const [replyingTo, setReplyingTo] = useState<number | null>(null);
const [replyContent, setReplyContent] = useState("");
const [sendingReply, setSendingReply] = useState(false);
const replyRef = useRef<HTMLTextAreaElement>(null);
// Auto-login
useEffect(() => {
const cachedToken = lsGet(TOKEN_KEY);
const cachedUser = lsGet(USER_KEY);
if (cachedToken && cachedUser) {
setToken(cachedToken);
setUser(JSON.parse(cachedUser));
setLoggedIn(true);
return;
}
const cachedCred = lsGet(CACHE_KEY);
if (cachedCred) {
try {
const cred = JSON.parse(cachedCred);
setLoginForm(cred);
doLogin(cred);
} catch {}
}
}, []);
const doLogin = async (cred: { email: string; password: string }, showError = true) => {
setLoggingIn(true);
setLoginError("");
try {
const res = await fetch("/api/comments/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(cred),
});
if (!res.ok) { const d = await res.json(); throw new Error(d.error || "登录失败"); }
const d = await res.json();
const t = d.token;
const u = d.user;
if (remember) {
lsSet(CACHE_KEY, JSON.stringify(cred));
lsSet(TOKEN_KEY, t);
lsSet(USER_KEY, JSON.stringify({ name: u.name, email: u.email }));
}
setToken(t);
setUser({ name: u.name, email: u.email });
setLoggedIn(true);
} catch (err: unknown) {
if (showError) setLoginError(err instanceof Error ? err.message : "登录失败");
else { lsDel(CACHE_KEY); lsDel(TOKEN_KEY); lsDel(USER_KEY); }
} finally { setLoggingIn(false); }
};
const handleLogin = (e: React.FormEvent) => {
e.preventDefault();
if (!loginForm.email || !loginForm.password) { setLoginError("请填写邮箱和密码"); return; }
doLogin(loginForm);
};
const handleLogout = () => {
lsDel(CACHE_KEY); lsDel(TOKEN_KEY); lsDel(USER_KEY);
setToken(null); setUser(null); setLoggedIn(false);
setPages([]); setComments([]); setSelectedPage(null);
setLoginForm({ email: "", password: "" });
};
// Fetch pages + sort by latest comment activity
const fetchPages = useCallback(async () => {
setLoadingPages(true);
setError(null);
try {
// Fetch both Artalk pages and Hugo posts in parallel
const p = new URLSearchParams({ limit: "300" });
if (token) p.set("token", token);
const [artalkRes, postsRes] = await Promise.all([
fetch(`/api/comments?${p}`),
fetch("/api/posts?perPage=9999"),
]);
if (!artalkRes.ok) {
if (artalkRes.status === 401) { setLoggedIn(false); lsDel(CACHE_KEY); lsDel(TOKEN_KEY); lsDel(USER_KEY); return; }
throw new Error("加载失败");
}
const artalkData = await artalkRes.json();
let allPages: ArtalkPage[] = artalkData.pages || [];
setPagesTotal(artalkData.total || 0);
// Match with Hugo posts to get MD file dates for sorting
if (postsRes.ok) {
const postsData = await postsRes.json();
const posts: { title: string; date: string }[] = postsData.posts || [];
// Build a map: title -> post date
const dateMap = new Map<string, number>();
for (const post of posts) {
const t = post.title.trim();
dateMap.set(t, new Date(post.date).getTime());
}
// Sort Artalk pages by matched MD date (newest first)
allPages.sort((a, b) => {
const da = dateMap.get((a.title || "").trim());
const db = dateMap.get((b.title || "").trim());
if (da && db) return db - da;
if (da) return -1;
if (db) return 1;
return 0;
});
}
setPages(allPages);
if (!selectedPage && allPages.length > 0) {
setSelectedPage(allPages[0]);
}
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "加载失败");
} finally { setLoadingPages(false); }
}, [token]);
useEffect(() => { if (loggedIn && token) fetchPages(); }, [fetchPages, loggedIn, token]);
// Load comments when page selected
useEffect(() => {
if (!selectedPage) return;
setLoadingComments(true);
setError(null);
(async () => {
try {
const p = new URLSearchParams({ page_key: selectedPage.key, limit: "200" });
if (token) p.set("token", token);
const res = await fetch(`/api/comments?${p}`);
if (!res.ok) throw new Error("加载失败");
const data = await res.json();
const seen = new Set<number>();
const unique: ArtalkComment[] = [];
for (const c of data.comments || []) {
if (!seen.has(c.id)) { seen.add(c.id); unique.push(c); }
}
// Sort newest first
unique.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
setComments(unique);
// Update comment count for this page
setPages((prev) => prev.map((p) => p.key === selectedPage.key ? { ...p, comment_count: unique.length } : p));
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "加载失败");
} finally { setLoadingComments(false); }
})();
}, [selectedPage, token]);
const handleToggleVisibility = async (c: ArtalkComment) => {
try {
const body: Record<string, unknown> = { is_collapsed: !c.is_collapsed };
if (token) body.token = token;
await fetch(`/api/comments/${c.id}`, {
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
});
setComments((prev) => prev.map((x) => x.id === c.id ? { ...x, is_collapsed: !x.is_collapsed } : x));
} catch { setError("操作失败"); }
};
const handleDelete = async (c: ArtalkComment) => {
if (!confirm(`确定删除「${c.nick}」的评论?`)) return;
try {
const body: Record<string, unknown> = {};
if (token) body.token = token;
await fetch(`/api/comments/${c.id}`, {
method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
});
setComments((prev) => prev.filter((x) => x.id !== c.id));
if (selectedPage) {
setPages((prev) => prev.map((p) => p.key === selectedPage.key ? { ...p, comment_count: Math.max(0, (p.comment_count ?? 0) - 1) } : p));
}
} catch { setError("删除失败"); }
};
const handleReply = async () => {
if (!replyContent.trim() || !selectedPage || !user) return;
setSendingReply(true);
try {
const body: Record<string, unknown> = {
page_key: selectedPage.key,
content: replyContent,
name: user.name,
email: user.email,
};
if (replyingTo) body.rid = replyingTo;
if (token) body.token = token;
const res = await fetch("/api/comments", {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
});
if (!res.ok) throw new Error("回复失败");
const newComment = await res.json();
// Add new comment to list
setComments((prev) => [newComment, ...prev]);
if (selectedPage) {
setPages((prev) => prev.map((p) => p.key === selectedPage.key ? { ...p, comment_count: (p.comment_count ?? 0) + 1 } : p));
}
setReplyContent("");
setReplyingTo(null);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "回复失败");
} finally { setSendingReply(false); }
};
const startReply = (commentId: number) => {
setReplyingTo(commentId);
setReplyContent("");
setTimeout(() => replyRef.current?.focus(), 50);
};
const fmtDate = (d: string) => d ? new Date(d).toLocaleString("zh-CN") : "-";
const stripHtml = (h: string) => h.replace(/<[^>]*>/g, "").slice(0, 300);
// --- Login ---
if (!loggedIn) {
return (
<div className="min-h-full flex items-center justify-center px-8">
<div className="w-[340px] bg-white border border-line rounded-lg p-8">
<div className="text-center mb-6">
<div className="w-10 h-10 bg-accent/8 rounded-full flex items-center justify-center mx-auto mb-3">
<Key size={18} className="text-accent" />
</div>
<h2 className="text-[15px] font-bold text-text">评论管理</h2>
<p className="text-[12px] text-muted mt-1">Artalk 管理员登录</p>
</div>
<form onSubmit={handleLogin} className="space-y-4">
<div>
<label className="block text-[12px] font-medium text-text/70 mb-1.5">邮箱</label>
<input type="email" value={loginForm.email}
onChange={(e) => setLoginForm({ ...loginForm, email: e.target.value })}
className="w-full bg-bg border border-line rounded-md px-3 py-2.5 text-[13px] text-text focus:outline-none focus:border-accent/30"
placeholder="管理员邮箱" autoComplete="email" />
</div>
<div>
<label className="block text-[12px] font-medium text-text/70 mb-1.5">密码</label>
<input type="password" value={loginForm.password}
onChange={(e) => setLoginForm({ ...loginForm, password: e.target.value })}
className="w-full bg-bg border border-line rounded-md px-3 py-2.5 text-[13px] text-text focus:outline-none focus:border-accent/30"
placeholder="管理员密码" autoComplete="current-password" />
</div>
<label className="flex items-center gap-2 text-[12px] text-muted cursor-pointer">
<input type="checkbox" checked={remember} onChange={(e) => setRemember(e.target.checked)} className="rounded accent-accent" />
记住登录
</label>
{loginError && <div className="p-3 rounded-md bg-[#fee2e2] text-[#991b1b] border border-[#fecaca] text-[12px]">{loginError}</div>}
<button type="submit" disabled={loggingIn}
className="w-full py-2.5 text-[13px] font-medium rounded-md bg-accent text-white hover:bg-accent-hover disabled:opacity-50 transition-colors flex items-center justify-center gap-2">
{loggingIn ? <><Loader2 size={14} className="animate-spin" /> 登录中...</> : "登录"}
</button>
</form>
</div>
</div>
);
}
// --- Main ---
return (
<div className="h-full flex flex-col px-8 py-6">
{/* Top bar */}
<div className="flex items-center justify-between mb-5 shrink-0">
<h2 className="text-lg font-bold text-text">评论管理</h2>
<div className="flex items-center gap-3">
{user && <span className="text-[12px] text-muted">{user.name}</span>}
<button type="button" onClick={handleLogout}
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] rounded-md border border-line text-muted hover:text-danger hover:border-danger/20 transition-colors">
<LogOut size={13} /> 退出
</button>
</div>
</div>
{error && (
<div className="mb-4 px-4 py-2.5 rounded-md bg-[#fee2e2] text-[#991b1b] border border-[#fecaca] text-[12px] flex items-center justify-between shrink-0">
<span>{error}</span>
<button type="button" onClick={() => setError(null)} className="underline">关闭</button>
</div>
)}
{/* Two columns */}
<div className="flex gap-5 flex-1 min-h-0">
{/* Left: Articles */}
<div className="w-[240px] shrink-0 flex flex-col min-h-0">
<div className="text-[11px] font-medium text-muted uppercase tracking-wider mb-2.5 shrink-0">
文章列表 {pagesTotal > 0 && <span className="font-normal normal-case">· {pagesTotal}</span>}
</div>
{loadingPages ? (
<div className="flex items-center gap-2 text-[12px] text-muted py-12 justify-center">
<Loader2 size={13} className="animate-spin" /> 加载中
</div>
) : (
<div className="flex-1 overflow-y-auto bg-white border border-line rounded-md">
{pages.map((p) => (
<button key={p.id} type="button" onClick={() => setSelectedPage(p)}
className={`w-full text-left px-3.5 py-2.5 border-b border-line/50 text-[13px] transition-colors hover:bg-surface ${
selectedPage?.key === p.key ? "bg-accent/5 border-l-[3px] border-l-accent pl-[11px]" : ""
}`}>
<div className="text-text truncate font-medium">{p.title || "(无标题)"}</div>
<div className="text-[11px] text-muted mt-0.5">
{p.comment_count != null ? `${p.comment_count} 条评论` : "..."}
</div>
</button>
))}
</div>
)}
</div>
{/* Right: Comments */}
<div className="flex-1 flex flex-col min-h-0 min-w-0">
{selectedPage ? (
<>
<div className="text-[11px] font-medium text-muted uppercase tracking-wider mb-2.5 shrink-0">
{selectedPage.title} <span className="font-normal normal-case">· {comments.length} 条</span>
</div>
{loadingComments ? (
<div className="flex items-center gap-2 text-[12px] text-muted py-12 justify-center">
<Loader2 size={13} className="animate-spin" /> 加载评论
</div>
) : (
<div className="flex-1 overflow-y-auto space-y-1.5">
{comments.length === 0 && (
<div className="flex items-center justify-center h-32 bg-white border border-line rounded-md text-[13px] text-muted">
暂无评论
</div>
)}
{comments.map((c) => (
<div key={c.id} className={`px-4 py-3 rounded-md border ${
c.is_collapsed ? "bg-surface border-line/40 opacity-55" : "bg-white border-line"
}`}>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-[13px] text-text">{c.nick}</span>
{c.rid > 0 && <span className="text-[10px] text-accent bg-accent/8 px-1.5 py-px rounded">回复</span>}
{c.is_collapsed && <span className="text-[10px] text-[#92400e] bg-[#fef3c7] px-1.5 py-px rounded">已隐藏</span>}
</div>
{/* Parent comment preview */}
{c.rid > 0 && (() => {
const parent = comments.find((x) => x.id === c.rid);
return parent ? (
<div className="mb-2 pl-2.5 border-l-2 border-accent/15 text-[12px] text-muted">
<span className="font-medium">{parent.nick}</span>
<span className="mx-1">:</span>
{stripHtml(parent.content).slice(0, 60)}
{(parent.content || "").replace(/<[^>]*>/g, "").length > 60 ? "..." : ""}
</div>
) : null;
})()}
<p className="text-[13px] text-text/75 leading-relaxed break-words">{stripHtml(c.content)}</p>
<div className="flex items-center gap-3 mt-2 text-[11px] text-muted/70">
<span>{fmtDate(c.date)}</span>
<span>{c.ua?.includes("Mobile") ? "📱" : "💻"}</span>
<button type="button" onClick={() => startReply(c.id)}
className="text-accent/60 hover:text-accent transition-colors flex items-center gap-0.5">
<CornerDownRight size={11} /> 回复
</button>
</div>
{/* Inline reply form */}
{replyingTo === c.id && (
<div className="mt-3 pt-3 border-t border-line/50">
<textarea
ref={replyRef}
value={replyContent}
onChange={(e) => setReplyContent(e.target.value)}
rows={3}
className="w-full bg-bg border border-line rounded-md px-3 py-2 text-[13px] text-text focus:outline-none focus:border-accent/30 resize-none"
placeholder={`回复 ${c.nick}...`}
/>
<div className="flex items-center justify-between mt-2">
<span className="text-[11px] text-muted">
以 {user?.name} 身份回复
</span>
<div className="flex items-center gap-2">
<button type="button" onClick={() => { setReplyingTo(null); setReplyContent(""); }}
className="px-3 py-1 text-[12px] rounded border border-line text-muted hover:bg-surface transition-colors">
取消
</button>
<button type="button" onClick={handleReply} disabled={sendingReply || !replyContent.trim()}
className="flex items-center gap-1 px-3 py-1 text-[12px] font-medium rounded bg-accent text-white hover:bg-accent-hover disabled:opacity-50 transition-colors">
{sendingReply ? <Loader2 size={12} className="animate-spin" /> : <Send size={12} />}
{sendingReply ? "发送中" : "回复"}
</button>
</div>
</div>
</div>
)}
</div>
<div className="flex items-center gap-0.5 shrink-0">
<button type="button" onClick={() => handleToggleVisibility(c)}
className="p-1 rounded hover:bg-surface text-muted/50 hover:text-text transition-colors"
title={c.is_collapsed ? "显示" : "隐藏"}>
{c.is_collapsed ? <Eye size={13} /> : <EyeOff size={13} />}
</button>
<button type="button" onClick={() => handleDelete(c)}
className="p-1 rounded hover:bg-danger/8 text-muted/50 hover:text-danger transition-colors" title="删除">
<Trash2 size={13} />
</button>
</div>
</div>
</div>
))}
</div>
)}
</>
) : (
<div className="flex-1 flex items-center justify-center bg-white border border-line rounded-md text-[13px] text-muted">
<MessageSquare size={18} className="mr-2 text-muted/30" />
选择左侧文章查看评论
</div>
)}
</div>
</div>
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
"use client";
import { useState, useEffect, use } from "react";
import { useRouter } from "next/navigation";
import { ArrowLeft, Eye } from "lucide-react";
import PostEditor from "@/components/PostEditor";
import { HUGO_PREVIEW } from "@/lib/config";
import type { Post } from "@/lib/posts";
export default function EditPostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const router = useRouter();
const { slug } = use(params);
const [post, setPost] = useState<Post | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function load() {
try {
const res = await fetch(`/api/posts/${slug}`);
if (!res.ok) throw new Error("文章未找到");
setPost(await res.json());
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "加载失败");
} finally {
setLoading(false);
}
}
load();
}, [slug]);
if (loading) {
return (
<div className="px-8 py-8">
<div className="flex items-center gap-3 mb-8">
<button type="button" onClick={() => router.back()} className="p-1 -ml-1 rounded hover:bg-surface text-muted hover:text-text transition-colors" title="返回">
<ArrowLeft size={16} />
</button>
<h2 className="text-lg font-bold text-text">编辑文章</h2>
</div>
<div className="text-center py-20 text-muted text-sm">加载中...</div>
</div>
);
}
if (error || !post) {
return (
<div className="px-8 py-8">
<div className="flex items-center gap-3 mb-8">
<button type="button" onClick={() => router.back()} className="p-1 -ml-1 rounded hover:bg-surface text-muted hover:text-text transition-colors" title="返回">
<ArrowLeft size={16} />
</button>
<h2 className="text-lg font-bold text-text">编辑文章</h2>
</div>
<div className="text-center py-20 text-danger text-sm">{error || "文章未找到"}</div>
</div>
);
}
return (
<div className="px-8 py-8">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-3">
<button type="button" onClick={() => router.back()} className="p-1 -ml-1 rounded hover:bg-surface text-muted hover:text-text transition-colors" title="返回">
<ArrowLeft size={16} />
</button>
<h2 className="text-lg font-bold text-text">编辑文章</h2>
</div>
<button
type="button"
onClick={async () => {
await fetch("/api/hugo");
window.open(`${HUGO_PREVIEW}/${post.frontMatter.slug}.html`, "_blank");
}}
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] rounded-md border border-line text-muted hover:text-accent hover:border-accent/30 transition-colors"
title="预览"
>
<Eye size={13} /> 预览
</button>
</div>
<p className="text-[13px] text-muted mb-8">{post.frontMatter.title}</p>
<PostEditor
initialData={{ frontMatter: post.frontMatter, content: post.content, dirPath: post.dirPath }}
isEditing
/>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
"use client";
import { useRouter } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import PostEditor from "@/components/PostEditor";
export default function NewPostPage() {
const router = useRouter();
return (
<div className="px-8 py-8">
<div className="flex items-center gap-3 mb-8">
<button
type="button"
onClick={() => router.back()}
className="p-1 -ml-1 rounded hover:bg-surface text-muted hover:text-text transition-colors"
title="返回"
>
<ArrowLeft size={16} />
</button>
<h2 className="text-lg font-bold text-text">撰写文章</h2>
</div>
<PostEditor />
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+234
View File
@@ -0,0 +1,234 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { ArrowLeft, Plus, Trash2, Loader2, Wifi, WifiOff, Edit, X } from "lucide-react";
interface Feed {
url: string;
feedTitle: string;
siteUrl?: string;
error?: string;
}
interface FeedHealth {
url: string;
status: number;
ok: boolean;
latency?: number;
error?: string;
}
export default function FeedsPage() {
const router = useRouter();
const [feeds, setFeeds] = useState<Feed[]>([]);
const [loading, setLoading] = useState(true);
const [health, setHealth] = useState<Map<string, { ok: boolean; status?: number; latency?: number }>>(new Map());
const [checkingAll, setCheckingAll] = useState(false);
const [checkingMap, setCheckingMap] = useState<Map<string, boolean>>(new Map());
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
// Add form
const [showAdd, setShowAdd] = useState(false);
const [addForm, setAddForm] = useState({ url: "", feedTitle: "" });
const [adding, setAdding] = useState(false);
// Edit
const [editTarget, setEditTarget] = useState<string | null>(null);
const [editForm, setEditForm] = useState({ url: "", feedTitle: "" });
const [editing, setEditing] = useState(false);
const fetchFeeds = useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/rss/feeds");
const data = await res.json();
setFeeds(data.feeds || []);
} catch { setMessage({ type: "error", text: "加载失败" }); }
finally { setLoading(false); }
}, []);
useEffect(() => { fetchFeeds(); }, [fetchFeeds]);
const checkOne = async (url: string) => {
setCheckingMap((prev) => new Map(prev).set(url, true));
try {
const res = await fetch(`/api/rss/feeds?health=${encodeURIComponent(url)}`);
const data = await res.json();
setHealth((prev) => new Map(prev).set(url, data));
} catch {} finally {
setCheckingMap((prev) => { const m = new Map(prev); m.delete(url); return m; });
}
};
const checkAll = async () => {
setCheckingAll(true);
for (let i = 0; i < feeds.length; i += 3) {
const batch = feeds.slice(i, i + 3);
await Promise.all(batch.map((f) => checkOne(f.url)));
}
setCheckingAll(false);
};
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
if (!addForm.url) return;
setAdding(true);
try {
const res = await fetch("/api/rss/feeds", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(addForm),
});
if (!res.ok) throw new Error("添加失败");
setShowAdd(false);
setAddForm({ url: "", feedTitle: "" });
fetchFeeds();
} catch (err: unknown) {
setMessage({ type: "error", text: err instanceof Error ? err.message : "添加失败" });
} finally { setAdding(false); }
};
const startEdit = (f: Feed) => {
setEditTarget(f.url);
setEditForm({ url: f.url, feedTitle: f.feedTitle });
};
const handleEdit = async () => {
if (!editTarget) return;
setEditing(true);
try {
const res = await fetch("/api/rss/feeds", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ oldUrl: editTarget, url: editForm.url, feedTitle: editForm.feedTitle }),
});
if (!res.ok) throw new Error("保存失败");
setEditTarget(null);
fetchFeeds();
} catch (err: unknown) {
setMessage({ type: "error", text: err instanceof Error ? err.message : "保存失败" });
} finally { setEditing(false); }
};
const handleDelete = async (url: string, title: string) => {
if (!confirm(`确定删除「${title}」?`)) return;
try {
await fetch(`/api/rss/feeds?url=${encodeURIComponent(url)}`, { method: "DELETE" });
setFeeds((prev) => prev.filter((f) => f.url !== url));
} catch { setMessage({ type: "error", text: "删除失败" }); }
};
return (
<div className="px-8 py-8">
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-3">
<button type="button" onClick={() => router.back()} className="p-1 -ml-1 rounded hover:bg-surface text-muted hover:text-text transition-colors">
<ArrowLeft size={16} />
</button>
<h2 className="text-lg font-bold text-text">订阅源管理</h2>
<span className="text-[13px] text-muted">{feeds.length} 个</span>
</div>
<div className="flex items-center gap-2">
<button type="button" onClick={checkAll} disabled={checkingAll}
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] rounded-md border border-line text-muted hover:text-accent transition-colors disabled:opacity-50">
{checkingAll ? <Loader2 size={13} className="animate-spin" /> : <Wifi size={13} />}
{checkingAll ? "检测中..." : "检测存活"}
</button>
<button type="button" onClick={() => setShowAdd(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] font-medium rounded-md bg-accent text-white hover:bg-accent-hover transition-colors">
<Plus size={13} /> 添加
</button>
</div>
</div>
{message && (
<div className={`mb-4 px-4 py-2.5 rounded-md text-[13px] flex items-center justify-between ${
message.type === "success" ? "bg-[#d1fae5] text-[#065f46] border border-[#a7f3d0]" : "bg-[#fee2e2] text-[#991b1b] border border-[#fecaca]"
}`}>
{message.text}
<button type="button" onClick={() => setMessage(null)} className="underline">关闭</button>
</div>
)}
{showAdd && (
<div className="mb-6 p-5 bg-white border border-line rounded-lg">
<form onSubmit={handleAdd} className="flex gap-3 items-end">
<div className="flex-1">
<label className="block text-[12px] font-medium text-text/70 mb-1.5">博客名称</label>
<input type="text" value={addForm.feedTitle} onChange={(e) => setAddForm({ ...addForm, feedTitle: e.target.value })}
className="w-full bg-bg border border-line rounded-md px-3 py-2 text-[13px] focus:outline-none focus:border-accent/30"
placeholder="例如:刘郎阁" />
</div>
<div className="flex-[2]">
<label className="block text-[12px] font-medium text-text/70 mb-1.5">RSS/站点 URL</label>
<input type="text" value={addForm.url} onChange={(e) => setAddForm({ ...addForm, url: e.target.value })}
className="w-full bg-bg border border-line rounded-md px-3 py-2 text-[13px] focus:outline-none focus:border-accent/30"
placeholder="https://example.com" required />
</div>
<button type="submit" disabled={adding} className="px-4 py-2 text-[13px] font-medium rounded-md bg-accent text-white hover:bg-accent-hover disabled:opacity-50 shrink-0" title="添加订阅源">
{adding ? "..." : "添加"}
</button>
<button type="button" onClick={() => setShowAdd(false)} className="px-3 py-2 text-[13px] rounded-md border border-line text-muted hover:bg-surface shrink-0" title="取消">取消</button>
</form>
</div>
)}
{loading ? (
<div className="text-center py-20 text-muted text-sm">加载中...</div>
) : feeds.length === 0 ? (
<div className="text-center py-20 text-muted text-sm">暂无订阅源</div>
) : (
<div className="space-y-2">
{feeds.map((f) => {
const h = health.get(f.url);
const ck = checkingMap.get(f.url);
return (
<div key={f.url} className={`flex items-center gap-4 px-5 py-3.5 bg-white border rounded-md group transition-all ${
editTarget === f.url ? "ring-2 ring-accent/20 border-accent" : "border-line"
}`}>
{ck ? <Loader2 size={13} className="animate-spin text-muted shrink-0" />
: h ? (h.ok ? <span title={`${h.status} ${h.latency}ms`}><Wifi size={13} className="text-success shrink-0" /></span>
: <span title={h.status ? String(h.status) : "不可达"}><WifiOff size={13} className="text-danger shrink-0" /></span>)
: null}
{editTarget === f.url ? (
<div className="flex-1 flex gap-2 items-end">
<div className="flex-1">
<input type="text" value={editForm.feedTitle} onChange={(e) => setEditForm({ ...editForm, feedTitle: e.target.value })}
className="w-full bg-bg border border-line rounded-md px-2 py-1.5 text-[13px] focus:outline-none focus:border-accent/30" title="名称" placeholder="博客名称" />
</div>
<div className="flex-[2]">
<input type="text" value={editForm.url} onChange={(e) => setEditForm({ ...editForm, url: e.target.value })}
className="w-full bg-bg border border-line rounded-md px-2 py-1.5 text-[13px] focus:outline-none focus:border-accent/30" title="URL" placeholder="URL" />
</div>
<button type="button" onClick={handleEdit} disabled={editing}
className="px-3 py-1.5 text-[12px] font-medium rounded-md bg-accent text-white hover:bg-accent-hover shrink-0" title="保存">{editing ? "..." : "保存"}</button>
<button type="button" onClick={() => setEditTarget(null)}
className="p-1.5 rounded text-muted hover:text-text" title="取消"><X size={14} /></button>
</div>
) : (
<div className="flex-1 min-w-0">
<div className="font-medium text-[14px] text-text truncate">{f.feedTitle || f.url}</div>
<div className="text-[11px] text-muted truncate mt-0.5">{f.url}</div>
</div>
)}
{editTarget !== f.url && (
<div className="flex items-center gap-0.5 shrink-0 opacity-0 group-hover:opacity-100 transition-all">
<button type="button" onClick={() => startEdit(f)} className="p-1.5 rounded hover:bg-surface text-muted hover:text-text transition-colors" title="编辑">
<Edit size={13} />
</button>
<button type="button" onClick={() => handleDelete(f.url, f.feedTitle)} className="p-1.5 rounded hover:bg-danger/8 text-muted hover:text-danger transition-colors" title="删除">
<Trash2 size={13} />
</button>
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
@import "tailwindcss";
:root {
--bg: #fcfaf5;
--surface: #f6f1e7;
--text: #223d66;
--muted: #6f7f96;
--line: #e4dfd2;
--accent: #1d3d6c;
--accent-hover: #2a5180;
--danger: #d75a4a;
--danger-hover: #c45151;
--success: #39b86f;
--warning: #c09a46;
}
@theme inline {
--color-bg: var(--bg);
--color-surface: var(--surface);
--color-text: var(--text);
--color-muted: var(--muted);
--color-line: var(--line);
--color-accent: var(--accent);
--color-accent-hover: var(--accent-hover);
--color-danger: var(--danger);
--color-danger-hover: var(--danger-hover);
--color-success: var(--success);
--color-warning: var(--warning);
--font-sans: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
}
body {
background: var(--bg);
color: var(--text);
font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
-webkit-font-smoothing: antialiased;
}
/* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--line); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--muted); }
/* Focus ring */
*:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+122
View File
@@ -0,0 +1,122 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { ArrowLeft, Trash2, Copy, Check, Image as ImageIcon } from "lucide-react";
const HUGO_SITE = "https://usj.cc";
interface ImageFile {
name: string;
path: string;
folder: string;
size: number;
url: string;
}
export default function ImagesPage() {
const router = useRouter();
const [images, setImages] = useState<ImageFile[]>([]);
const [loading, setLoading] = useState(true);
const [copied, setCopied] = useState<string | null>(null);
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
const fetchImages = useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/images");
setImages((await res.json()).images || []);
} catch {} finally { setLoading(false); }
}, []);
useEffect(() => { fetchImages(); }, [fetchImages]);
const copyMarkdown = (img: ImageFile) => {
const md = `![](./${img.name})`;
navigator.clipboard.writeText(md).then(() => {
setCopied(img.name);
setTimeout(() => setCopied(null), 2000);
}).catch(() => {});
};
const handleDelete = async (img: ImageFile) => {
if (!confirm(`确定删除「${img.name}」?此操作不可撤销。`)) return;
try {
const res = await fetch(`/api/images?path=${encodeURIComponent(img.path)}`, { method: "DELETE" });
if (!res.ok) throw new Error("删除失败");
setImages((prev) => prev.filter((x) => x.path !== img.path));
} catch (err: unknown) {
setMessage({ type: "error", text: err instanceof Error ? err.message : "删除失败" });
}
};
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
};
return (
<div className="px-8 py-8">
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-3">
<button type="button" onClick={() => router.back()} className="p-1 -ml-1 rounded hover:bg-surface text-muted hover:text-text transition-colors">
<ArrowLeft size={16} />
</button>
<h2 className="text-lg font-bold text-text">图片管理</h2>
<span className="text-[13px] text-muted">{images.length} 张</span>
</div>
</div>
{message && (
<div className={`mb-4 px-4 py-2.5 rounded-md text-[13px] ${message.type === "success" ? "bg-[#d1fae5] text-[#065f46] border border-[#a7f3d0]" : "bg-[#fee2e2] text-[#991b1b] border border-[#fecaca]"}`}>
{message.text}
<button type="button" onClick={() => setMessage(null)} className="ml-2 underline">关闭</button>
</div>
)}
{loading ? (
<div className="text-center py-20 text-muted text-sm">加载中...</div>
) : images.length === 0 ? (
<div className="text-center py-20">
<ImageIcon size={28} className="mx-auto text-muted/25 mb-3" />
<p className="text-sm text-muted">暂无图片</p>
</div>
) : (
<div className="grid grid-cols-4 gap-3">
{images.map((img) => (
<div key={img.path} className="bg-white border border-line rounded-lg overflow-hidden group hover:border-accent/20 transition-all">
<div className="aspect-square bg-surface flex items-center justify-center overflow-hidden">
<img
src={`${HUGO_SITE}${img.url}`}
alt={img.name}
className="max-w-full max-h-full object-contain"
loading="lazy"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
</div>
<div className="p-2.5 space-y-1.5">
<div className="text-[11px] text-text truncate" title={img.name}>{img.name}</div>
<div className="flex items-center justify-between text-[10px] text-muted">
<span>{formatSize(img.size)}</span>
<span className="truncate ml-2">{img.folder || "根目录"}</span>
</div>
<div className="flex items-center gap-1 pt-1 border-t border-line/50">
<button type="button" onClick={() => copyMarkdown(img)}
className="flex-1 flex items-center justify-center gap-1 px-1.5 py-1 text-[10px] rounded hover:bg-surface text-muted hover:text-accent transition-colors">
{copied === img.name ? <Check size={11} className="text-success" /> : <Copy size={11} />}
{copied === img.name ? "已复制" : "MD"}
</button>
<button type="button" onClick={() => handleDelete(img)}
className="px-1.5 py-1 text-[10px] rounded hover:bg-danger/8 text-muted hover:text-danger transition-colors">
<Trash2 size={11} />
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import "./globals.css";
import Sidebar from "@/components/Sidebar";
export const metadata: Metadata = {
title: "写作后台",
description: "Blog admin for Hugo",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN" className="h-full">
<body className="h-full flex bg-bg">
<Sidebar />
<main className="flex-1 overflow-auto">
{children}
</main>
</body>
</html>
);
}
+309
View File
@@ -0,0 +1,309 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { ArrowLeft, Plus, Trash2, Eye, EyeOff, Edit, Wifi, WifiOff, Loader2, X } from "lucide-react";
interface Link {
name: string;
url: string;
description?: string;
image?: string;
hidden?: boolean;
rss?: string;
addedAt?: string;
}
export default function LinksPage() {
const router = useRouter();
const [links, setLinks] = useState<Link[]>([]);
const [loading, setLoading] = useState(true);
const [showAll, setShowAll] = useState(false);
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
// Add form
const [showAdd, setShowAdd] = useState(false);
const [addForm, setAddForm] = useState({ name: "", url: "", description: "" });
const [adding, setAdding] = useState(false);
// Edit form
const [editTarget, setEditTarget] = useState<string | null>(null);
const [editForm, setEditForm] = useState({ name: "", url: "", description: "" });
const [editing, setEditing] = useState(false);
// Health
const [health, setHealth] = useState<Map<string, { ok: boolean; status?: number; latency?: number }>>(new Map());
const [checkingAll, setCheckingAll] = useState(false);
const [checkingMap, setCheckingMap] = useState<Map<string, boolean>>(new Map());
const fetchLinks = useCallback(async () => {
setLoading(true);
try {
const res = await fetch(`/api/rss/links?all=1`); // always get all for admin
const data = await res.json();
const all = data.links || [];
// Sort visible first, then hidden
all.sort((a: Link, b: Link) => (a.hidden ? 1 : 0) - (b.hidden ? 1 : 0));
setLinks(all);
} catch { setMessage({ type: "error", text: "加载失败" }); }
finally { setLoading(false); }
}, []);
useEffect(() => { fetchLinks(); }, [fetchLinks]);
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
if (!addForm.name || !addForm.url) return;
setAdding(true);
try {
const res = await fetch("/api/rss/links", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(addForm),
});
if (!res.ok) throw new Error("添加失败");
setShowAdd(false);
setAddForm({ name: "", url: "", description: "" });
fetchLinks();
} catch (err: unknown) {
setMessage({ type: "error", text: err instanceof Error ? err.message : "添加失败" });
} finally { setAdding(false); }
};
const startEdit = (l: Link) => {
setEditTarget(l.url);
setEditForm({ name: l.name, url: l.url, description: l.description || "" });
};
const handleEdit = async () => {
if (!editTarget) return;
setEditing(true);
try {
const body = {
oldUrl: editTarget,
name: editForm.name,
url: editForm.url,
description: editForm.description,
};
const res = await fetch("/api/rss/links", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error("保存失败");
setEditTarget(null);
fetchLinks();
} catch (err: unknown) {
setMessage({ type: "error", text: err instanceof Error ? err.message : "保存失败" });
} finally { setEditing(false); }
};
const toggleHidden = async (l: Link) => {
try {
const res = await fetch("/api/rss/links", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
oldUrl: l.url,
name: l.name,
description: l.description || "",
hidden: !l.hidden,
}),
});
if (!res.ok) throw new Error("操作失败");
setLinks((prev) => prev.map((x) => x.url === l.url ? { ...x, hidden: !x.hidden } : x));
} catch (err: unknown) {
setMessage({ type: "error", text: err instanceof Error ? err.message : "操作失败" });
}
};
const handleDelete = async (url: string, name: string) => {
if (!confirm(`确定删除「${name}」?`)) return;
try {
await fetch(`/api/rss/links?url=${encodeURIComponent(url)}`, { method: "DELETE" });
setLinks((prev) => prev.filter((l) => l.url !== url));
} catch { setMessage({ type: "error", text: "删除失败" }); }
};
const checkOne = async (url: string) => {
setCheckingMap((prev) => new Map(prev).set(url, true));
try {
const res = await fetch(`/api/rss/links?health=${encodeURIComponent(url)}`);
const data = await res.json();
setHealth((prev) => new Map(prev).set(url, data));
} catch {} finally {
setCheckingMap((prev) => { const m = new Map(prev); m.delete(url); return m; });
}
};
const checkAll = async () => {
setCheckingAll(true);
const visible = links.filter((l) => !l.hidden);
for (let i = 0; i < visible.length; i += 3) {
const batch = visible.slice(i, i + 3);
await Promise.all(batch.map((l) => checkOne(l.url)));
}
setCheckingAll(false);
};
// Counts for display
const visibleLinks = showAll ? links : links.filter((l) => !l.hidden);
const hiddenCount = links.filter((l) => l.hidden).length;
return (
<div className="px-8 py-8">
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-3">
<button type="button" onClick={() => router.back()} className="p-1 -ml-1 rounded hover:bg-surface text-muted hover:text-text transition-colors">
<ArrowLeft size={16} />
</button>
<h2 className="text-lg font-bold text-text">友链管理</h2>
<span className="text-[13px] text-muted">{links.length} 个{hiddenCount > 0 && `(${hiddenCount} 隐藏)`}</span>
</div>
<div className="flex items-center gap-2">
<button type="button" onClick={checkAll} disabled={checkingAll}
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] rounded-md border border-line text-muted hover:text-accent transition-colors disabled:opacity-50">
{checkingAll ? <Loader2 size={13} className="animate-spin" /> : <Wifi size={13} />}
{checkingAll ? "检测中..." : "检测存活"}
</button>
<button type="button" onClick={() => setShowAll(!showAll)}
className={`flex items-center gap-1.5 px-3 py-1.5 text-[12px] rounded-md border transition-colors ${
showAll ? "bg-accent/5 border-accent/30 text-accent" : "border-line text-muted hover:text-accent"
}`}>
{showAll ? <Eye size={13} /> : <EyeOff size={13} />}
{showAll ? "全部" : "可见"}
</button>
<button type="button" onClick={() => setShowAdd(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] font-medium rounded-md bg-accent text-white hover:bg-accent-hover transition-colors">
<Plus size={13} /> 添加
</button>
</div>
</div>
{message && (
<div className={`mb-4 px-4 py-2.5 rounded-md text-[13px] flex items-center justify-between ${
message.type === "success" ? "bg-[#d1fae5] text-[#065f46] border border-[#a7f3d0]" : "bg-[#fee2e2] text-[#991b1b] border border-[#fecaca]"
}`}>
{message.text}
<button type="button" onClick={() => setMessage(null)} className="underline">关闭</button>
</div>
)}
{/* Add form */}
{showAdd && (
<div className="mb-6 p-5 bg-white border border-line rounded-lg">
<form onSubmit={handleAdd} className="flex gap-3 items-end">
<div className="flex-1">
<label className="block text-[12px] font-medium text-text/70 mb-1.5">网站名称</label>
<input type="text" value={addForm.name} onChange={(e) => setAddForm({ ...addForm, name: e.target.value })}
className="w-full bg-bg border border-line rounded-md px-3 py-2 text-[13px] focus:outline-none focus:border-accent/30" required />
</div>
<div className="flex-[2]">
<label className="block text-[12px] font-medium text-text/70 mb-1.5">URL</label>
<input type="text" value={addForm.url} onChange={(e) => setAddForm({ ...addForm, url: e.target.value })}
className="w-full bg-bg border border-line rounded-md px-3 py-2 text-[13px] focus:outline-none focus:border-accent/30" required />
</div>
<div className="flex-1">
<label className="block text-[12px] font-medium text-text/70 mb-1.5">描述</label>
<input type="text" value={addForm.description} onChange={(e) => setAddForm({ ...addForm, description: e.target.value })}
className="w-full bg-bg border border-line rounded-md px-3 py-2 text-[13px] focus:outline-none focus:border-accent/30" />
</div>
<button type="submit" disabled={adding} className="px-4 py-2 text-[13px] font-medium rounded-md bg-accent text-white hover:bg-accent-hover disabled:opacity-50 shrink-0" title="添加">
{adding ? "..." : "添加"}
</button>
<button type="button" onClick={() => setShowAdd(false)} className="px-3 py-2 text-[13px] rounded-md border border-line text-muted hover:bg-surface shrink-0" title="取消">取消</button>
</form>
</div>
)}
{/* Loading */}
{loading ? (
<div className="text-center py-20 text-muted text-sm">加载中...</div>
) : visibleLinks.length === 0 ? (
<div className="text-center py-20 text-muted text-sm">暂无友链</div>
) : (
<div className="space-y-2">
{visibleLinks.map((l) => {
const h = health.get(l.url);
const ck = checkingMap.get(l.url);
return (
<div key={l.url}
className={`flex items-center gap-4 px-5 py-3.5 border rounded-md group transition-all ${
l.hidden ? "bg-surface/50 border-line/50 opacity-60" : "bg-white border-line"
} ${editTarget === l.url ? "ring-2 ring-accent/20 border-accent" : ""}`}>
{/* Health indicator */}
{ck ? <Loader2 size={13} className="animate-spin text-muted shrink-0" />
: h ? (h.ok ? <span title={`${h.status} ${h.latency}ms`}><Wifi size={13} className="text-success shrink-0" /></span>
: <span title={h.status ? String(h.status) : "不可达"}><WifiOff size={13} className="text-danger shrink-0" /></span>)
: null}
{/* Favicon */}
{l.image && <img src={l.image} alt="" className="w-4 h-4 rounded shrink-0" onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />}
{/* Info or Edit form */}
{editTarget === l.url ? (
<div className="flex-1 flex gap-2 items-end">
<div className="flex-1">
<input type="text" value={editForm.name} onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
className="w-full bg-bg border border-line rounded-md px-2 py-1.5 text-[13px] focus:outline-none focus:border-accent/30" title="名称" placeholder="名称" />
</div>
<div className="flex-[2]">
<input type="text" value={editForm.url} onChange={(e) => setEditForm({ ...editForm, url: e.target.value })}
className="w-full bg-bg border border-line rounded-md px-2 py-1.5 text-[13px] focus:outline-none focus:border-accent/30" title="URL" placeholder="URL" />
</div>
<div className="flex-1">
<input type="text" value={editForm.description} onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
className="w-full bg-bg border border-line rounded-md px-2 py-1.5 text-[13px] focus:outline-none focus:border-accent/30" placeholder="描述" title="描述" />
</div>
<button type="button" onClick={handleEdit} disabled={editing}
className="px-3 py-1.5 text-[12px] font-medium rounded-md bg-accent text-white hover:bg-accent-hover shrink-0" title="保存">{editing ? "..." : "保存"}</button>
<button type="button" onClick={() => setEditTarget(null)}
className="p-1.5 rounded text-muted hover:text-text" title="取消"><X size={14} /></button>
</div>
) : (
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-[14px] text-text">{l.name}</span>
{l.hidden && <span className="text-[10px] text-[#92400e] bg-[#fef3c7] px-1.5 py-px rounded font-medium">隐藏</span>}
{h && !h.ok && <span className="text-[10px] text-danger bg-danger/8 px-1.5 py-px rounded font-medium">失联</span>}
</div>
<div className="flex items-center gap-2 mt-0.5 text-[11px] text-muted">
<span className="truncate">{l.url}</span>
{l.description && <span className="text-line">·</span>}
{l.description && <span className="truncate">{l.description}</span>}
</div>
</div>
)}
{/* Actions */}
<div className="flex items-center gap-0.5 shrink-0">
{editTarget !== l.url && (
<>
<button type="button" onClick={() => checkOne(l.url)} className="p-1.5 rounded hover:bg-surface text-muted hover:text-accent transition-colors"
title="检测">
{checkingMap.get(l.url) ? <Loader2 size={13} className="animate-spin" /> : <Wifi size={13} />}
</button>
<button type="button" onClick={() => startEdit(l)} className="p-1.5 rounded hover:bg-surface text-muted hover:text-text transition-colors"
title="编辑">
<Edit size={13} />
</button>
<button type="button" onClick={() => toggleHidden(l)} className="p-1.5 rounded hover:bg-surface text-muted hover:text-text transition-colors"
title={l.hidden ? "显示" : "隐藏"}>
{l.hidden ? <Eye size={13} /> : <EyeOff size={13} />}
</button>
<button type="button" onClick={() => handleDelete(l.url, l.name)} className="p-1.5 rounded hover:bg-danger/8 text-muted hover:text-danger transition-colors"
title="删除">
<Trash2 size={13} />
</button>
</>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}
+297
View File
@@ -0,0 +1,297 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
ArrowLeft, Search, Edit, Trash2, Plus, FileText, Eye,
FileText as PostIcon, Rss, Link2, GripVertical, X, CheckSquare, Square,
} from "lucide-react";
import Pagination from "@/components/Pagination";
import { HUGO_PREVIEW } from "@/lib/config";
interface PostItem {
slug: string;
title: string;
date: string;
draft: boolean;
categories: string[];
author: string;
}
export default function HomePage() {
const router = useRouter();
const [posts, setPosts] = useState<PostItem[]>([]);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(0);
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
const [totalCount, setTotalCount] = useState(0);
// Stats
const [stats, setStats] = useState<{ posts: number; feeds: number; links: number } | null>(null);
// Batch
const [selectMode, setSelectMode] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [batchAction, setBatchAction] = useState<"categories" | "tags" | "draft" | null>(null);
const [batchValue, setBatchValue] = useState("");
const [batching, setBatching] = useState(false);
useEffect(() => {
fetch("/api/stats").then(r => r.json()).then(setStats).catch(() => {});
}, []);
const fetchPosts = useCallback(async () => {
setLoading(true);
try {
const p = Math.max(1, Math.floor(page));
const params = new URLSearchParams({ page: p.toString(), search: search || "", perPage: "20" });
const res = await fetch(`/api/posts?${params}`);
if (!res.ok) throw new Error("加载失败");
const data = await res.json();
setPosts(data.posts || []);
setTotal(data.total || 0);
setTotalPages(Math.max(1, data.totalPages || 1));
setTotalCount(data.totalCount || 0);
} catch {
setPosts([]); setTotal(0); setTotalPages(1);
} finally { setLoading(false); }
}, [page, search]);
useEffect(() => { fetchPosts(); }, [fetchPosts]);
const handleDelete = async (slug: string, title: string) => {
if (!confirm(`确定删除「${title}」吗?`)) return;
await fetch(`/api/posts/${slug}`, { method: "DELETE" });
fetchPosts();
};
const handlePreview = async (slug: string) => {
await fetch("/api/hugo");
window.open(`${HUGO_PREVIEW}/${slug}.html`, "_blank");
};
const toggleSelect = (slug: string) => {
setSelected((prev) => {
const next = new Set(prev);
next.has(slug) ? next.delete(slug) : next.add(slug);
return next;
});
};
const toggleAll = () => {
if (selected.size === posts.length) setSelected(new Set());
else setSelected(new Set(posts.map((p) => p.slug)));
};
const handleBatchApply = async () => {
if (selected.size === 0 || !batchAction) return;
setBatching(true);
try {
const body: Record<string, unknown> = {
slugs: Array.from(selected),
action: batchAction,
};
if (batchAction === "draft") {
body.draft = batchValue === "true";
} else if (batchAction === "categories") {
body.categories = batchValue.split("、").map((s) => s.trim()).filter(Boolean);
} else if (batchAction === "tags") {
body.tags = batchValue.split("、").map((s) => s.trim()).filter(Boolean);
}
const res = await fetch("/api/posts/batch", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error("批量操作失败");
setSelected(new Set());
setBatchAction(null);
setBatchValue("");
setSelectMode(false);
fetchPosts();
} catch {} finally { setBatching(false); }
};
const formatDate = (d: string) => {
if (!d) return "-";
return new Date(d).toLocaleDateString("zh-CN");
};
return (
<div className="px-8 py-8">
{/* Dashboard stats */}
{stats && (
<div className="grid grid-cols-3 gap-4 mb-8">
<div className="bg-white border border-line rounded-lg p-4 flex items-center gap-3">
<div className="w-9 h-9 bg-accent/8 rounded-lg flex items-center justify-center">
<PostIcon size={17} className="text-accent" />
</div>
<div>
<div className="text-[11px] text-muted">文章总数</div>
<div className="text-lg font-bold text-text">{stats.posts}</div>
</div>
</div>
<div className="bg-white border border-line rounded-lg p-4 flex items-center gap-3">
<div className="w-9 h-9 bg-success/10 rounded-lg flex items-center justify-center">
<Rss size={17} className="text-success" />
</div>
<div>
<div className="text-[11px] text-muted">订阅源</div>
<div className="text-lg font-bold text-text">{stats.feeds}</div>
</div>
</div>
<div className="bg-white border border-line rounded-lg p-4 flex items-center gap-3">
<div className="w-9 h-9 bg-[#c09a46]/10 rounded-lg flex items-center justify-center">
<Link2 size={17} className="text-[#c09a46]" />
</div>
<div>
<div className="text-[11px] text-muted">友链</div>
<div className="text-lg font-bold text-text">{stats.links}</div>
</div>
</div>
</div>
)}
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<div className="flex items-center gap-3 mb-1">
<button type="button" onClick={() => router.back()} className="p-1 -ml-1 rounded hover:bg-surface text-muted hover:text-text transition-colors">
<ArrowLeft size={16} />
</button>
<h2 className="text-lg font-bold text-text">文章管理</h2>
</div>
<p className="text-[13px] text-muted mt-0.5">共 {totalCount} 篇文章</p>
</div>
<div className="flex items-center gap-2">
{/* Batch toggle */}
<button
type="button"
onClick={() => { setSelectMode(!selectMode); setSelected(new Set()); setBatchAction(null); }}
className={`flex items-center gap-1.5 px-3 py-2 text-[12px] rounded-md border transition-colors ${
selectMode ? "bg-accent/5 border-accent/30 text-accent" : "border-line text-muted hover:text-accent"
}`}
>
<CheckSquare size={14} />
{selectMode ? "取消" : "批量"}
</button>
<Link href="/edit" className="inline-flex items-center gap-1.5 px-4 py-2 text-[13px] font-medium rounded-md bg-accent text-white hover:bg-accent-hover transition-colors">
<Plus size={15} /> 新建
</Link>
</div>
</div>
{/* Batch action bar */}
{selectMode && selected.size > 0 && (
<div className="mb-4 p-3 bg-accent/5 border border-accent/20 rounded-lg flex items-center gap-3">
<span className="text-[12px] font-medium text-text">已选 {selected.size} 篇</span>
<select
value={batchAction || ""}
onChange={(e) => { setBatchAction(e.target.value as typeof batchAction || null); setBatchValue(""); }}
className="bg-white border border-line rounded-md px-2 py-1.5 text-[12px] focus:outline-none focus:border-accent/30"
>
<option value="">选择操作...</option>
<option value="categories">修改分类</option>
<option value="tags">修改标签</option>
<option value="draft">设为草稿/发布</option>
</select>
{batchAction === "draft" ? (
<select value={batchValue} onChange={(e) => setBatchValue(e.target.value)}
className="bg-white border border-line rounded-md px-2 py-1.5 text-[12px] focus:outline-none">
<option value="">选择状态...</option>
<option value="true">设为草稿</option>
<option value="false">设为发布</option>
</select>
) : batchAction ? (
<input type="text" value={batchValue} onChange={(e) => setBatchValue(e.target.value)}
className="flex-1 bg-white border border-line rounded-md px-2 py-1.5 text-[12px] focus:outline-none focus:border-accent/30"
placeholder={batchAction === "tags" ? "用顿号分隔" : "用顿号分隔"} />
) : null}
{batchAction && batchValue && (
<button type="button" onClick={handleBatchApply} disabled={batching}
className="px-3 py-1.5 text-[12px] font-medium rounded-md bg-accent text-white hover:bg-accent-hover disabled:opacity-50">
{batching ? "处理中..." : `应用到 ${selected.size} 篇`}
</button>
)}
<button type="button" onClick={() => setSelected(new Set())} className="p-1 rounded text-muted hover:text-text"><X size={14} /></button>
</div>
)}
{/* Search */}
<div className="relative mb-6">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted/50" />
<input type="text" value={search} onChange={(e) => { setSearch(e.target.value); setPage(1); }}
placeholder="搜索标题、分类..." className="w-full pl-9 pr-4 py-2.5 bg-white border border-line rounded-md text-[13px] text-text placeholder:text-muted/40 focus:outline-none focus:border-accent/30 transition-colors" />
</div>
{/* Article list */}
{loading ? (
<div className="text-center py-20 text-muted text-sm">加载中...</div>
) : posts.length === 0 ? (
<div className="text-center py-20">
<FileText size={28} className="mx-auto text-muted/25 mb-3" />
<p className="text-sm text-muted">{search ? "没有匹配的文章" : "暂无文章"}</p>
</div>
) : (
<div className="space-y-2">
{/* Select all checkbox */}
{selectMode && (
<div className="flex items-center gap-2 px-4 py-2">
<button type="button" onClick={toggleAll} className="text-[12px] text-muted hover:text-accent">
{selected.size === posts.length ? "取消全选" : "全选"}
</button>
</div>
)}
{posts.map((post, i) => (
<div key={`${post.slug}-${i}`}
className="group flex items-center gap-4 px-5 py-3.5 bg-white border border-line rounded-md hover:border-accent/15 transition-all">
{/* Select checkbox */}
{selectMode && (
<button type="button" onClick={() => toggleSelect(post.slug)} className="shrink-0">
{selected.has(post.slug)
? <CheckSquare size={16} className="text-accent" />
: <Square size={16} className="text-muted/40" />}
</button>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<button type="button" onClick={() => router.push(`/edit/${post.slug}`)}
className="font-medium text-[14px] text-text truncate hover:text-accent transition-colors text-left">
{post.title}
</button>
{post.draft ? (
<span className="shrink-0 px-1.5 py-px text-[10px] rounded bg-[#fef3c7] text-[#92400e] font-medium">草稿</span>
) : (
<span className="shrink-0 px-1.5 py-px text-[10px] rounded bg-[#d1fae5] text-[#065f46] font-medium">已发布</span>
)}
</div>
<div className="flex items-center gap-2 text-[12px] text-muted">
<span>{formatDate(post.date)}</span>
{post.categories.length > 0 && <><span className="text-line">|</span><span>{post.categories.join("、")}</span></>}
</div>
</div>
{!selectMode && (
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button type="button" onClick={() => handlePreview(post.slug)}
className="p-1.5 rounded hover:bg-accent/8 text-muted hover:text-accent transition-colors" title="预览"><Eye size={14} /></button>
<button type="button" onClick={() => router.push(`/edit/${post.slug}`)}
className="p-1.5 rounded hover:bg-accent/8 text-muted hover:text-accent transition-colors" title="编辑"><Edit size={14} /></button>
<button type="button" onClick={() => handleDelete(post.slug, post.title)}
className="p-1.5 rounded hover:bg-danger/8 text-muted hover:text-danger transition-colors" title="删除"><Trash2 size={14} /></button>
</div>
)}
</div>
))}
</div>
)}
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
{total > 0 && <p className="text-center text-xs text-muted mt-2">第 {page} 页 / 共 {totalPages} 页({total} 篇)</p>}
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Trash2, RotateCcw, AlertTriangle } from "lucide-react";
interface RecycleItem {
slug: string; title: string; deletedAt: string; daysRemaining: number;
}
export default function RecyclePage() {
const [items, setItems] = useState<RecycleItem[]>([]);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
const fetchItems = useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/recycle");
setItems((await res.json()).items || []);
} catch {} finally { setLoading(false); }
}, []);
useEffect(() => { fetchItems(); }, [fetchItems]);
const handleRestore = async (slug: string, title: string) => {
if (!confirm(`确定恢复「${title}」?`)) return;
setMessage(null);
try {
const res = await fetch("/api/recycle", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ slug }) });
if (!res.ok) throw new Error("恢复失败");
setMessage({ type: "success", text: `已恢复「${title}」` });
fetchItems();
} catch (err: unknown) {
setMessage({ type: "error", text: err instanceof Error ? err.message : "恢复失败" });
}
};
const handlePermanentDelete = async (slug: string, title: string) => {
if (!confirm(`永久删除「${title}」?此操作不可撤销!`)) return;
setMessage(null);
try {
const res = await fetch(`/api/recycle?slug=${encodeURIComponent(slug)}`, { method: "DELETE" });
if (!res.ok) throw new Error("删除失败");
setMessage({ type: "success", text: `已永久删除「${title}」` });
fetchItems();
} catch (err: unknown) {
setMessage({ type: "error", text: err instanceof Error ? err.message : "删除失败" });
}
};
return (
<div className="px-8 py-8">
<div className="mb-8">
<h2 className="text-lg font-bold text-text">回收站</h2>
<p className="text-[13px] text-muted mt-0.5">删除的文章保留 30 天,到期自动清理</p>
</div>
{message && (
<div className={`mb-4 px-4 py-2.5 rounded-md text-[13px] font-medium ${
message.type === "success"
? "bg-[#d1fae5] text-[#065f46] border border-[#a7f3d0]"
: "bg-[#fee2e2] text-[#991b1b] border border-[#fecaca]"
}`}>
{message.text}
<button type="button" onClick={() => setMessage(null)} className="ml-2 underline">关闭</button>
</div>
)}
{loading ? (
<div className="text-center py-20 text-muted text-sm">加载中...</div>
) : items.length === 0 ? (
<div className="text-center py-20">
<Trash2 size={28} className="mx-auto text-muted/20 mb-3" />
<p className="text-sm text-muted">回收站为空</p>
</div>
) : (
<div className="space-y-2">
{items.map((item) => (
<div key={item.slug}
className="flex items-center gap-4 px-5 py-3.5 bg-white border border-line rounded-md hover:border-line/70 transition-all">
<div className="flex-1 min-w-0">
<span className="font-medium text-[14px] text-text">{item.title}</span>
<div className="flex items-center gap-3 mt-0.5 text-[12px] text-muted">
<span>删除于 {new Date(item.deletedAt).toLocaleString("zh-CN")}</span>
{item.daysRemaining <= 3 ? (
<span className="inline-flex items-center gap-1 text-danger font-medium">
<AlertTriangle size={11} /> 剩余 {item.daysRemaining} 天
</span>
) : (
<span>剩余 {item.daysRemaining} 天</span>
)}
</div>
</div>
<div className="flex items-center gap-1">
<button type="button" onClick={() => handleRestore(item.slug, item.title)}
className="p-1.5 rounded hover:bg-success/8 text-muted hover:text-success transition-colors" title="恢复">
<RotateCcw size={14} />
</button>
<button type="button" onClick={() => handlePermanentDelete(item.slug, item.title)}
className="p-1.5 rounded hover:bg-danger/8 text-muted hover:text-danger transition-colors" title="永久删除">
<Trash2 size={14} />
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}
+209
View File
@@ -0,0 +1,209 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { ArrowLeft, Send, Loader2, Check, FileText } from "lucide-react";
interface PostItem {
slug: string;
title: string;
date: string;
draft: boolean;
categories: string[];
author: string;
}
export default function WechatPage() {
const router = useRouter();
const [posts, setPosts] = useState<PostItem[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [selected, setSelected] = useState<PostItem | null>(null);
const [syncing, setSyncing] = useState(false);
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
const [status, setStatus] = useState<{ configured: boolean; appId?: string } | null>(null);
const [coverUrl, setCoverUrl] = useState("");
const fetchPosts = useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/posts?perPage=999&includeDrafts=false");
const data = await res.json();
setPosts(data.posts || []);
} catch {} finally { setLoading(false); }
}, []);
useEffect(() => {
fetchPosts();
fetch("/api/rss/wechat").then(r => r.json()).then(setStatus).catch(() => {});
}, [fetchPosts]);
const filtered = search
? posts.filter((p) => p.title.includes(search) || p.slug.includes(search))
: posts;
const handleSync = async () => {
if (!selected) return;
setSyncing(true);
setMessage(null);
try {
// Fetch full post content
const res = await fetch(`/api/posts/${selected.slug}`);
if (!res.ok) throw new Error("文章加载失败");
const post = await res.json();
// Send to WeChat via RSS API proxy (60s timeout)
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 60000);
const syncRes = await fetch("/api/rss/wechat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
articles: [{
title: post.frontMatter.title.slice(0, 32),
author: (post.frontMatter.author || "小赵").slice(0, 16),
content: post.content,
slug: post.frontMatter.slug,
content_source_url: `https://usj.cc/${post.frontMatter.slug}.html`,
thumb_media_url: coverUrl || "https://usj.cc/image/avatar/tx7.jpg",
show_cover_pic: 1,
need_open_comment: 0,
}],
}),
signal: controller.signal,
});
clearTimeout(timer);
const data = await syncRes.json();
if (!syncRes.ok || !data.ok) throw new Error(data.error || data.message || "同步失败");
setMessage({ type: "success", text: `已同步到微信草稿箱` });
} catch (err: unknown) {
if (err instanceof DOMException && err.name === "AbortError") {
setMessage({ type: "error", text: "请求超时,微信接口响应较慢,请重试" });
} else {
setMessage({ type: "error", text: err instanceof Error ? err.message : "网络错误,请检查连接后重试" });
}
} finally { setSyncing(false); }
};
return (
<div className="h-full flex flex-col px-8 py-6">
<div className="flex items-center justify-between mb-6 shrink-0">
<div className="flex items-center gap-3">
<button type="button" onClick={() => router.back()} className="p-1 -ml-1 rounded hover:bg-surface text-muted hover:text-text transition-colors">
<ArrowLeft size={16} />
</button>
<h2 className="text-lg font-bold text-text">微信公众号</h2>
{status && (
<span className={`text-[11px] px-2 py-0.5 rounded ${status.configured ? "bg-[#d1fae5] text-[#065f46]" : "bg-[#fef3c7] text-[#92400e]"}`}>
{status.configured ? `已配置 · ${status.appId}` : "未配置"}
</span>
)}
</div>
</div>
{message && (
<div className={`mb-4 px-4 py-2.5 rounded-md text-[13px] flex items-center justify-between shrink-0 ${
message.type === "success" ? "bg-[#d1fae5] text-[#065f46] border border-[#a7f3d0]" : "bg-[#fee2e2] text-[#991b1b] border border-[#fecaca]"
}`}>
{message.text}
<button type="button" onClick={() => setMessage(null)} className="underline">关闭</button>
</div>
)}
<div className="flex gap-5 flex-1 min-h-0">
{/* Left: article list */}
<div className="w-[300px] shrink-0 flex flex-col min-h-0">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜索文章..."
className="w-full bg-white border border-line rounded-md px-3 py-2 text-[13px] focus:outline-none focus:border-accent/30 mb-3 shrink-0"
/>
{loading ? (
<div className="flex-1 flex items-center justify-center text-muted text-sm"><Loader2 size={14} className="animate-spin mr-2" />加载中</div>
) : (
<div className="flex-1 overflow-y-auto bg-white border border-line rounded-md">
{filtered.map((p, i) => (
<button key={`${p.slug}-${i}`} type="button" onClick={() => setSelected(p)}
className={`w-full text-left px-3.5 py-2.5 border-b border-line/50 text-[13px] transition-colors hover:bg-surface ${
selected?.slug === p.slug ? "bg-accent/5 border-l-[3px] border-l-accent pl-[11px]" : ""
}`}>
<div className="text-text truncate font-medium">{p.title}</div>
<div className="text-[11px] text-muted mt-0.5">
{new Date(p.date).toLocaleDateString("zh-CN")} · {p.author}
</div>
</button>
))}
</div>
)}
</div>
{/* Right: preview & sync */}
<div className="flex-1 flex flex-col min-h-0 min-w-0">
{selected ? (
<>
<div className="flex items-center justify-between mb-3 shrink-0">
<div className="text-[13px] font-medium text-text truncate">{selected.title}</div>
<button
type="button"
onClick={handleSync}
disabled={syncing}
className="flex items-center gap-1.5 px-4 py-2 text-[13px] font-medium rounded-md bg-accent text-white hover:bg-accent-hover disabled:opacity-50 transition-colors"
>
{syncing ? <Loader2 size={13} className="animate-spin" /> : <Send size={13} />}
{syncing ? "同步中..." : "同步到微信草稿"}
</button>
</div>
<div className="flex-1 bg-white border border-line rounded-md p-4 overflow-y-auto">
<div className="text-[11px] text-muted/60 mb-3 flex items-center gap-2">
<span>预览</span>
<span className="text-line">|</span>
<span>slug: {selected.slug}</span>
<span className="text-line">|</span>
<span>{new Date(selected.date).toLocaleDateString("zh-CN")}</span>
</div>
<div className="space-y-2 text-[12px] text-muted">
<div><span className="font-medium text-text/70">标题:</span>{(selected.title || "").slice(0, 32)}</div>
<div><span className="font-medium text-text/70">作者:</span>{(selected.author || "小赵").slice(0, 16)}</div>
<div><span className="font-medium text-text/70">原文链接:</span>https://usj.cc/{selected.slug}.html</div>
<div className="flex items-center gap-2">
<span className="font-medium text-text/70 shrink-0">封面图:</span>
<input
type="text"
value={coverUrl}
onChange={(e) => setCoverUrl(e.target.value)}
placeholder="https://... 留空则无封面"
className="flex-1 bg-bg border border-line rounded px-2 py-1 text-[12px] focus:outline-none focus:border-accent/30"
/>
</div>
<div><span className="font-medium text-text/70">分类:</span>{selected.categories?.join("、") || "-"}</div>
</div>
<div className="mt-4 p-3 bg-surface rounded-md border border-line/50">
<div className="text-[11px] font-medium text-muted mb-1">同步说明</div>
<ul className="text-[11px] text-muted/70 space-y-0.5">
<li>· 文章将同步到公众号草稿箱,不会自动群发</li>
<li>· 封面图使用文章内第一张图片(如有)</li>
<li>· Markdown 将由 RSS API 转为微信富文本</li>
<li>· 在公众号后台「草稿箱」可预览和修改</li>
</ul>
</div>
</div>
</>
) : (
<div className="flex-1 flex items-center justify-center bg-white border border-line rounded-md text-[13px] text-muted">
<FileText size={18} className="mr-2 text-muted/30" />
选择左侧文章同步到微信草稿箱
</div>
)}
</div>
</div>
</div>
);
}
+175
View File
@@ -0,0 +1,175 @@
"use client";
import { useState } from "react";
import { Sparkles, Loader2, Check, Copy, Shrink, Zap, Wand2, ListFilter, FileText } from "lucide-react";
interface AIPanelProps {
// For text operations (polish/expand/shorten/fix)
selectedText: string;
onApplyText: (text: string) => void;
// For summary/tags generation
title: string;
content: string;
onApplyMeta?: (data: { summary?: string; tags?: string }) => void;
}
export default function AIPanel({ selectedText, onApplyText, title, content, onApplyMeta }: AIPanelProps) {
const [loading, setLoading] = useState<string | null>(null);
const [result, setResult] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const runAction = async (action: string) => {
setLoading(action);
setError(null);
setResult(null);
try {
const body: Record<string, string> = { action };
if (["polish", "expand", "shorten", "fix"].includes(action) && selectedText) {
body.text = selectedText;
} else if (["summary", "tags"].includes(action)) {
body.title = title;
body.content = content;
}
const res = await fetch("/api/ai", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error((await res.json()).error || "AI error");
const data = await res.json();
setResult(data.result);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "请求失败");
} finally {
setLoading(null);
}
};
const apply = () => {
if (!result) return;
if (loading && ["summary", "tags"].includes(loading)) {
// For tags: parse and apply
if (loading === "tags" && onApplyMeta) {
onApplyMeta({ tags: result });
} else if (loading === "summary" && onApplyMeta) {
onApplyMeta({ summary: result });
}
} else {
onApplyText(result);
}
setResult(null);
};
const textActions = [
{ id: "polish", label: "润色", icon: Wand2, desc: "让文字更流畅" },
{ id: "expand", label: "扩写", icon: Zap, desc: "增加细节论述" },
{ id: "shorten", label: "缩写", icon: Shrink, desc: "精简核心信息" },
{ id: "fix", label: "纠错", icon: Check, desc: "修正错别字语法" },
];
const metaActions = [
{ id: "summary", label: "生成摘要", icon: FileText },
{ id: "tags", label: "提取标签", icon: ListFilter },
];
return (
<div>
<h3 className="text-[11px] font-medium text-muted uppercase tracking-wider mb-3">AI 助手</h3>
<div className="bg-white border border-line rounded-lg overflow-hidden">
{/* Text operations */}
<div className="p-3 border-b border-line/50">
<div className="text-[10px] font-medium text-muted/70 uppercase mb-2">文本处理</div>
<div className="space-y-1">
{textActions.map((a) => (
<button
key={a.id}
type="button"
disabled={!!loading}
onClick={() => runAction(a.id)}
className={`w-full flex items-center gap-2 px-2.5 py-1.5 text-[12px] rounded transition-colors ${
selectedText
? "text-text/70 hover:text-accent hover:bg-accent/4"
: "text-muted/30 cursor-not-allowed"
}`}
title={selectedText ? a.desc : "请先选中文字"}
>
{loading === a.id ? (
<Loader2 size={13} className="animate-spin shrink-0" />
) : (
<a.icon size={13} className="shrink-0" />
)}
<span>{a.label}</span>
</button>
))}
</div>
</div>
{/* Meta generation */}
<div className="p-3">
<div className="text-[10px] font-medium text-muted/70 uppercase mb-2">元信息生成</div>
<div className="space-y-1">
{metaActions.map((a) => (
<button
key={a.id}
type="button"
disabled={!!loading || !title}
onClick={() => runAction(a.id)}
className={`w-full flex items-center gap-2 px-2.5 py-1.5 text-[12px] rounded transition-colors ${
title
? "text-text/70 hover:text-accent hover:bg-accent/4"
: "text-muted/30 cursor-not-allowed"
}`}
>
{loading === a.id ? (
<Loader2 size={13} className="animate-spin shrink-0" />
) : (
<a.icon size={13} className="shrink-0" />
)}
<span>{a.label}</span>
</button>
))}
</div>
</div>
</div>
{/* Result panel */}
{(result || error) && (
<div className="mt-3 p-3 bg-white border border-line rounded-lg">
{error ? (
<div className="text-[12px] text-danger">{error}</div>
) : (
<>
<div className="text-[12px] text-text/80 leading-relaxed max-h-[200px] overflow-y-auto whitespace-pre-wrap">
{result}
</div>
<div className="flex items-center gap-2 mt-2">
<button
type="button"
onClick={apply}
className="flex items-center gap-1 px-2.5 py-1 text-[11px] font-medium rounded bg-accent text-white hover:bg-accent-hover transition-colors"
>
<Copy size={11} /> 应用
</button>
<button
type="button"
onClick={() => setResult(null)}
className="px-2.5 py-1 text-[11px] rounded border border-line text-muted hover:bg-surface transition-colors"
>
忽略
</button>
</div>
</>
)}
</div>
)}
{/* API key tip */}
{!selectedText && !title && (
<p className="mt-3 text-[10px] text-muted/50 leading-relaxed">
选中文字后可使用润色/扩写/缩写/纠错。填写标题后可生成摘要和标签。
</p>
)}
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
"use client";
interface PaginationProps {
page: number;
totalPages: number;
onPageChange: (page: number) => void;
}
export default function Pagination({ page, totalPages, onPageChange }: PaginationProps) {
if (totalPages <= 1) return null;
const pages: number[] = [];
const maxShow = 7;
let start = Math.max(1, page - Math.floor(maxShow / 2));
const end = Math.min(totalPages, start + maxShow - 1);
start = Math.max(1, end - maxShow + 1);
for (let i = start; i <= end; i++) pages.push(i);
const btn = "px-2.5 py-1.5 text-[12px] rounded-md border border-line hover:bg-surface transition-colors";
return (
<div className="flex items-center justify-center gap-1 mt-8">
<button type="button" onClick={() => onPageChange(page - 1)} disabled={page <= 1}
className={`${btn} ${page <= 1 ? "opacity-25 cursor-not-allowed" : ""}`}>上一页</button>
{start > 1 && (
<>
<button type="button" onClick={() => onPageChange(1)} className={btn}>1</button>
{start > 2 && <span className="px-1 text-[12px] text-muted">...</span>}
</>
)}
{pages.map((p) => (
<button type="button" key={p} onClick={() => onPageChange(p)}
className={`px-2.5 py-1.5 text-[12px] rounded-md border ${
p === page ? "bg-accent text-white border-accent font-medium" : `border-line hover:bg-surface`
}`}>{p}</button>
))}
{end < totalPages && (
<>
{end < totalPages - 1 && <span className="px-1 text-[12px] text-muted">...</span>}
<button type="button" onClick={() => onPageChange(totalPages)} className={btn}>{totalPages}</button>
</>
)}
<button type="button" onClick={() => onPageChange(page + 1)} disabled={page >= totalPages}
className={`${btn} ${page >= totalPages ? "opacity-25 cursor-not-allowed" : ""}`}>下一页</button>
</div>
);
}
+366
View File
@@ -0,0 +1,366 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { Image, Loader2, Send } from "lucide-react";
import type { PostFrontMatter } from "@/lib/posts";
import ShortcutPanel from "./ShortcutPanel";
import AIPanel from "./AIPanel";
interface PostEditorProps {
initialData?: { frontMatter: PostFrontMatter; content: string; dirPath?: string };
isEditing?: boolean;
savedDirPath?: string;
}
export default function PostEditor({ initialData, isEditing }: PostEditorProps) {
const router = useRouter();
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
const [dirPath, setDirPath] = useState<string | null>(initialData?.dirPath || null);
const today = new Date().toISOString().slice(0, 10);
const [form, setForm] = useState({
title: initialData?.frontMatter.title || "",
slug: initialData?.frontMatter.slug || "",
date: initialData?.frontMatter.date ? new Date(initialData.frontMatter.date).toISOString().slice(0, 10) : today,
categories: initialData?.frontMatter.categories?.join("、") || "",
tags: initialData?.frontMatter.tags?.join("、") || "",
author: initialData?.frontMatter.author || "小赵",
ai_comment: initialData?.frontMatter.ai_comment || "",
});
const [content, setContent] = useState(initialData?.content || "");
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [selectedText, setSelectedText] = useState("");
const [autoSaved, setAutoSaved] = useState(false);
// Auto-save: restore from localStorage on mount
const cacheKey = isEditing ? `draft-edit-${initialData?.frontMatter.slug}` : "draft-new";
useEffect(() => {
if (initialData?.content) return; // Don't override loaded content
try {
const cached = localStorage.getItem(cacheKey);
if (cached) {
const data = JSON.parse(cached);
if (data.title) setForm((f) => ({ ...f, ...data.form }));
if (data.content) setContent(data.content);
if (data.dirPath) setDirPath(data.dirPath);
setAutoSaved(true);
}
} catch {}
}, []);
// Auto-save every 5 seconds
useEffect(() => {
const timer = setInterval(() => {
if (!form.title && !content) return;
try {
localStorage.setItem(cacheKey, JSON.stringify({
form: { title: form.title, slug: form.slug, date: form.date, categories: form.categories, tags: form.tags, author: form.author, ai_comment: form.ai_comment },
content,
dirPath,
savedAt: Date.now(),
}));
setAutoSaved(true);
} catch {}
}, 5000);
return () => clearInterval(timer);
}, [form, content, dirPath, cacheKey]);
// Clear cache on successful save
const clearCache = () => {
try { localStorage.removeItem(cacheKey); } catch {}
setAutoSaved(false);
};
const handleSelectionChange = () => {
const el = textareaRef.current;
if (el) {
setSelectedText(el.value.substring(el.selectionStart, el.selectionEnd));
}
};
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const fd = new FormData();
fd.append("file", file);
if (dirPath) fd.append("dirPath", dirPath);
const res = await fetch("/api/upload", { method: "POST", body: fd });
if (!res.ok) throw new Error("上传失败");
const data = await res.json();
setContent((prev) => prev + "\n" + `![](${data.url})` + "\n");
} catch {
setMessage({ type: "error", text: "图片上传失败" });
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
const savePost = async (draft: boolean): Promise<string | null> => {
const frontMatter: Record<string, unknown> = {
title: form.title,
slug: form.slug,
date: new Date(form.date).toISOString(),
draft,
categories: form.categories.split("、").map((s) => s.trim()).filter(Boolean),
tags: form.tags.split("、").map((s) => s.trim()).filter(Boolean),
author: form.author,
layout: "post",
};
if (form.ai_comment) frontMatter.ai_comment = form.ai_comment;
const url = isEditing ? `/api/posts/${initialData!.frontMatter.slug}` : "/api/posts";
const method = isEditing ? "PUT" : "POST";
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ frontMatter, content }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "保存失败");
}
const data = await res.json();
return data.dirPath || null;
};
const handleDraft = async () => {
setSaving(true); setMessage(null);
try {
const dp = await savePost(true);
if (dp) setDirPath(dp);
setMessage({ type: "success", text: "草稿已保存" });
clearCache();
if (!isEditing) router.push(`/edit/${form.slug}`);
} catch (err: unknown) {
setMessage({ type: "error", text: err instanceof Error ? err.message : "保存失败" });
} finally { setSaving(false); }
};
const handleSave = async () => {
setSaving(true); setMessage(null);
try {
const dp = await savePost(false);
if (dp) setDirPath(dp);
setMessage({ type: "success", text: "已保存到本地" });
clearCache();
if (!isEditing) router.push(`/edit/${form.slug}`);
} catch (err: unknown) {
setMessage({ type: "error", text: err instanceof Error ? err.message : "保存失败" });
} finally { setSaving(false); }
};
const handlePublish = async () => {
setSaving(true); setMessage(null);
try {
// First save with draft:false
const dp = await savePost(false);
if (dp) setDirPath(dp);
// Then deploy via git
setMessage({ type: "success", text: "已保存,正在推送到 GitHub..." });
const deployRes = await fetch("/api/deploy", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: form.title, slug: form.slug }),
});
if (!deployRes.ok) {
const d = await deployRes.json();
throw new Error(d.error || "推送失败");
}
setMessage({ type: "success", text: "已发布并推送到 GitHub" });
clearCache();
setTimeout(() => router.push("/"), 800);
} catch (err: unknown) {
setMessage({ type: "error", text: err instanceof Error ? err.message : "发布失败" });
} finally { setSaving(false); }
};
const handleApplyText = (text: string) => {
const el = textareaRef.current;
if (el) {
const start = el.selectionStart;
const end = el.selectionEnd;
const newVal = content.slice(0, start) + text + content.slice(end);
setContent(newVal);
setSelectedText("");
}
};
const handleApplyMeta = (data: { summary?: string; tags?: string }) => {
if (data.tags) setForm((f) => ({ ...f, tags: data.tags || "" }));
if (data.summary) setForm((f) => ({ ...f, ai_comment: data.summary || "" }));
};
const inputCls =
"w-full bg-white border border-line rounded-md px-3 py-2 text-[13px] text-text placeholder:text-muted/35 focus:outline-none focus:border-accent/30 transition-colors";
return (
<div className="flex gap-8">
<div className="flex-1 min-w-0 space-y-8">
{message && (
<div className={`px-4 py-2.5 rounded-md text-[13px] font-medium ${
message.type === "success"
? "bg-[#d1fae5] text-[#065f46] border border-[#a7f3d0]"
: "bg-[#fee2e2] text-[#991b1b] border border-[#fecaca]"
}`}>
{message.text}
</div>
)}
{/* Section: 元信息 */}
<section>
<h3 className="text-[11px] font-medium text-muted uppercase tracking-wider mb-4">文章信息</h3>
<div className="space-y-3">
<div className="flex gap-3">
<div className="flex-1">
<label className="block text-[12px] font-medium text-text/70 mb-1.5">标题</label>
<input
type="text"
value={form.title}
onChange={(e) => {
setForm({ ...form, title: e.target.value });
if (!isEditing && !form.slug) {
// Slug: YYYYMMDDHHmm numeric timestamp
const now = new Date();
const s = now.getFullYear().toString() +
String(now.getMonth() + 1).padStart(2, "0") +
String(now.getDate()).padStart(2, "0") +
String(now.getHours()).padStart(2, "0") +
String(now.getMinutes()).padStart(2, "0");
setForm((f) => ({ ...f, slug: s }));
}
}}
className={inputCls}
placeholder="文章标题"
/>
</div>
<div className="w-[200px]">
<label className="block text-[12px] font-medium text-text/70 mb-1.5">
Slug <span className="text-[10px] text-muted/50 font-normal">(自动时间戳)</span>
</label>
<input type="text" value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} className={inputCls} placeholder="中文-slug" />
</div>
</div>
<div className="flex gap-3">
<div className="w-[180px]">
<label className="block text-[12px] font-medium text-text/70 mb-1.5">日期</label>
<input type="date" value={form.date} onChange={(e) => setForm({ ...form, date: e.target.value })} className={inputCls} title="日期" />
</div>
<div className="w-[160px]">
<label className="block text-[12px] font-medium text-text/70 mb-1.5">作者</label>
<input type="text" value={form.author} onChange={(e) => setForm({ ...form, author: e.target.value })} className={inputCls} title="作者" />
</div>
<div className="flex-1">
<label className="block text-[12px] font-medium text-text/70 mb-1.5">分类</label>
<input type="text" value={form.categories} onChange={(e) => setForm({ ...form, categories: e.target.value })} className={inputCls} placeholder="用顿号分隔" />
</div>
</div>
<div className="flex gap-3">
<div className="flex-1">
<label className="block text-[12px] font-medium text-text/70 mb-1.5">标签</label>
<input type="text" value={form.tags} onChange={(e) => setForm({ ...form, tags: e.target.value })} className={inputCls} placeholder="用顿号分隔" />
</div>
<div className="flex-1">
<label className="block text-[12px] font-medium text-text/70 mb-1.5">AI 评论</label>
<input type="text" value={form.ai_comment} onChange={(e) => setForm({ ...form, ai_comment: e.target.value })} className={inputCls} placeholder="可选" />
</div>
</div>
{/* Folder name preview */}
<div className="text-[11px] text-muted/60">
文件夹:{form.date}-{form.title.replace(/[^\w一-鿿]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase() || "untitled"}
</div>
</div>
</section>
{/* Section: 正文 */}
<section>
<div className="flex items-center justify-between mb-4">
<h3 className="text-[11px] font-medium text-muted uppercase tracking-wider">正文 · Markdown</h3>
<label className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] rounded-md border border-line text-muted hover:text-accent hover:border-accent/30 cursor-pointer transition-colors">
{uploading ? <Loader2 size={13} className="animate-spin" /> : <Image size={13} />}
{uploading ? "上传中..." : (!dirPath ? "先保存再传图" : "插入图片")}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageUpload}
disabled={!dirPath}
className="hidden"
/>
</label>
</div>
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onSelect={handleSelectionChange}
onKeyUp={handleSelectionChange}
onMouseUp={handleSelectionChange}
rows={30}
className={`${inputCls} resize-y font-mono text-[14px] leading-relaxed`}
placeholder="开始撰写..."
/>
</section>
{/* Actions: 草稿 | 保存 | 发布 */}
<div className="flex items-center gap-3 pt-2">
<button
type="button"
onClick={handleDraft}
disabled={saving}
className="px-4 py-2 text-[13px] rounded-md border border-line text-muted hover:bg-surface disabled:opacity-50 transition-colors"
>
{saving ? "..." : "草稿"}
</button>
<button
type="button"
onClick={handleSave}
disabled={saving}
className="px-4 py-2 text-[13px] rounded-md border border-line text-text hover:bg-surface disabled:opacity-50 transition-colors"
>
{saving ? "..." : "保存"}
</button>
<button
type="button"
onClick={handlePublish}
disabled={saving}
className="flex items-center gap-1.5 px-5 py-2 text-[13px] font-medium rounded-md bg-accent text-white hover:bg-accent-hover disabled:opacity-50 transition-colors"
>
<Send size={13} />
{saving ? "发布中..." : "发布"}
</button>
</div>
<p className="text-[11px] text-muted/50 -mt-4 flex items-center gap-2">
{autoSaved && <span className="text-success">已自动缓存</span>}
草稿:draft:true 不显示 | 保存:存本地 draft:false | 发布:git push 到 GitHub
</p>
</div>
{/* Right: shortcuts + AI panel */}
<div className="w-[180px] shrink-0">
<div className="sticky top-6 space-y-6">
<ShortcutPanel textareaRef={textareaRef} value={content} onChange={setContent} />
<AIPanel
selectedText={selectedText}
onApplyText={handleApplyText}
title={form.title}
content={content}
onApplyMeta={handleApplyMeta}
/>
</div>
</div>
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
"use client";
import { RefObject } from "react";
import { Heading2, Heading3, Code, Quote, Image, Link, Minus, Bold, FileCode } from "lucide-react";
interface ShortcutPanelProps {
textareaRef: RefObject<HTMLTextAreaElement | null>;
value: string;
onChange: (value: string) => void;
}
export default function ShortcutPanel({ textareaRef, value, onChange }: ShortcutPanelProps) {
const insert = (before: string, after: string, placeholder: string) => {
const el = textareaRef.current;
if (!el) {
onChange(value + before + placeholder + after);
return;
}
const start = el.selectionStart;
const end = el.selectionEnd;
const selected = value.slice(start, end) || placeholder;
const newVal = value.slice(0, start) + before + selected + after + value.slice(end);
onChange(newVal);
setTimeout(() => {
const pos = start + before.length + selected.length + after.length;
el.setSelectionRange(start + before.length, pos);
el.focus();
}, 0);
};
const shortcuts = [
{ label: "H2", icon: Heading2, action: () => insert("## ", "\n", "二级标题") },
{ label: "H3", icon: Heading3, action: () => insert("### ", "\n", "三级标题") },
{ label: "粗体", icon: Bold, action: () => insert("**", "**", "粗体文本") },
{ label: "代码块", icon: FileCode, action: () => insert("```\n", "\n```\n", "代码") },
{ label: "行内代码", icon: Code, action: () => insert("`", "`", "code") },
{ label: "引用", icon: Quote, action: () => insert("> ", "\n", "引用内容") },
{ label: "图片", icon: Image, action: () => insert("![", "]()", "描述") },
{ label: "链接", icon: Link, action: () => insert("[", "]()", "链接文本") },
{ label: "分割线", icon: Minus, action: () => insert("\n---\n", "", "") },
];
return (
<div>
<h3 className="text-[11px] font-medium text-muted uppercase tracking-wider mb-3">快捷插入</h3>
<div className="bg-white border border-line rounded-lg p-3 space-y-1">
{shortcuts.map((s) => (
<button
key={s.label}
type="button"
onClick={s.action}
className="w-full flex items-center gap-2.5 px-2.5 py-2 text-[12px] text-text/70 hover:text-accent hover:bg-accent/4 rounded transition-colors"
title={s.label}
>
<s.icon size={14} className="shrink-0" />
<span>{s.label}</span>
</button>
))}
</div>
<div className="mt-4 p-3 bg-surface border border-line/50 rounded-lg">
<h4 className="text-[10px] font-medium text-muted uppercase tracking-wider mb-2">说明</h4>
<ul className="text-[11px] text-muted/70 space-y-1 leading-relaxed">
<li>选中文字后点按钮可包裹</li>
<li>未选中则插入模板 + 占位文字</li>
<li>代码块自动补全语言标注</li>
</ul>
</div>
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { FileText, PenSquare, MessageSquare, Trash2, Rss, Link2, MessageCircle, Image } from "lucide-react";
const navItems = [
{ href: "/", label: "文章", icon: FileText },
{ href: "/edit", label: "撰写", icon: PenSquare },
{ href: "/comments", label: "评论", icon: MessageSquare },
{ href: "/feeds", label: "订阅源", icon: Rss },
{ href: "/links", label: "友链", icon: Link2 },
{ href: "/images", label: "图片", icon: Image },
{ href: "/wechat", label: "公众号", icon: MessageCircle },
{ href: "/recycle", label: "回收站", icon: Trash2 },
];
export default function Sidebar() {
const pathname = usePathname();
const isActive = (href: string) => {
if (href === "/") return pathname === "/";
return pathname.startsWith(href);
};
return (
<aside className="w-1/4 min-w-[200px] max-w-[260px] bg-surface border-r border-line flex flex-col shrink-0">
{/* Logo */}
<div className="px-5 py-5 border-b border-line">
<h1 className="text-[15px] font-bold text-accent tracking-wider">
写作后台
</h1>
<p className="text-[11px] text-muted/70 mt-0.5">优世界</p>
</div>
{/* Nav */}
<nav className="flex-1 px-2.5 py-3 space-y-0.5">
{navItems.map((item) => {
const active = isActive(item.href);
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-2.5 px-3 py-2 rounded-md text-[13px] transition-colors ${
active
? "bg-accent text-white font-medium"
: "text-text/80 hover:bg-bg hover:text-accent"
}`}
>
<item.icon size={15} strokeWidth={active ? 2.5 : 1.8} />
{item.label}
</Link>
);
})}
</nav>
{/* Footer */}
<div className="px-4 py-3 border-t border-line">
<p className="text-[10px] text-muted/50">localhost:8016</p>
</div>
</aside>
);
}
+69
View File
@@ -0,0 +1,69 @@
const DEEPSEEK_BASE = process.env.DEEPSEEK_BASE_URL || "https://api.deepseek.com";
const DEEPSEEK_KEY = process.env.DEEPSEEK_API_KEY || "";
const DEEPSEEK_MODEL = process.env.DEEPSEEK_MODEL || "deepseek-chat";
const DEEPSEEK_ANTHROPIC = process.env.DEEPSEEK_ANTHROPIC === "true";
export async function aiChat(
systemPrompt: string,
userMessage: string
): Promise<string> {
if (!DEEPSEEK_KEY) throw new Error("DeepSeek API key not configured");
let url: string;
let headers: Record<string, string>;
let body: Record<string, unknown>;
if (DEEPSEEK_ANTHROPIC) {
// Anthropic Messages API
url = `${DEEPSEEK_BASE}/v1/messages`;
headers = {
"Content-Type": "application/json",
"x-api-key": DEEPSEEK_KEY,
"anthropic-version": "2023-06-01",
};
body = {
model: DEEPSEEK_MODEL,
max_tokens: 2048,
system: systemPrompt,
messages: [{ role: "user", content: userMessage }],
};
} else {
// OpenAI-compatible API (default)
url = `${DEEPSEEK_BASE}/v1/chat/completions`;
headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${DEEPSEEK_KEY}`,
};
body = {
model: DEEPSEEK_MODEL,
temperature: 0.7,
max_tokens: 2048,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userMessage },
],
};
}
const res = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as { error?: { message?: string } }).error?.message || `API error: ${res.status}`);
}
const data = await res.json();
// Anthropic format or OpenAI format
const text = data?.content?.[0]?.text || data?.choices?.[0]?.message?.content || "";
if (!text) throw new Error("AI returned empty response");
return text;
}
export const AI_PROMPTS = {
polish: "你是一个文字润色助手。请润色以下文字,使其更流畅、更有文采,但保持原意和语气不变。直接返回润色后的文字,不要加任何解释。",
expand: "你是一个写作助手。请扩写以下文字,增加更多细节和论述,使其更充实。保持原风格和语气。直接返回扩写后的文字,不要加任何解释。",
shorten: "你是一个写作助手。请缩写以下文字,保留核心信息,删除冗余。直接返回缩写后的文字,不要加任何解释。",
fix: "你是一个文字校对助手。请修正以下文字中的错别字、语法错误和标点问题。直接返回修正后的文字,不要加任何解释。",
summary: "根据以下文章标题和内容,生成一个简短的摘要(100字以内)。直接返回摘要,不要加任何解释。\n\n标题:{title}\n\n内容片段:{content}",
tags: "根据以下文章标题和内容,提取3-5个中文标签(用顿号分隔)。只返回标签,不要加解释。\n\n标题:{title}\n\n内容片段:{content}",
};
+165
View File
@@ -0,0 +1,165 @@
import { ARTALK_SERVER, ARTALK_SITE } from "./config";
// Artalk API v2 types
export interface ArtalkComment {
id: number;
content: string;
nick: string;
email: string;
page_key: string;
page_title: string;
user_id: number;
ua: string;
ip: string;
is_collapsed: boolean;
is_pending: boolean;
is_pinned: boolean;
vote_up: number;
vote_down: number;
rid: number;
date: string;
updated_at: string;
badge_word?: string;
is_allow_reply?: boolean;
children?: ArtalkComment[];
}
export interface ArtalkPage {
id: number;
admin_only: boolean;
key: string;
title: string;
url: string;
view: number;
comment_count: number;
date: string;
}
let authToken: string | null = null;
export function setAuthToken(token: string | null) {
authToken = token;
}
export function getAuthToken(): string | null {
return authToken;
}
export async function login(
email: string,
password: string
): Promise<{ token: string; user: { name: string; email: string } }> {
const url = `${ARTALK_SERVER}/api/v2/auth/email/login`;
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const json = await res.json();
if (!json || !json.token) {
throw new Error(json.msg || "登录失败");
}
authToken = json.token as string;
return { token: authToken, user: json.user };
}
export async function getPages(options?: {
offset?: number;
limit?: number;
type?: "all" | "admin_only";
}): Promise<{ pages: ArtalkPage[]; total: number }> {
const { offset = 0, limit = 50, type = "all" } = options || {};
const params = new URLSearchParams({
site_name: ARTALK_SITE,
offset: offset.toString(),
limit: limit.toString(),
type,
});
const url = `${ARTALK_SERVER}/api/v2/pages?${params.toString()}`;
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (authToken) headers["Authorization"] = `Bearer ${authToken}`;
const res = await fetch(url, { headers });
const json = await res.json();
if (json.msg && !json.pages && !json.count) {
throw new Error(json.msg);
}
return { pages: json.pages || json.data || [], total: json.count || json.total || 0 };
}
export async function getComments(
pageKey: string,
options?: { offset?: number; limit?: number }
): Promise<{ comments: ArtalkComment[]; total: number }> {
const { offset = 0, limit = 100 } = options || {};
const params = new URLSearchParams({
site_name: ARTALK_SITE,
page_key: pageKey,
offset: offset.toString(),
limit: limit.toString(),
flat_mode: "true",
sort_by: "date_desc",
});
const url = `${ARTALK_SERVER}/api/v2/comments?${params.toString()}`;
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (authToken) headers["Authorization"] = `Bearer ${authToken}`;
const res = await fetch(url, { headers });
const json = await res.json();
if (json.msg && !json.comments && !json.count) {
throw new Error(json.msg);
}
return { comments: json.comments || json.data || [], total: json.count || json.total || 0 };
}
async function artalkMutate<T>(endpoint: string, method: string, body: Record<string, unknown>): Promise<T> {
const url = `${ARTALK_SERVER}${endpoint}`;
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (authToken) headers["Authorization"] = `Bearer ${authToken}`;
const res = await fetch(url, { method, headers, body: JSON.stringify(body) });
const json = await res.json();
if (json.msg) {
throw new Error(json.msg);
}
return (json.data || json) as T;
}
export async function updateComment(
id: number,
data: {
content?: string;
is_collapsed?: boolean;
is_pending?: boolean;
is_pinned?: boolean;
rid?: number;
}
): Promise<ArtalkComment> {
return artalkMutate<ArtalkComment>(`/api/v2/comments/${id}`, "PUT", { ...data, site_name: ARTALK_SITE });
}
export async function createComment(data: {
page_key: string;
content: string;
name: string;
email: string;
rid?: number;
}): Promise<ArtalkComment> {
return artalkMutate<ArtalkComment>("/api/v2/comments", "POST", { ...data, site_name: ARTALK_SITE });
}
export async function deleteComment(id: number): Promise<void> {
await artalkMutate<void>(`/api/v2/comments/${id}`, "DELETE", { site_name: ARTALK_SITE });
}
+16
View File
@@ -0,0 +1,16 @@
import path from "path";
export const BLOG_ROOT = path.resolve(process.cwd(), "..");
export const CONTENT_DIR = path.join(BLOG_ROOT, "content", "post");
export const RECYCLE_DIR = path.join(process.cwd(), ".recycle");
export const RECYCLE_POSTS_DIR = path.join(RECYCLE_DIR, "posts");
export const RECYCLE_META_DIR = path.join(RECYCLE_DIR, "meta");
export const ARTALK_SERVER = "https://artalk.usj.cc";
export const ARTALK_SITE = "优世界";
export const ITEMS_PER_PAGE = 20;
export const RECYCLE_DAYS = 30;
export const HUGO_PREVIEW = "http://localhost:1313";
export const RSS_API_BASE = process.env.RSS_API_BASE || "https://rssapi.usj.cc";
export const RSS_API_TOKEN = process.env.RSS_API_TOKEN || "";
export const WECHAT_APP_ID = process.env.WECHAT_APP_ID || "";
export const WECHAT_APP_SECRET = process.env.WECHAT_APP_SECRET || "";
+259
View File
@@ -0,0 +1,259 @@
import fs from "fs";
import path from "path";
import matter from "gray-matter";
import { CONTENT_DIR, ITEMS_PER_PAGE } from "./config";
export interface PostFrontMatter {
pid?: number;
title: string;
date: string;
slug: string;
draft: boolean;
categories?: string[];
tags?: string[];
author?: string;
layout?: string;
ai_comment?: string;
[key: string]: unknown;
}
export interface Post {
slug: string;
frontMatter: PostFrontMatter;
content: string;
filePath: string;
dirPath: string;
}
export interface PostListItem {
slug: string;
title: string;
date: string;
draft: boolean;
categories: string[];
tags: string[];
author: string;
}
function parseDate(dateStr: string): Date {
const d = new Date(dateStr);
return isNaN(d.getTime()) ? new Date(0) : d;
}
function findPostDirectories(): string[] {
const result: string[] = [];
if (!fs.existsSync(CONTENT_DIR)) return result;
const years = fs.readdirSync(CONTENT_DIR).filter((f) => {
const full = path.join(CONTENT_DIR, f);
return fs.statSync(full).isDirectory() && /^\d{4}$/.test(f);
});
for (const year of years) {
const yearDir = path.join(CONTENT_DIR, year);
const posts = fs.readdirSync(yearDir).filter((f) => {
return fs.statSync(path.join(yearDir, f)).isDirectory();
});
for (const post of posts) {
result.push(path.join(yearDir, post));
}
}
return result;
}
function slugFromDirName(dirName: string): string {
// e.g. "2026-01-21-how-to-implement-friend-circle-in-hugo" -> "how-to-implement-friend-circle-in-hugo"
const match = dirName.match(/^\d{4}-\d{2}-\d{2}-(.+)$/);
return match ? match[1] : dirName;
}
function yearFromDirName(dirName: string): string {
const match = dirName.match(/^(\d{4})/);
return match ? match[1] : "";
}
export function listPosts(options?: {
search?: string;
page?: number;
perPage?: number;
includeDrafts?: boolean;
}): { posts: PostListItem[]; total: number; totalPages: number } {
const { search = "", page = 1, perPage = ITEMS_PER_PAGE, includeDrafts = true } = options || {};
const dirs = findPostDirectories();
const allPosts: PostListItem[] = [];
for (const dirPath of dirs) {
const indexPath = path.join(dirPath, "index.md");
if (!fs.existsSync(indexPath)) continue;
const raw = fs.readFileSync(indexPath, "utf-8");
const { data } = matter(raw);
const dirName = path.basename(dirPath);
const post: PostListItem = {
slug: String(data.slug || slugFromDirName(dirName)),
title: data.title || "Untitled",
date: data.date ? new Date(data.date).toISOString() : "",
draft: data.draft === true,
categories: Array.isArray(data.categories) ? data.categories : [],
tags: Array.isArray(data.tags) ? data.tags : [],
author: data.author || "",
};
if (!includeDrafts && post.draft) continue;
if (search) {
const q = search.toLowerCase();
const match =
post.title.toLowerCase().includes(q) ||
post.slug.toLowerCase().includes(q) ||
post.categories.some((c) => c.toLowerCase().includes(q)) ||
post.tags.some((t) => t.toLowerCase().includes(q));
if (!match) continue;
}
allPosts.push(post);
}
// Sort by date descending
allPosts.sort((a, b) => parseDate(b.date).getTime() - parseDate(a.date).getTime());
const total = allPosts.length;
const totalPages = Math.ceil(total / perPage);
const start = (page - 1) * perPage;
const posts = allPosts.slice(start, start + perPage);
return { posts, total, totalPages };
}
export function getPost(slug: string): Post | null {
const dirs = findPostDirectories();
for (const dirPath of dirs) {
const indexPath = path.join(dirPath, "index.md");
if (!fs.existsSync(indexPath)) continue;
const raw = fs.readFileSync(indexPath, "utf-8");
const { data, content } = matter(raw);
const existingSlug = String(data.slug || slugFromDirName(path.basename(dirPath)));
if (existingSlug === slug) {
return {
slug: existingSlug,
frontMatter: data as PostFrontMatter,
content,
filePath: indexPath,
dirPath,
};
}
}
return null;
}
export function createPost(frontMatter: PostFrontMatter, content: string): string {
const date = new Date(frontMatter.date || new Date());
const year = date.getFullYear().toString();
const dateStr = date.toISOString().slice(0, 10);
// Folder name: date + sanitized title (Chinese-friendly)
const titlePart = (frontMatter.title || "untitled")
.replace(/[^\w一-鿿㐀-䶿]+/g, "-")
.replace(/^-+|-+$/g, "")
.toLowerCase() || "post";
const dirName = `${dateStr}-${titlePart}`;
const dirPath = path.join(CONTENT_DIR, year, dirName);
if (fs.existsSync(dirPath)) {
throw new Error(`Post already exists at ${dirPath}`);
}
fs.mkdirSync(dirPath, { recursive: true });
const fm: Record<string, unknown> = {
title: frontMatter.title,
date: frontMatter.date,
slug: frontMatter.slug,
draft: frontMatter.draft ?? false,
categories: frontMatter.categories || [],
tags: frontMatter.tags || [],
author: frontMatter.author || "",
layout: frontMatter.layout || "post",
};
if (frontMatter.ai_comment) {
fm.ai_comment = frontMatter.ai_comment;
}
const fileContent = matter.stringify(content, fm);
const indexPath = path.join(dirPath, "index.md");
fs.writeFileSync(indexPath, fileContent, "utf-8");
return dirPath;
}
export function updatePost(
slug: string,
frontMatter: Partial<PostFrontMatter>,
content?: string
): { success: true; dirPath: string } | { success: false } {
const post = getPost(slug);
if (!post) return { success: false };
const merged = { ...post.frontMatter, ...frontMatter };
// If slug changed, we need to rename the directory
if (frontMatter.slug && frontMatter.slug !== post.frontMatter.slug) {
const newDate = new Date(merged.date || post.frontMatter.date);
const newYear = newDate.getFullYear().toString();
const newDateStr = newDate.toISOString().slice(0, 10);
const newDirName = `${newDateStr}-${frontMatter.slug}`;
const newDirPath = path.join(CONTENT_DIR, newYear, newDirName);
if (fs.existsSync(newDirPath) && newDirPath !== post.dirPath) {
throw new Error(`Target path already exists: ${newDirPath}`);
}
fs.mkdirSync(path.dirname(newDirPath), { recursive: true });
fs.renameSync(post.dirPath, newDirPath);
const fm: Record<string, unknown> = {
title: merged.title,
date: merged.date,
slug: frontMatter.slug,
draft: merged.draft ?? false,
categories: merged.categories || [],
tags: merged.tags || [],
author: merged.author || "",
layout: merged.layout || "post",
};
if (merged.ai_comment) fm.ai_comment = merged.ai_comment;
const fileContent = matter.stringify(content ?? post.content, fm);
fs.writeFileSync(path.join(newDirPath, "index.md"), fileContent, "utf-8");
return { success: true, dirPath: newDirPath };
}
// Update in place
const fm: Record<string, unknown> = {
title: merged.title,
date: merged.date,
slug: merged.slug,
draft: merged.draft ?? false,
categories: merged.categories || [],
tags: merged.tags || [],
author: merged.author || "",
layout: merged.layout || "post",
};
if (merged.ai_comment) fm.ai_comment = merged.ai_comment;
const fileContent = matter.stringify(content ?? post.content, fm);
fs.writeFileSync(post.filePath, fileContent, "utf-8");
return { success: true, dirPath: post.dirPath };
}
export function getPostCount(): number {
return findPostDirectories().filter((dir) =>
fs.existsSync(path.join(dir, "index.md"))
).length;
}
+146
View File
@@ -0,0 +1,146 @@
import fs from "fs";
import path from "path";
import {
RECYCLE_DIR,
RECYCLE_POSTS_DIR,
RECYCLE_META_DIR,
RECYCLE_DAYS,
} from "./config";
export interface RecycleItem {
slug: string;
title: string;
deletedAt: string; // ISO date
originalPath: string;
expiresAt: string; // ISO date
daysRemaining: number;
}
export function initRecycleBin(): void {
if (!fs.existsSync(RECYCLE_DIR)) {
fs.mkdirSync(RECYCLE_DIR, { recursive: true });
}
if (!fs.existsSync(RECYCLE_POSTS_DIR)) {
fs.mkdirSync(RECYCLE_POSTS_DIR, { recursive: true });
}
if (!fs.existsSync(RECYCLE_META_DIR)) {
fs.mkdirSync(RECYCLE_META_DIR, { recursive: true });
}
}
export function moveToRecycle(
slug: string,
sourceDir: string,
title: string
): void {
initRecycleBin();
const now = new Date();
const expires = new Date(now);
expires.setDate(expires.getDate() + RECYCLE_DAYS);
const meta: Omit<RecycleItem, "daysRemaining"> = {
slug,
title,
deletedAt: now.toISOString(),
originalPath: sourceDir,
expiresAt: expires.toISOString(),
};
// Move post directory to recycle
const targetDir = path.join(RECYCLE_POSTS_DIR, slug);
if (fs.existsSync(targetDir)) {
fs.rmSync(targetDir, { recursive: true, force: true });
}
fs.renameSync(sourceDir, targetDir);
// Save metadata
const metaPath = path.join(RECYCLE_META_DIR, `${slug}.json`);
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), "utf-8");
}
export function listRecycle(): RecycleItem[] {
initRecycleBin();
if (!fs.existsSync(RECYCLE_META_DIR)) return [];
const items: RecycleItem[] = [];
const files = fs.readdirSync(RECYCLE_META_DIR).filter((f) => f.endsWith(".json"));
for (const file of files) {
const metaPath = path.join(RECYCLE_META_DIR, file);
const raw = fs.readFileSync(metaPath, "utf-8");
const meta = JSON.parse(raw);
const now = new Date();
const expires = new Date(meta.expiresAt);
const daysRemaining = Math.max(
0,
Math.ceil((expires.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
);
items.push({ ...meta, daysRemaining });
}
items.sort(
(a, b) =>
new Date(b.deletedAt).getTime() - new Date(a.deletedAt).getTime()
);
return items;
}
export function restoreFromRecycle(slug: string): boolean {
const metaPath = path.join(RECYCLE_META_DIR, `${slug}.json`);
if (!fs.existsSync(metaPath)) return false;
const raw = fs.readFileSync(metaPath, "utf-8");
const meta = JSON.parse(raw);
const recyclePostPath = path.join(RECYCLE_POSTS_DIR, slug);
if (!fs.existsSync(recyclePostPath)) return false;
// Ensure target directory exists
const targetDir = path.dirname(meta.originalPath);
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
// If target already exists, remove it first
if (fs.existsSync(meta.originalPath)) {
fs.rmSync(meta.originalPath, { recursive: true, force: true });
}
fs.renameSync(recyclePostPath, meta.originalPath);
fs.unlinkSync(metaPath);
return true;
}
export function permanentDelete(slug: string): boolean {
const metaPath = path.join(RECYCLE_META_DIR, `${slug}.json`);
const recyclePostPath = path.join(RECYCLE_POSTS_DIR, slug);
let deleted = false;
if (fs.existsSync(recyclePostPath)) {
fs.rmSync(recyclePostPath, { recursive: true, force: true });
deleted = true;
}
if (fs.existsSync(metaPath)) {
fs.unlinkSync(metaPath);
deleted = true;
}
return deleted;
}
export function cleanExpired(): number {
const items = listRecycle();
let count = 0;
for (const item of items) {
if (item.daysRemaining <= 0) {
permanentDelete(item.slug);
count++;
}
}
return count;
}
+190
View File
@@ -0,0 +1,190 @@
import { RSS_API_BASE, RSS_API_TOKEN, WECHAT_APP_ID, WECHAT_APP_SECRET } from "./config";
async function rssFetch<T>(path: string, options?: RequestInit): Promise<T> {
const url = `${RSS_API_BASE}${path}`;
const res = await fetch(url, options);
if (!res.ok) throw new Error(`RSS API error: ${res.status}`);
return res.json();
}
function withToken(params?: URLSearchParams): string {
const p = params || new URLSearchParams();
if (RSS_API_TOKEN) p.set("token", RSS_API_TOKEN);
return p.toString();
}
// ---- Types ----
export interface Feed {
url: string;
feedTitle: string;
siteUrl?: string;
error?: string;
}
export interface FeedHealth {
url: string;
status: number;
ok: boolean;
latency?: number;
error?: string;
}
export interface Link {
name: string;
url: string;
description?: string;
image?: string;
hidden?: boolean;
rss?: string;
addedAt?: string;
}
export interface LinkHealth {
url: string;
ok: boolean;
status?: number;
latency?: number;
error?: string;
}
// ---- Feeds API ----
export async function getFeeds(): Promise<Feed[]> {
const data = await rssFetch<{ feeds: Feed[] }>("/api/feeds");
return data.feeds || [];
}
export async function addFeed(url: string, feedTitle: string): Promise<void> {
const qs = withToken();
await rssFetch(`/api/feeds?${qs}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url, feedTitle }),
});
}
export async function deleteFeed(url: string): Promise<void> {
const qs = withToken(new URLSearchParams({ url, token: RSS_API_TOKEN }));
await rssFetch(`/api/feeds?${qs}`, { method: "DELETE" });
}
export async function getFeedsHealth(): Promise<{
total: number; alive: number; dead: number;
results: FeedHealth[];
}> {
return rssFetch("/api/health");
}
// ---- Links API ----
export async function getLinks(all = false): Promise<Link[]> {
const p = new URLSearchParams();
if (all) p.set("all", "1");
const data = await rssFetch<{ links: Link[] }>(`/api/links?${p.toString()}`);
return data.links || [];
}
export async function addLink(link: { name: string; url: string; description?: string; hidden?: boolean }): Promise<void> {
const qs = withToken();
await rssFetch(`/api/links?${qs}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(link),
});
}
export async function updateLink(url: string, data: { name?: string; description?: string; hidden?: boolean }): Promise<void> {
// RSS API uses POST as upsert
const qs = withToken();
await rssFetch(`/api/links?${qs}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url, ...data }),
});
}
export async function deleteLink(url: string): Promise<void> {
const qs = withToken(new URLSearchParams({ url, token: RSS_API_TOKEN }));
await rssFetch(`/api/links?${qs}`, { method: "DELETE" });
}
// ---- SCF Proxy ----
const SCF_PROXY = process.env.SCF_PROXY || "https://scfapi.usj.cc";
export async function checkLinkHealth(targetUrl: string): Promise<LinkHealth> {
const start = Date.now();
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 12000);
const res = await fetch(targetUrl, {
method: "HEAD",
signal: controller.signal,
headers: { "User-Agent": "Mozilla/5.0 (compatible; BlogAdmin/1.0)" },
});
clearTimeout(timer);
return { url: targetUrl, ok: res.ok || res.status < 500, status: res.status, latency: Date.now() - start };
} catch (err: unknown) {
return { url: targetUrl, ok: false, error: err instanceof Error ? err.message : "timeout", latency: Date.now() - start };
}
}
export async function checkLinkViaProxy(targetUrl: string): Promise<LinkHealth> {
const start = Date.now();
try {
const res = await fetch(SCF_PROXY, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: targetUrl, timeout: 12000 }),
});
const data = await res.json();
return {
url: targetUrl,
ok: data.ok && (data.status < 500),
status: data.status,
latency: Date.now() - start,
error: data.ok ? undefined : data.error,
};
} catch (err: unknown) {
return { url: targetUrl, ok: false, error: err instanceof Error ? err.message : "proxy error", latency: Date.now() - start };
}
}
// ---- WeChat Material API ----
export interface WechatArticle {
title: string;
thumb_media_url?: string;
thumb_media_id?: string;
author?: string;
digest?: string;
show_cover_pic?: number;
content: string;
content_source_url?: string;
content_image_urls?: { original_url: string }[];
need_open_comment?: number;
only_fans_can_comment?: number;
}
export async function getWechatStatus(): Promise<{ configured: boolean; appId?: string }> {
const data = await rssFetch<{ configured: boolean; appId?: string }>("/api/wechat-material");
return data;
}
export async function syncWechat(articles: Record<string, unknown>[]): Promise<{ ok: boolean; media_id?: string; message?: string }> {
const qs = withToken();
const body: Record<string, unknown> = { articles };
if (WECHAT_APP_ID) body.appId = WECHAT_APP_ID;
if (WECHAT_APP_SECRET) body.appSecret = WECHAT_APP_SECRET;
const res = await fetch(`${RSS_API_BASE}/api/wechat-material?${qs}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok || !data.ok) {
throw new Error(data.error || data.message || `WeChat API error: ${res.status}`);
}
return data;
}
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}