12 Commits
Author SHA1 Message Date
wangairnan b1ea89ba3d fix(ci): 发版重跑幂等:刷新发布说明并替换旧附件
Release / release (push) Successful in 5m16s
同 tag 重跑时 POST 冲突走查询回退,需 PATCH 以本次构建为准补写
发布说明,并先清空旧附件再重传,避免同名副本。
2026-09-09 23:31:26 +08:00
wangairnan 9b0f2785cf fix(ci): 修复 release id 提取的 node 语法错误
Release / release (push) Successful in 5m15s
node -e 少写一个右括号导致 Release 创建后资产上传前失败。
改经环境变量传入响应 JSON,不再用 stdin 拼串;
已存在的同 tag release 走查询回退,重跑即可补传产物。
2026-09-09 23:24:13 +08:00
wangairnan ccc858bfdd fix(ci): 标签名与 token 改经表达式注入
Release / release (push) Failing after 4m17s
该 runner(Forgejo 系 v3.3.2)未导出 GITEA_REF_NAME 等别名环境变量
(checkout 因空 pathspec 失败)。改为 job 级 env 显式表达式注入:
TAG=${{ github.ref_name }},token 按 GITHUB_TOKEN/GITEA_TOKEN/FORGEJO_TOKEN 依次回退。
2026-09-09 23:17:18 +08:00
wangairnan 2be5d4d25b fix(ci): 发版工作流改为零 action 纯 run 实现
Release / release (push) Failing after 13s
runner 实例地址注册为 localhost:3000,job 容器内不可达,导致所有
JS 类 action(checkout/setup-go/release)无法工作。改为:
- git clone 公网域名检出,tarball 安装 Go(go.dev 失败回退中国镜像)
- 容器固定 node:24-bookworm(runner 默认镜像无 node)
- curl 调 Gitea API 创建 Release 并上传产物,npm/Go 模块代理双层回退
2026-09-09 23:09:21 +08:00
wangairnan 95cac4688d fix(ci): 发版 job 显式使用 node 容器镜像
Release / release (push) Failing after 2m8s
runner 默认镜像无 node,checkout/setup-go/release 等 JS 类 action
第一步即失败(Cannot find: node in PATH)。改用 node:24-bookworm
镜像(自带 node 与 git),并省去 setup-node 步骤。
2026-09-09 23:00:32 +08:00
wangairnan ad4a87fc9f feat(ci): 基于 v* 标签的多架构自动发版
Release / release (push) Failing after 8s
- 新增 .gitea/workflows/release.yml:推送 v* 标签触发,构建前端嵌入后
  交叉编译 linux/darwin(amd64+arm64),生成变更日志并创建 Gitea Release
- 新增 scripts/build-release.sh:make dist 与 CI 共用的产物构建脚本,
  tar.gz 打包 + SHA256SUMS;不含 windows(internal/store 依赖 Unix Umask)
- Makefile 增加 dist 目标;版本注入沿用 git describe + ldflags(pn version)
2026-09-09 22:46:12 +08:00
wangairnan 92d89717c5 refactor(web): 残留自制控件统一替换为 antd 组件
- 状态点自制徽标改用 Badge,回收站剩余天数紧急态改用红色 Tag
- 危险按钮改用 danger 属性并补充语言,text+danger 不套实心红样式
- 后台搜索框改 antd Input 前缀形态,删除手写容器与聚焦环样式
- 加载态统一 Spin/Skeleton(Table 用 loading),空态统一 Empty
- Segmented 选项计数改用 Badge count,弱化为 muted 灰
- 首页分页说明改 Pagination showTotal,笔记错误态改 Empty 承接布局
- 代码块复制按钮改 antd Button 并增加「已复制」反馈,修复悬浮不显示
- 博客/后台图标按钮与溢出菜单触发器统一为 antd Button type=text
2026-09-09 15:37:36 +08:00
wangairnan 969d537aee fix(web): 后台侧栏「新建笔记」上移至首位,移除管理员信息中的「站点所有者」
侧栏 Menu 与移动端 tabbar 同步调换,编辑页高亮新建入口的逻辑不变。
2026-09-09 12:25:10 +08:00
wangairnan 614bfe2852 fix(web): 后台侧栏选中项改黑底反白,修复明暗模式下选中态看不清
自定义主题 colorPrimary 为近黑/近白,antd Menu 选中项默认的
primary 淡色底与侧栏底色对比不足;选中/hover 态改用 sidebar
token(黑底反白/浅灰底),并覆盖此前对未选中文字色的误覆盖。
2026-09-09 12:09:33 +08:00
wangairnan 5bac272f6d feat: 前台标签接入 antd 组件、列表封面图、favicon 随站点 Logo 及侧栏收窄
- 前台列表/详情/标签页标签统一 antd Tag;标签页改 CheckableTagGroup 单选
  (选中态由组件承接,再点已选标签回到全部);移除首页标签筛选条,
  标签入口收敛至顶部「标签」页
- 公开列表新增 cover 字段(取正文第一张站内图,store.FirstImageRef),
  首页条目左侧显示封面缩略图,无图条目保持纯文字
- favicon 随站点 Logo:webui.Meta.Icon 服务端注入 index.html,
  前端 useSiteTitle 兜底(dev 模板占位符场景)并即时生效;
  设置页「重置默认」清空 Logo 后回退内置 /favicon.svg
- 后台侧栏改 antd Menu(inline 分组),宽度 256→208、窄侧栏档 224→184
2026-09-09 11:51:06 +08:00
wangairnan 5457aeaa33 feat: 新增发布日期自选、站点 Logo、回收站清空等十项改进
- 编辑页标题改 filled 变体;标签改 tags 选择器(可勾选既有/输入新建)
- 笔记发布日期可自选(schema v2 新增 published_at 并回填),前台展示发布日期
- 登录页移除「请输入管理密码以继续」「连续失败将被暂时锁定」文案
- 站点设置每页条数收敛为 10/20/30/50 选择器,后端白名单同源校验
- 站点设置两卡片宽屏左右/窄屏上下;修改密码按钮改常规大小
- 后台各页顶栏固定高度,切换页面不再抖动
- 站点 Logo:settings 新增 site_logo(站内路径),gc/孤儿清单豁免 Logo 图片,
  博客 header 站点名前展示,设置页支持上传/更换/清除
- 回收站右上角一键清空(DELETE /api/admin/trash,含确认弹窗)
- pn init 写入 Markdown 语法示例文档(slug welcome,公开)与程序生成的示例图片
2026-09-09 11:10:30 +08:00
wangairnan 87d9bab5dc feat(web): 整体替换 UI 框架为 Ant Design 6 并接入自定义主题
以 antd ConfigProvider + antd-style 承载 zinc 风格自定义主题(明暗双套 token),
button/input/select/notification 用 createStyles 定制;三态主题经
darkAlgorithm 无刷新切换;use-toast 改基于 antd notification;
移除 shadcn/ui 组件目录与 radix 依赖;样式桥接层保证排版与多端布局不变。
2026-09-09 10:35:24 +08:00
46 changed files with 3826 additions and 2146 deletions
+109
View File
@@ -0,0 +1,109 @@
# 发版工作流:推送 v* 标签 → 构建前端并嵌入 → 交叉编译多平台二进制
# → 生成发布说明 → 通过 Gitea API 创建 Release 并上传产物。
#
# 发版操作:
# git tag -a v0.0.1 -m "v0.0.1"
# git push origin v0.0.1
#
# 实现说明:刻意不使用任何 JS 类 action(checkout/setup-*/release-*)——
# 自托管 runner 常把实例地址注册成 localhost,job 容器内不可达,
# 且默认容器镜像可能没有 node。此处全程纯 run 步骤 + 公网域名访问实例,
# 容器固定 node:24-bookworm(自带 node 与 git,npm 构建前端所需)。
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
env:
SITE: https://git.airnan.cn
REPO: wangairnan/pure-note
jobs:
release:
runs-on: ubuntu-latest
container:
image: node:24-bookworm
env:
# 这台 runner 只保证 GITHUB_* 兼容层(GITEA_REF_NAME 等别名不可靠),
# 一律经表达式显式注入,不直接读 GITEA_* 环境变量
TAG: ${{ github.ref_name }}
TOKEN: ${{ secrets.GITHUB_TOKEN || secrets.GITEA_TOKEN || secrets.FORGEJO_TOKEN }}
steps:
# 完整历史:供 git log 生成上一个 tag 以来的变更日志
- name: 检出代码
run: |
git clone "$SITE/$REPO.git" .
git checkout --detach "$TAG"
git log --oneline -1
- name: 安装 Go(版本取自 go.mod)
run: |
set -euo pipefail
GOVER=$(awk '$1 == "go" {print $2}' go.mod)
echo "==> 安装 Go $GOVER"
# go.dev 在部分网络不可达,回退官方中国镜像
curl -fsSL "https://go.dev/dl/go${GOVER}.linux-amd64.tar.gz" -o /tmp/go.tgz \
|| curl -fsSL "https://golang.google.cn/dl/go${GOVER}.linux-amd64.tar.gz" -o /tmp/go.tgz
tar -C /usr/local -xzf /tmp/go.tgz
/usr/local/go/bin/go version
- name: 构建前端并嵌入(web/dist → internal/webui/dist)
run: make web sync-assets || (npm config set registry https://registry.npmmirror.com && make web sync-assets)
- name: 交叉编译发布产物
run: |
set -euo pipefail
export PATH=/usr/local/go/bin:$PATH
# 模块代理双层回退:官方代理不可达时走 goproxy.cn
export GOPROXY=https://proxy.golang.org,https://goproxy.cn,direct
scripts/build-release.sh "$TAG"
# 首个 tag 无上一版本,变更日志段自动省略
- name: 生成发布说明
run: |
set -euo pipefail
prev=$(git describe --tags --abbrev=0 "HEAD^" 2>/dev/null || true)
short=$(git rev-parse --short HEAD)
{
echo "## pn ${TAG}"
echo
echo "- commit: \`$short\`"
echo "- 构建时间: $(date -u '+%Y-%m-%dT%H:%M:%SZ')"
if [ -n "$prev" ]; then
echo
echo "### 自 ${prev} 以来的变更"
echo
git log --oneline --no-decorate "${prev}..HEAD" | sed 's/^/- /'
fi
} > release-notes.md
cat release-notes.md
# 重跑幂等:release 已存在时更新说明、清旧附件后重传
- name: 创建 Release 并上传产物
run: |
set -euo pipefail
API="$SITE/api/v1/repos/$REPO"
AUTH="Authorization: token ${TOKEN}"
REQ=$(node -e 'const fs=require("fs");process.stdout.write(JSON.stringify({tag_name:process.env.TAG,name:"pn "+process.env.TAG,body:fs.readFileSync("release-notes.md","utf8")}))')
# 同 tag release 已存在(如失败重跑)时回退为查询
REL=$(curl -fsS -X POST "$API/releases" -H "$AUTH" -H 'Content-Type: application/json' -d "$REQ") \
|| REL=$(curl -fsS "$API/releases/tags/${TAG}" -H "$AUTH")
RID=$(REL="$REL" node -e 'process.stdout.write(String(JSON.parse(process.env.REL).id))')
echo "==> release id=$RID"
# 发布说明以本次构建为准(回退路径下 POST 已冲突,须补写)
curl -fsS -X PATCH "$API/releases/$RID" -H "$AUTH" -H 'Content-Type: application/json' -d "$REQ" >/dev/null
# 清除旧附件,避免重跑产生同名副本
ASSETS=$(curl -fsS "$API/releases/$RID/assets" -H "$AUTH")
ASSETS="$ASSETS" node -e 'JSON.parse(process.env.ASSETS).forEach(a=>console.log(a.id))' | while read -r aid; do
curl -fsS -X DELETE "$API/releases/$RID/assets/$aid" -H "$AUTH" >/dev/null
done
for f in dist/*; do
echo "==> 上传 $(basename "$f")"
curl -fsS -X POST "$API/releases/$RID/assets?name=$(basename "$f")" -H "$AUTH" -F "attachment=@$f" >/dev/null
done
echo "==> 完成:$SITE/$REPO/releases/tag/${TAG}"
+3
View File
@@ -13,5 +13,8 @@ internal/webui/dist/*
/pn /pn
/pn-linux-* /pn-linux-*
# 发布产物(make dist / CI 发版构建输出)
/dist/
# 系统文件 # 系统文件
.DS_Store .DS_Store
+10 -1
View File
@@ -5,17 +5,21 @@
# make sync-assets 拷贝 web/dist → internal/webui/dist(go:embed 约束,§8.3-1) # make sync-assets 拷贝 web/dist → internal/webui/dist(go:embed 约束,§8.3-1)
# make build 全量构建(前端 + 单二进制 pn) # make build 全量构建(前端 + 单二进制 pn)
# make linux 交叉编译 linux/amd64 # make linux 交叉编译 linux/amd64
# make dist 全平台发布产物 → dist/(VERSION ?= git describe,与 CI 发版共用脚本)
# make test Go 测试 + 前端测试 # make test Go 测试 + 前端测试
# make smoke 真实构建冒烟测试(§8.3-6) # make smoke 真实构建冒烟测试(§8.3-6)
# make dev 双进程热重载(air + vite dev) # make dev 双进程热重载(air + vite dev)
# make vulncheck govulncheck # make vulncheck govulncheck
#
# 发版:git tag -a v0.0.1 -m "v0.0.1" && git push origin v0.0.1
# (推送 v* 标签触发 .gitea/workflows/release.yml 自动构建并创建 Release)
VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev) VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev)
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none) COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none)
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildDate=$(DATE) LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildDate=$(DATE)
.PHONY: web sync-assets build linux test smoke dev vulncheck clean .PHONY: web sync-assets build linux dist test smoke dev vulncheck clean
web: web:
cd web && npm ci && npm run build cd web && npm ci && npm run build
@@ -33,6 +37,10 @@ linux: web sync-assets
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="$(LDFLAGS)" \ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="$(LDFLAGS)" \
-o pn-linux-amd64 ./cmd/pn -o pn-linux-amd64 ./cmd/pn
# 全平台发布产物(版本注入逻辑与 CI 发版共用 scripts/build-release.sh)
dist: web sync-assets
scripts/build-release.sh "$(VERSION)"
test: test:
go vet ./... go vet ./...
go test ./... go test ./...
@@ -51,3 +59,4 @@ vulncheck:
clean: clean:
rm -f pn pn-linux-amd64 rm -f pn pn-linux-amd64
rm -rf dist
+25 -7
View File
@@ -7,12 +7,12 @@
## 功能 ## 功能
- **笔记**:Markdown CRUD、粘贴/拖拽图片上传(≤5MB、魔数校验、BLOB 入库去重)、公开/私有两态、置顶、标签 - **笔记**:Markdown CRUD、粘贴/拖拽图片上传(≤5MB、魔数校验、BLOB 入库去重)、公开/私有两态、置顶、标签、发布日期可自选
- **博客**:首页(置顶优先 + 分页 + 标签云)、详情(上一篇/下一篇)、标签页、RSS、sitemap、SEO meta 注入(仅公开内容) - **博客**:首页(置顶优先 + 分页 + 标签云)、详情(上一篇/下一篇)、标签页、RSS、sitemap、SEO meta 注入(仅公开内容);站点名称前可显示自定义 Logo
- **管理**:单管理员口令登录(Argon2id)、会话 7 天滑动续期(CSRF 轮换保持不变)、防爆破限流(per-IP + per-账号)、改密 - **管理**:单管理员口令登录(Argon2id)、会话 7 天滑动续期(CSRF 轮换保持不变)、防爆破限流(per-IP + per-账号)、改密
- **回收站**:删除 = 软删除,30 天内可恢复,`gc` 到期物理清除 - **回收站**:删除 = 软删除,30 天内可恢复,可一键清空,`gc` 到期物理清除
- **界面**:9 页面响应式 SPA(React 19 + Tailwind 4),深色/浅色/跟随系统三态主题 - **界面**:9 页面响应式 SPA(React 19 + Tailwind 4 + Ant Design 6),深色/浅色/跟随系统三态主题
- **运维**:`init` / `passwd`(重设口令并吊销全部会话)/ `start` / `backup`(在线一致快照)/ `gc`(默认 dry-run)/ `version`(亦可 `-v`) - **运维**:`init`(含 Markdown 示例文档与示例图片种子)/ `passwd`(重设口令并吊销全部会话)/ `start` / `backup`(在线一致快照)/ `gc`(默认 dry-run)/ `version`(亦可 `-v`)
## 快速开始 ## 快速开始
@@ -20,7 +20,7 @@
# 1. 构建单二进制(前端 + 后端) # 1. 构建单二进制(前端 + 后端)
make build make build
# 2. 初始化(设置管理员口令与站点标题) # 2. 初始化(设置管理员口令与站点标题,自动写入一篇 Markdown 示例文档)
./pn init ./pn init
# 非交互:PN_ADMIN_PASSWORD=xxx PN_SITE_TITLE=yyy ./pn init # 非交互:PN_ADMIN_PASSWORD=xxx PN_SITE_TITLE=yyy ./pn init
@@ -78,6 +78,22 @@ sudo systemctl enable --now pn pn-maint.timer
**恢复 SOP**:停服 → 删除 `data/pn.db-wal` 与 `-shm` → 以备份文件替换 `pn.db` → 起服。 **恢复 SOP**:停服 → 删除 `data/pn.db-wal` 与 `-shm` → 以备份文件替换 `pn.db` → 起服。
## 版本与发版
版本号以 git tag 为唯一来源(语义化版本,从 `v0.0.1` 开始),构建时经 ldflags 注入,
`./pn version` 可查看:
```bash
git tag -a v0.0.1 -m "v0.0.1"
git push origin v0.0.1 # 推送 v* 标签即触发自动发版
```
推送 `v*` 标签后,Gitea Actions([.gitea/workflows/release.yml](.gitea/workflows/release.yml))
自动构建前端并嵌入,交叉编译 linux/darwin(amd64 + arm64)共 4 个产物,
连同 SHA256SUMS 与上一版本以来的变更日志一起发布到仓库的 Release 页面。
本地验证发布产物:`make dist`(版本缺省 `git describe --tags --always`,输出到 `dist/`)。
## 架构 ## 架构
``` ```
@@ -91,7 +107,9 @@ internal/httpapi/ 路由与 handler(公共 / 认证 / 管理 / feed)
internal/webui/ go:embed 前端产物 + SPA fallback + index.html meta 注入 internal/webui/ go:embed 前端产物 + SPA fallback + index.html meta 注入
web/ React 19 + Vite + Tailwind 4 前端 web/ React 19 + Vite + Tailwind 4 前端
├─ src/pages/ 9 页面:首页 / 笔记详情 / 标签页 / 登录 / 后台列表 / 编辑 / 回收站 / 设置 / 404 ├─ src/pages/ 9 页面:首页 / 笔记详情 / 标签页 / 登录 / 后台列表 / 编辑 / 回收站 / 设置 / 404
├─ src/components/ Layout(导航与主题切换)、Editor、MarkdownViewer、Toast ├─ src/components/ Layout(导航与主题切换)、Editor、MarkdownViewer
├─ src/theme/ shadcnTheme(antd ConfigProvider 主题:品牌 token + 明暗算法 + 桥接样式)
├─ src/hooks/ use-toast(基于 antd notification 的操作反馈)
└─ src/lib/ api(CSRF 注入)/ auth(会话上下文)/ theme(三态主题)/ sanitize / invalidate └─ src/lib/ api(CSRF 注入)/ auth(会话上下文)/ theme(三态主题)/ sanitize / invalidate
deploy/ systemd 单元 ×3 + Caddyfile deploy/ systemd 单元 ×3 + Caddyfile
``` ```
+4
View File
@@ -226,6 +226,10 @@ func runInit(args []string) error {
if err := st.SetSetting(store.KeySiteTitle, title); err != nil { if err := st.SetSetting(store.KeySiteTitle, title); err != nil {
return err return err
} }
// 种子内容:一篇 Markdown 语法示例文档(公开)+ 自动生成的示例图片
if _, err := seedWelcome(st); err != nil {
return fmt.Errorf("写入初始文档失败: %w", err)
}
fmt.Println("初始化完成。现在可以启动:pn start --data-dir", cfg.DataDir) fmt.Println("初始化完成。现在可以启动:pn start --data-dir", cfg.DataDir)
return nil return nil
} }
+183
View File
@@ -0,0 +1,183 @@
// init 种子内容:一篇展示常用 Markdown 语法的初始文档 + 一张程序生成的示例图片。
package main
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"image"
"image/color"
"image/png"
"time"
"pure-note/internal/store"
)
// welcomeSlug 固定 slug,便于用户识别与重装后覆盖判断(init 幂等由口令守卫保证)。
const welcomeSlug = "welcome"
// seedWelcome 写入初始文档(公开)与其引用的示例图片;返回写入的笔记 id。
func seedWelcome(st *store.Store) (int64, error) {
imgID, err := seedWelcomeImage(st)
if err != nil {
return 0, fmt.Errorf("写入示例图片: %w", err)
}
content := welcomeMarkdown(imgID)
now := time.Now().Unix()
n := &store.Note{
Slug: welcomeSlug,
Title: "欢迎使用 Pure Note:Markdown 速览",
Summary: "一篇入门笔记,覆盖日常写作会用到的全部 Markdown 语法:标题、列表、引用、代码、表格与图片。",
Content: content,
Status: "public",
Tags: []string{"Markdown", "入门"},
PublishedAt: now,
CreatedAt: now,
UpdatedAt: now,
}
id, err := st.CreateNote(n)
if err != nil {
return 0, err
}
if err := st.RebuildImageRefs(id, content); err != nil {
return 0, err
}
return id, nil
}
// welcomeMarkdown 初始文档正文:覆盖常用语法,并引用示例图片。
func welcomeMarkdown(imgID int64) string {
return fmt.Sprintf(`这是一篇初始化时自动创建的示例笔记,覆盖日常写作会用到的绝大部分 Markdown 语法。随时可以编辑或删除它。
## 基础排版
普通段落之间用一个空行分隔。行内语法有:**加粗**、*斜体*、***粗斜体***、~~删除线~~,以及行内代码 `+"`pn start`"+`。也可以直接粘贴链接:<https://github.com>
## 标题层级
### 三级标题
#### 四级标题
标题会进入文章大纲,正文建议从二级标题用起。
## 列表
无序列表:
- 支持嵌套
- 子项缩进两个空格
- 快捷键 `+"`Ctrl/⌘ + S`"+` 可手动保存
有序列表:
1. 在编辑器里写 Markdown
2. 粘贴图片直接上传
3. 点击发布即可访问
任务列表:
- [x] 初始化站点
- [x] 阅读本文档
- [ ] 写第一篇笔记
## 引用
> 简单,可靠,只有一个数据文件。
> —— Pure Note 设计哲学
## 代码块
代码块支持语法高亮:
`+"```go"+`
package main
import "fmt"
func main() {
fmt.Println("Hello, Pure Note!")
}
`+"```"+`
`+"```"+`javascript
const note = { title: "你好", tags: ["Markdown"] };
console.log(`+"`开始写作:${note.title}`"+`);
`+"```"+`
## 表格
| 语法 | 用途 | 示例 |
| --- | --- | --- |
| **文本** | 加粗 | **重要** |
| `+"`代码`"+` | 行内代码 | `+"`npm run dev`"+` |
| [文本](url) | 超链接 | [首页](/) |
## 图片
在编辑器里直接粘贴或拖入图片即可上传(≤5MB),下面这张就是初始化时自动生成的示例图:
![示例图片](/api/images/%d)
## 其他
---
私有笔记仅自己可见,公开笔记会出现在首页与 RSS 中;删除的笔记进入回收站保留 30 天。祝写作愉快。
`, imgID)
}
// seedWelcomeImage 生成一张对角渐变 PNG(zinc 色系),入库后返回图片 id。
func seedWelcomeImage(st *store.Store) (int64, error) {
data, err := gradientPNG()
if err != nil {
return 0, err
}
return st.UpsertImage(sha256Hex(data), "image/png", len(data), data, time.Now().Unix())
}
// gradientPNG 绘制 960×360 对角渐变:zinc-950 → zinc-400,叠加一条高光斜带。
func gradientPNG() ([]byte, error) {
const w, h = 960, 360
img := image.NewNRGBA(image.Rect(0, 0, w, h))
from := color.RGBA{R: 24, G: 24, B: 27, A: 255} // #18181b
to := color.RGBA{R: 161, G: 161, B: 170, A: 255} // #a1a1aa
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
t := (float64(x)/float64(w-1) + float64(y)/float64(h-1)) / 2
// 高光斜带:在 t≈0.55 附近提亮,形成柔和的光泽过渡
band := 0.16 * float64(1) / (1 + pow2((t-0.55)*9))
c := color.RGBA{
R: lerp(from.R, to.R, t+band),
G: lerp(from.G, to.G, t+band),
B: lerp(from.B, to.B, t+band),
A: 255,
}
img.Set(x, y, c)
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func lerp(a, b uint8, t float64) uint8 {
if t < 0 {
t = 0
} else if t > 1 {
t = 1
}
return uint8(float64(a) + (float64(b)-float64(a))*t)
}
func pow2(x float64) float64 { return x * x }
func sha256Hex(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
+17 -13
View File
@@ -58,8 +58,8 @@
| F3 | 图片 | 编辑器内粘贴/拖拽上传(≤5MB);Markdown 中引用;图片可见性 = 引用它的笔记可见性的并集;去重存储 | | F3 | 图片 | 编辑器内粘贴/拖拽上传(≤5MB);Markdown 中引用;图片可见性 = 引用它的笔记可见性的并集;去重存储 |
| F4 | 博客 | 匿名访问:首页(公开列表+分页+标签)、详情页、标签页;RSS Feed;sitemap.xml;SEO 基础(仅公开笔记注入 title/description/og meta) | | F4 | 博客 | 匿名访问:首页(公开列表+分页+标签)、详情页、标签页;RSS Feed;sitemap.xml;SEO 基础(仅公开笔记注入 title/description/og meta) |
| F5 | 认证 | 单管理员密码登录/登出/**改密**;会话失效/续期;登录与改密防爆破 | | F5 | 认证 | 单管理员密码登录/登出/**改密**;会话失效/续期;登录与改密防爆破 |
| F6 | 站点设置 | 站点标题、副标题、每页条数(白名单字段,绝不含口令哈希) | | F6 | 站点设置 | 站点标题、副标题、每页条数(10/20/30/50)、站点 Logo(白名单字段,绝不含口令哈希) |
| F7 | 回收站 | 删除 = 软删除进入回收站;30 天内可恢复;`gc` 到期物理清除 | | F7 | 回收站 | 删除 = 软删除进入回收站;30 天内可恢复;可一键清空;`gc` 到期物理清除 |
| F8 | 运维 | 单二进制启动;`--data-dir` 指定数据目录;在线备份子命令;`gc` 子命令(默认 dry-run);版本号 | | F8 | 运维 | 单二进制启动;`--data-dir` 指定数据目录;在线备份子命令;`gc` 子命令(默认 dry-run);版本号 |
### 2.2 非功能需求 ### 2.2 非功能需求
@@ -100,13 +100,13 @@
| 框架 | React | 19.2.8 | 生态最大、md 渲染/编辑器组件最丰富 | — | | 框架 | React | 19.2.8 | 生态最大、md 渲染/编辑器组件最丰富 | — |
| 语言 | TypeScript | 7.x(不动则 5.9) | 原生编译器版已可用;若第三方库兼容有问题回退 5.9 | — | | 语言 | TypeScript | 7.x(不动则 5.9) | 原生编译器版已可用;若第三方库兼容有问题回退 5.9 | — |
| 构建 | Vite | 8.2.2 | 事实标准;部署在域名根,`base` 用默认值 `'/'`(相对 base 与 SPA 深链冲突,见 §8.3 与审评报告 P0-1) | — | | 构建 | Vite | 8.2.2 | 事实标准;部署在域名根,`base` 用默认值 `'/'`(相对 base 与 SPA 深链冲突,见 §8.3 与审评报告 P0-1) | — |
| 样式/组件 | Tailwind CSS 4 + **shadcn/ui** | 4.3.3 / CLI 4.21.0 | CSS-first 无样板配置;shadcn 复制式组件零运行时、克制美观,正合内容型博客;按需叠加 Radix 原语 | Ant Design 6(重、后台味浓)、Chakra 3、HeroUI、Radix Themes | | 样式/组件 | Tailwind CSS 4 + **Ant Design 6** | 4.3.3 / 6.6.3 | Tailwind 负责页面排版与设计 token;交互组件统一用 antd(Button/Input/Table/Modal/Dropdown/Tooltip/notification 等),经 ConfigProvider 自定义主题对齐黑白灰品牌 token 并随三态主题切换明暗算法;v6 原生支持 React 19 | shadcn/ui + Radix(曾采用,组件需自维护,替换为 antd 降低长期维护成本)、Chakra 3、HeroUI |
| 路由 | react-router | 8.3.1 | library mode 成熟,博客+后台两个区足够 | TanStack Router 1.x(类型安全更强,但本项目路由简单) | | 路由 | react-router | 8.3.1 | library mode 成熟,博客+后台两个区足够 | TanStack Router 1.x(类型安全更强,但本项目路由简单) |
| 数据请求 | TanStack Query | 5.102.8 | 十几 kB 换来统一 loading/缓存/重试,值得 | 裸 fetch + 自写 hook | | 数据请求 | TanStack Query | 5.102.8 | 十几 kB 换来统一 loading/缓存/重试,值得 | 裸 fetch + 自写 hook |
| Markdown 渲染 | react-markdown + remark-gfm + rehype-sanitize + **rehype-highlight** | 10.1.0 / 4.0.1 / 6.0.0 / 7.0.2 | 组合标准;高亮选 rehype-highlight(**纯 class 输出、零内联样式**)而非 shiki(inline style),与严格 CSP 兼容 | shiki 4.4.3(准确度最高但产 inline style)、prism-react-renderer(近停更,不推荐) | | Markdown 渲染 | react-markdown + remark-gfm + rehype-sanitize + **rehype-highlight** | 10.1.0 / 4.0.1 / 6.0.0 / 7.0.2 | 组合标准;高亮选 rehype-highlight(**纯 class 输出、零内联样式**)而非 shiki(inline style),与严格 CSP 兼容 | shiki 4.4.3(准确度最高但产 inline style)、prism-react-renderer(近停更,不推荐) |
| Markdown 编辑器 | **CodeMirror 6**(@uiw/react-codemirror + @codemirror/lang-markdown) | 4.25.11 / 6.5.2 | 源码编辑 + 分屏实时预览,零魔法、可预期;图片粘贴自写 handler(≤50 行) | Tiptap 3.31(体验派,WYSIWYG+成熟图片上传,文档全);MDXEditor(重);ByteMD(字节已弃坑)、Vditor 4(体积大、心智旧) | | Markdown 编辑器 | **CodeMirror 6**(@uiw/react-codemirror + @codemirror/lang-markdown) | 4.25.11 / 6.5.2 | 源码编辑 + 分屏实时预览,零魔法、可预期;图片粘贴自写 handler(≤50 行) | Tiptap 3.31(体验派,WYSIWYG+成熟图片上传,文档全);MDXEditor(重);ByteMD(字节已弃坑)、Vditor 4(体积大、心智旧) |
| 状态管理 | React Query + Context(**不引入** zustand) | — | 会话态 + 服务端缓存已覆盖;确需跨页 UI 态再加 zustand(≈1kB) | redux(不必要) | | 状态管理 | React Query + Context(**不引入** zustand) | — | 会话态 + 服务端缓存已覆盖;确需跨页 UI 态再加 zustand(≈1kB) | redux(不必要) |
| 图标 | lucide-react | 1.42.0 | 活跃、风格统一、与 shadcn 默认匹配 | — | | 图标 | lucide-react | 1.42.0 | 活跃、风格统一(antd 组件内亦复用) | — |
### 3.3 关键决策记录(ADR 摘要) ### 3.3 关键决策记录(ADR 摘要)
@@ -194,9 +194,11 @@ pure-note/
│ └── src/ │ └── src/
│ ├── main.tsx / App.tsx / router.tsx │ ├── main.tsx / App.tsx / router.tsx
│ ├── lib/ # api client、auth、csrf、格式化 │ ├── lib/ # api client、auth、csrf、格式化
│ ├── components/ # shadcn/ui 生成目录 + 业务组件 │ ├── theme/ # antd ConfigProvider 主题(品牌 token + 明暗算法)
│ ├── components/ # 业务组件
│ │ ├── markdown/ # MarkdownViewer(渲染管线,可替换) │ │ ├── markdown/ # MarkdownViewer(渲染管线,可替换)
│ │ └── editor/ # Editor(CodeMirror)+ ImagePaste + Toolbar │ │ └── editor/ # Editor(CodeMirror)+ ImagePaste + Toolbar
│ ├── hooks/ # use-toast(antd notification 封装)
│ ├── features/ # notes / admin 业务 hooks │ ├── features/ # notes / admin 业务 hooks
│ └── pages/ # Home / Note / Tags / AdminLogin / AdminList / AdminTrash / AdminEdit │ └── pages/ # Home / Note / Tags / AdminLogin / AdminList / AdminTrash / AdminEdit
└── deploy/ └── deploy/
@@ -230,7 +232,8 @@ CREATE TABLE IF NOT EXISTS notes (
pinned INTEGER NOT NULL DEFAULT 0, pinned INTEGER NOT NULL DEFAULT 0,
deleted_at INTEGER, -- NULL=正常;非空=回收站(软删除) deleted_at INTEGER, -- NULL=正常;非空=回收站(软删除)
created_at INTEGER NOT NULL, -- Unix 秒 created_at INTEGER NOT NULL, -- Unix 秒
updated_at INTEGER NOT NULL updated_at INTEGER NOT NULL,
published_at INTEGER -- 发布日期(可自选);v2 迁移回填为 created_at
); );
CREATE INDEX IF NOT EXISTS idx_notes_public CREATE INDEX IF NOT EXISTS idx_notes_public
ON notes (status, pinned, updated_at DESC); ON notes (status, pinned, updated_at DESC);
@@ -265,7 +268,7 @@ CREATE TABLE IF NOT EXISTS settings (
); );
-- 键白名单(§7.1 SettingsDTO 同源): -- 键白名单(§7.1 SettingsDTO 同源):
-- admin_password_hash : PHC 串 $argon2id$v=19$m=19456,t=2,p=1$<b64salt>$<b64hash> -- admin_password_hash : PHC 串 $argon2id$v=19$m=19456,t=2,p=1$<b64salt>$<b64hash>
-- site_title / site_desc / page_size -- site_title / site_desc / page_size / beian_no / site_logo(站内绝对路径)
-- admin_password_hash 永不进入任何 API 响应、不可经 settings 接口写入(§9.3) -- admin_password_hash 永不进入任何 API 响应、不可经 settings 接口写入(§9.3)
``` ```
@@ -324,10 +327,11 @@ CREATE TABLE IF NOT EXISTS settings (
| PUT | `/api/admin/notes/{id}` | 更新(事务内重建 image_refs);slug 冲突返回 409 + 字段级错误 | | PUT | `/api/admin/notes/{id}` | 更新(事务内重建 image_refs);slug 冲突返回 409 + 字段级错误 |
| DELETE | `/api/admin/notes/{id}` | **软删除**:置 `deleted_at` 进入回收站 | | DELETE | `/api/admin/notes/{id}` | **软删除**:置 `deleted_at` 进入回收站 |
| GET | `/api/admin/trash` | 回收站列表 | | GET | `/api/admin/trash` | 回收站列表 |
| DELETE | `/api/admin/trash` | **清空回收站**:物理删除全部软删除笔记(refs 级联) |
| POST | `/api/admin/trash/{id}/restore` | 恢复:清空 `deleted_at`(slug 仍被自身占用,无冲突;外部占用则在 30 天内不可能,见 §6.2) | | POST | `/api/admin/trash/{id}/restore` | 恢复:清空 `deleted_at`(slug 仍被自身占用,无冲突;外部占用则在 30 天内不可能,见 §6.2) |
| POST | `/api/admin/images` | multipart 上传 → `{id, url}`;≤5MB | | POST | `/api/admin/images` | multipart 上传 → `{id, url}`;≤5MB |
| GET | `/api/admin/images?orphan=1` | 0 引用图片清单(删除前检视,实际清除由 gc 执行) | | GET | `/api/admin/images?orphan=1` | 0 引用图片清单(删除前检视,实际清除由 gc 执行) |
| GET/PUT | `/api/admin/settings` | **SettingsDTO 白名单**(site_title/site_desc/page_size),读永不序列化 `admin_password_hash`、写只收白名单键 | | GET/PUT | `/api/admin/settings` | **SettingsDTO 白名单**(site_title/site_desc/page_size/beian_no/site_logo;page_size 仅 10/20/30/50),读永不序列化 `admin_password_hash`、写只收白名单键 |
| POST | `/api/admin/password` | `{old_password, new_password}`;校验旧密码(常量时间)、新密码 ≥12 字符;复用登录限流 | | POST | `/api/admin/password` | `{old_password, new_password}`;校验旧密码(常量时间)、新密码 ≥12 字符;复用登录限流 |
### 7.2 中间件链与服务器参数 ### 7.2 中间件链与服务器参数
@@ -377,7 +381,7 @@ SecurityHeaders(含 HSTS,§9.2)
| 子命令 / 开关 | 说明 | | 子命令 / 开关 | 说明 |
| --- | --- | | --- | --- |
| `pn start` | 启动;`--addr :8080`、`--data-dir`(默认 `./data`)、`--log-level`、`--log-format text|json`、`--behind-proxy`(声明位于可信反代之后,启用 XFF 处理)、`--dev`(loopback-only,§7.3-8)、`--allow-newer`(跳过 schema 版本上界校验,§10.4);环境变量:`PN_ADMIN_PASSWORD` | | `pn start` | 启动;`--addr :8080`、`--data-dir`(默认 `./data`)、`--log-level`、`--log-format text|json`、`--behind-proxy`(声明位于可信反代之后,启用 XFF 处理)、`--dev`(loopback-only,§7.3-8)、`--allow-newer`(跳过 schema 版本上界校验,§10.4);环境变量:`PN_ADMIN_PASSWORD` |
| `pn init` | 首次初始化:设口令(Argon2id+PHC)、站点标题 | | `pn init` | 首次初始化:设口令(Argon2id+PHC)、站点标题;并写入一篇 Markdown 语法示例文档(公开,slug `welcome`)及程序生成的示例图片 |
| `pn passwd` | 重设管理员口令(覆盖旧哈希)并吊销全部会话;口令来源同 `init`(环境变量 `PN_ADMIN_PASSWORD` 或交互输入,不经 argv 避免 ps 泄露)。CLI 可达即具备服务器权限,允许直接重设 | | `pn passwd` | 重设管理员口令(覆盖旧哈希)并吊销全部会话;口令来源同 `init`(环境变量 `PN_ADMIN_PASSWORD` 或交互输入,不经 argv 避免 ps 泄露)。CLI 可达即具备服务器权限,允许直接重设 |
| `pn backup [path]` | 在线备份:`VACUUM INTO`(一致快照,不停服);默认输出 0600 权限 | | `pn backup [path]` | 在线备份:`VACUUM INTO`(一致快照,不停服);默认输出 0600 权限 |
| `pn gc` | 回收站过期清除 + 孤儿图片清除 + 会话清理;**默认 `--dry-run`**,`--commit` 才执行(§6.3) | | `pn gc` | 回收站过期清除 + 孤儿图片清除 + 会话清理;**默认 `--dry-run`**,`--commit` 才执行(§6.3) |
@@ -396,8 +400,8 @@ SecurityHeaders(含 HSTS,§9.2)
| `/tags/:tag` | 标签页 | 该标签下公开笔记 | | `/tags/:tag` | 标签页 | 该标签下公开笔记 |
| `/admin/login` | 登录页 | 口令登录、防爆破提示 | | `/admin/login` | 登录页 | 口令登录、防爆破提示 |
| `/admin` | 管理列表 | 全部笔记(公/私标签可见)、状态开关、新建、删除(入回收站) | | `/admin` | 管理列表 | 全部笔记(公/私标签可见)、状态开关、新建、删除(入回收站) |
| `/admin/trash` | 回收站 | 已删列表、恢复 | | `/admin/trash` | 回收站 | 已删列表、恢复、一键清空 |
| `/admin/notes/new` `/admin/notes/:id/edit` | 编辑器 | CodeMirror 源码编辑 + 实时预览(分屏/切换)、元信息侧栏(标题/slug/标签/摘要/公开开关/置顶)、图片粘贴上传 | | `/admin/notes/new` `/admin/notes/:id/edit` | 编辑器 | CodeMirror 源码编辑 + 实时预览(分屏/切换)、元信息侧栏(标题/slug/标签选择器/发布日期/摘要/公开开关/置顶)、图片粘贴上传 |
| `*` | 404 | 统一兜底 | | `*` | 404 | 统一兜底 |
### 8.2 核心技术要点 ### 8.2 核心技术要点
@@ -705,7 +709,7 @@ timer 触发 `pn-maint.service`:先 `pn gc --commit`,后 `pn backup /backup/
| react / react-dom | 19.2.8 | UI 框架 | | react / react-dom | 19.2.8 | UI 框架 |
| vite / @vitejs/plugin-react | 8.2.2 | 构建(base 默认 `'/'`) | | vite / @vitejs/plugin-react | 8.2.2 | 构建(base 默认 `'/'`) |
| tailwindcss + @tailwindcss/vite + @tailwindcss/typography | 4.3.3 | 样式体系 | | tailwindcss + @tailwindcss/vite + @tailwindcss/typography | 4.3.3 | 样式体系 |
| shadcn/ui(CLI)+ Radix primitives | 4.21.0 | 组件库 | | antd + antd-style | 6.6.3 / 4.1.0 | 组件库(ConfigProvider 自定义主题 + createStyles 桥接) |
| react-router | 8.3.1 | 路由 | | react-router | 8.3.1 | 路由 |
| @tanstack/react-query | 5.102.8 | 服务端状态 | | @tanstack/react-query | 5.102.8 | 服务端状态 |
| react-markdown + remark-gfm + rehype-sanitize + rehype-highlight | 10.1.0 / 4.0.1 / 6.0.0 / 7.0.2 | Markdown 渲染与防 XSS | | react-markdown + remark-gfm + rehype-sanitize + rehype-highlight | 10.1.0 / 4.0.1 / 6.0.0 / 7.0.2 | Markdown 渲染与防 XSS |
@@ -719,7 +723,7 @@ timer 触发 `pn-maint.service`:先 `pn gc --commit`,后 `pn backup /backup/
- https://go.dev/dl/ · https://pkg.go.dev/modernc.org/sqlite - https://go.dev/dl/ · https://pkg.go.dev/modernc.org/sqlite
- https://github.com/yuin/goldmark · https://github.com/microcosm-cc/bluemonday - https://github.com/yuin/goldmark · https://github.com/microcosm-cc/bluemonday
- https://github.com/facebook/react · https://github.com/vitejs/vite - https://github.com/facebook/react · https://github.com/vitejs/vite
- https://github.com/shadcn-ui/ui · https://github.com/remix-run/react-router - https://github.com/ant-design/ant-design · https://github.com/remix-run/react-router
- https://github.com/remarkjs/react-markdown · https://github.com/rehypejs/rehype-sanitize - https://github.com/remarkjs/react-markdown · https://github.com/rehypejs/rehype-sanitize
- https://github.com/ueberdosis/tiptap · https://github.com/uiwjs/react-codemirror - https://github.com/ueberdosis/tiptap · https://github.com/uiwjs/react-codemirror
+56 -22
View File
@@ -27,16 +27,17 @@ import (
// adminNoteItem 管理列表项(不含全文)。 // adminNoteItem 管理列表项(不含全文)。
type adminNoteItem struct { type adminNoteItem struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Slug string `json:"slug"` Slug string `json:"slug"`
Title string `json:"title"` Title string `json:"title"`
Summary string `json:"summary"` Summary string `json:"summary"`
Status string `json:"status"` Status string `json:"status"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
Pinned bool `json:"pinned"` Pinned bool `json:"pinned"`
DeletedAt *int64 `json:"deleted_at,omitempty"` DeletedAt *int64 `json:"deleted_at,omitempty"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"` UpdatedAt int64 `json:"updated_at"`
PublishedAt int64 `json:"published_at"`
} }
func toAdminItem(n *store.Note) adminNoteItem { func toAdminItem(n *store.Note) adminNoteItem {
@@ -44,6 +45,7 @@ func toAdminItem(n *store.Note) adminNoteItem {
ID: n.ID, Slug: n.Slug, Title: n.Title, Summary: n.Summary, ID: n.ID, Slug: n.Slug, Title: n.Title, Summary: n.Summary,
Status: n.Status, Tags: n.Tags, Pinned: n.Pinned, Status: n.Status, Tags: n.Tags, Pinned: n.Pinned,
DeletedAt: n.DeletedAt, CreatedAt: n.CreatedAt, UpdatedAt: n.UpdatedAt, DeletedAt: n.DeletedAt, CreatedAt: n.CreatedAt, UpdatedAt: n.UpdatedAt,
PublishedAt: n.PublishedAt,
} }
} }
@@ -61,15 +63,16 @@ func (s *Server) handleAdminNotes(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": len(items)}) writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": len(items)})
} }
// notePayload 笔记写请求体。 // notePayload 笔记写请求体。PublishedAt 为发布日期(Unix 秒);nil = 创建取当前时刻 / 更新保持不变。
type notePayload struct { type notePayload struct {
Title string `json:"title"` Title string `json:"title"`
Slug string `json:"slug"` Slug string `json:"slug"`
Summary string `json:"summary"` Summary string `json:"summary"`
Content string `json:"content"` Content string `json:"content"`
Status string `json:"status"` Status string `json:"status"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
Pinned bool `json:"pinned"` Pinned bool `json:"pinned"`
PublishedAt *int64 `json:"published_at"`
} }
func (p *notePayload) validate() (string, string) { // 返回 (错误码, 消息) func (p *notePayload) validate() (string, string) { // 返回 (错误码, 消息)
@@ -106,10 +109,14 @@ func (s *Server) handleAdminNoteCreate(w http.ResponseWriter, r *http.Request) {
if strings.TrimSpace(summary) == "" { if strings.TrimSpace(summary) == "" {
summary = markdown.Summary(p.Content, 200) summary = markdown.Summary(p.Content, 200)
} }
publishedAt := now
if p.PublishedAt != nil && *p.PublishedAt > 0 {
publishedAt = *p.PublishedAt
}
n := &store.Note{ n := &store.Note{
Slug: slug, Title: strings.TrimSpace(p.Title), Summary: summary, Slug: slug, Title: strings.TrimSpace(p.Title), Summary: summary,
Content: p.Content, Status: p.Status, Tags: store.NormalizeTags(p.Tags), Content: p.Content, Status: p.Status, Tags: store.NormalizeTags(p.Tags),
Pinned: p.Pinned, CreatedAt: now, UpdatedAt: now, Pinned: p.Pinned, PublishedAt: publishedAt, CreatedAt: now, UpdatedAt: now,
} }
id, err := s.st.CreateNote(n) id, err := s.st.CreateNote(n)
if err != nil { if err != nil {
@@ -203,6 +210,9 @@ func (s *Server) handleAdminNoteUpdate(w http.ResponseWriter, r *http.Request) {
n.Tags = store.NormalizeTags(p.Tags) n.Tags = store.NormalizeTags(p.Tags)
n.Pinned = p.Pinned n.Pinned = p.Pinned
n.Content = p.Content n.Content = p.Content
if p.PublishedAt != nil && *p.PublishedAt > 0 {
n.PublishedAt = *p.PublishedAt
}
if strings.TrimSpace(p.Summary) == "" { if strings.TrimSpace(p.Summary) == "" {
n.Summary = markdown.Summary(p.Content, 200) n.Summary = markdown.Summary(p.Content, 200)
} else { } else {
@@ -253,6 +263,17 @@ func (s *Server) handleAdminTrash(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": len(items)}) writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": len(items)})
} }
// handleAdminTrashEmpty DELETE /api/admin/trash:清空回收站(物理删除全部,§6.2)。
func (s *Server) handleAdminTrashEmpty(w http.ResponseWriter, r *http.Request) {
n, err := s.st.EmptyTrash()
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
return
}
s.log.Info("admin_action", "op", "trash.empty", "count", n)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "deleted": n})
}
// handleAdminTrashRestore POST /api/admin/trash/{id}/restore:恢复(清空 deleted_at)。 // handleAdminTrashRestore POST /api/admin/trash/{id}/restore:恢复(清空 deleted_at)。
func (s *Server) handleAdminTrashRestore(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAdminTrashRestore(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(r) id, ok := pathID(r)
@@ -397,6 +418,7 @@ type settingsDTO struct {
SiteDesc *string `json:"site_desc"` SiteDesc *string `json:"site_desc"`
PageSize *int `json:"page_size"` PageSize *int `json:"page_size"`
BeianNo *string `json:"beian_no"` BeianNo *string `json:"beian_no"`
SiteLogo *string `json:"site_logo"`
} }
// handleAdminSettingsGet GET /api/admin/settings:白名单三键;永不序列化 admin_password_hash。 // handleAdminSettingsGet GET /api/admin/settings:白名单三键;永不序列化 admin_password_hash。
@@ -415,7 +437,7 @@ func (s *Server) handleAdminSettingsPut(w http.ResponseWriter, r *http.Request)
dec.DisallowUnknownFields() dec.DisallowUnknownFields()
var dto settingsDTO var dto settingsDTO
if err := dec.Decode(&dto); err != nil { if err := dec.Decode(&dto); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "包含未知字段或类型不合法(白名单:site_title/site_desc/page_size/beian_no)") writeError(w, http.StatusBadRequest, "bad_request", "包含未知字段或类型不合法(白名单:site_title/site_desc/page_size/beian_no/site_logo)")
return return
} }
if dto.SiteTitle != nil { if dto.SiteTitle != nil {
@@ -440,8 +462,8 @@ func (s *Server) handleAdminSettingsPut(w http.ResponseWriter, r *http.Request)
} }
} }
if dto.PageSize != nil { if dto.PageSize != nil {
if *dto.PageSize < 1 || *dto.PageSize > 100 { if !store.ValidPageSize(*dto.PageSize) {
writeError(w, http.StatusBadRequest, "bad_request", "page_size ∈ [1,100]") writeError(w, http.StatusBadRequest, "bad_request", "page_size 仅支持 10/20/30/50")
return return
} }
if err := s.st.SetSetting(store.KeyPageSize, strconv.Itoa(*dto.PageSize)); err != nil { if err := s.st.SetSetting(store.KeyPageSize, strconv.Itoa(*dto.PageSize)); err != nil {
@@ -460,6 +482,18 @@ func (s *Server) handleAdminSettingsPut(w http.ResponseWriter, r *http.Request)
return return
} }
} }
if dto.SiteLogo != nil {
// 仅收站内绝对路径(如 /api/images/1):CSP img-src 'self',外链图片本来就无法展示
v := strings.TrimSpace(*dto.SiteLogo)
if v != "" && (len(v) > 500 || strings.ContainsAny(v, " \t\r\n\"'\\") || !strings.HasPrefix(v, "/")) {
writeError(w, http.StatusBadRequest, "bad_request", "站点 Logo 须为站内绝对路径(如 /api/images/1)或留空")
return
}
if err := s.st.SetSetting(store.KeySiteLogo, v); err != nil {
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
return
}
}
// admin_password_hash 不可经此接口写入(§9.3):不在白名单结构体中,天然拒绝。 // admin_password_hash 不可经此接口写入(§9.3):不在白名单结构体中,天然拒绝。
s.log.Info("admin_action", "op", "settings.update") s.log.Info("admin_action", "op", "settings.update")
ss, err := s.st.GetSiteSettings() ss, err := s.st.GetSiteSettings()
+9 -7
View File
@@ -284,7 +284,7 @@ func TestSettingsWhitelist(t *testing.T) {
} }
// PUT 合法键 // PUT 合法键
resp, _ = e.do(admin, http.MethodPut, "/api/admin/settings", resp, _ = e.do(admin, http.MethodPut, "/api/admin/settings",
[]byte(`{"site_title":"新标题","site_desc":"描述","page_size":5,"beian_no":"京公网安备12345678901号"}`), e.adminHeaders()) []byte(`{"site_title":"新标题","site_desc":"描述","page_size":20,"beian_no":"京公网安备12345678901号"}`), e.adminHeaders())
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
t.Errorf("合法 PUT 应 200: %d", resp.StatusCode) t.Errorf("合法 PUT 应 200: %d", resp.StatusCode)
} }
@@ -311,14 +311,16 @@ func TestSettingsWhitelist(t *testing.T) {
} }
// page_size 生效为公开列表默认 // page_size 生效为公开列表默认
_, body = e.get(e.client(), "/api/notes") _, body = e.get(e.client(), "/api/notes")
if !strings.Contains(string(body), `"page_size":5`) { if !strings.Contains(string(body), `"page_size":20`) {
t.Errorf("page_size 设置应生效: %s", body) t.Errorf("page_size 设置应生效: %s", body)
} }
// page_size 越界 // page_size 越界/非候选项
resp, _ = e.do(admin, http.MethodPut, "/api/admin/settings", for _, bad := range []string{"0", "5", "101"} {
[]byte(`{"page_size":0}`), e.adminHeaders()) resp, _ = e.do(admin, http.MethodPut, "/api/admin/settings",
if resp.StatusCode != http.StatusBadRequest { []byte(`{"page_size":`+bad+`}`), e.adminHeaders())
t.Errorf("page_size=0 应 400: %d", resp.StatusCode) if resp.StatusCode != http.StatusBadRequest {
t.Errorf("page_size=%s 应 400: %d", bad, resp.StatusCode)
}
} }
} }
+3 -3
View File
@@ -29,7 +29,7 @@ type rssItem struct {
Title string `xml:"title"` Title string `xml:"title"`
Link string `xml:"link"` Link string `xml:"link"`
GUID string `xml:"guid"` GUID string `xml:"guid"`
PubDate string `xml:"pubDate"` // RFC 822(RSS 2.0 规范) PubDate string `xml:"pubDate"` // RFC 822(RSS 2.0 规范)
Description string `xml:"description"` // 已清洗的 HTML(经 encoding/xml 自动转义) Description string `xml:"description"` // 已清洗的 HTML(经 encoding/xml 自动转义)
} }
@@ -92,8 +92,8 @@ func (s *Server) handleRSS(w http.ResponseWriter, r *http.Request) {
// ---- sitemap(仅公开笔记)---- // ---- sitemap(仅公开笔记)----
type urlSet struct { type urlSet struct {
XMLName xml.Name `xml:"urlset"` XMLName xml.Name `xml:"urlset"`
XMLNS string `xml:"xmlns,attr"` XMLNS string `xml:"xmlns,attr"`
URLs []siteURL `xml:"url"` URLs []siteURL `xml:"url"`
} }
+15 -11
View File
@@ -40,15 +40,17 @@ func (s *Server) handleSiteInfo(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, ss) writeJSON(w, http.StatusOK, ss)
} }
// publicNoteItem 公开列表项(元信息,不含全文)。 // publicNoteItem 公开列表项(元信息 + 封面图,不含全文)。
type publicNoteItem struct { type publicNoteItem struct {
Slug string `json:"slug"` Slug string `json:"slug"`
Title string `json:"title"` Title string `json:"title"`
Summary string `json:"summary"` Summary string `json:"summary"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
Pinned bool `json:"pinned"` Pinned bool `json:"pinned"`
CreatedAt int64 `json:"created_at"` Cover string `json:"cover,omitempty"`
UpdatedAt int64 `json:"updated_at"` CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
PublishedAt int64 `json:"published_at"`
} }
// handlePublicNotes GET /api/notes:公开笔记列表(可见性过滤在查询层,§9.1-T10)。 // handlePublicNotes GET /api/notes:公开笔记列表(可见性过滤在查询层,§9.1-T10)。
@@ -70,7 +72,8 @@ func (s *Server) handlePublicNotes(w http.ResponseWriter, r *http.Request) {
items = append(items, publicNoteItem{ items = append(items, publicNoteItem{
Slug: n.Slug, Title: n.Title, Summary: n.Summary, Slug: n.Slug, Title: n.Title, Summary: n.Summary,
Tags: n.Tags, Pinned: n.Pinned, Tags: n.Tags, Pinned: n.Pinned,
CreatedAt: n.CreatedAt, UpdatedAt: n.UpdatedAt, Cover: store.FirstImageRef(n.Content),
CreatedAt: n.CreatedAt, UpdatedAt: n.UpdatedAt, PublishedAt: n.PublishedAt,
}) })
} }
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
@@ -119,8 +122,9 @@ func (s *Server) handlePublicNote(w http.ResponseWriter, r *http.Request) {
"summary": note.Summary, "content": note.Content, "summary": note.Summary, "content": note.Content,
"status": note.Status, "tags": note.Tags, "pinned": note.Pinned, "status": note.Status, "tags": note.Tags, "pinned": note.Pinned,
"created_at": note.CreatedAt, "updated_at": note.UpdatedAt, "created_at": note.CreatedAt, "updated_at": note.UpdatedAt,
"prev": siblingOrEmpty(prevSlug, prevTitle), "published_at": note.PublishedAt,
"next": siblingOrEmpty(nextSlug, nextTitle), "prev": siblingOrEmpty(prevSlug, prevTitle),
"next": siblingOrEmpty(nextSlug, nextTitle),
}) })
} }
+22 -12
View File
@@ -29,12 +29,12 @@ const (
// Server HTTP 服务。 // Server HTTP 服务。
type Server struct { type Server struct {
st *store.Store st *store.Store
cfg *config.Config cfg *config.Config
log *slog.Logger log *slog.Logger
global *middleware.Limiter // 全局宽松限流(per-IP) global *middleware.Limiter // 全局宽松限流(per-IP)
loginIP *middleware.Limiter // 登录/改密 per-IP:10 次/5 分钟 loginIP *middleware.Limiter // 登录/改密 per-IP:10 次/5 分钟
loginAcct *middleware.Limiter // 登录/改密 per-账号:5 次/10 分钟 loginAcct *middleware.Limiter // 登录/改密 per-账号:5 次/10 分钟
} }
// New 构造 Server(生产限流参数,§7.2)。 // New 构造 Server(生产限流参数,§7.2)。
@@ -93,6 +93,7 @@ func (s *Server) Handler(ui http.Handler) http.Handler {
adminMux.Handle("PUT /api/admin/notes/{id}", middleware.MaxBytes(maxNoteBody)(http.HandlerFunc(s.handleAdminNoteUpdate))) adminMux.Handle("PUT /api/admin/notes/{id}", middleware.MaxBytes(maxNoteBody)(http.HandlerFunc(s.handleAdminNoteUpdate)))
adminMux.HandleFunc("DELETE /api/admin/notes/{id}", s.handleAdminNoteDelete) adminMux.HandleFunc("DELETE /api/admin/notes/{id}", s.handleAdminNoteDelete)
adminMux.HandleFunc("GET /api/admin/trash", s.handleAdminTrash) adminMux.HandleFunc("GET /api/admin/trash", s.handleAdminTrash)
adminMux.HandleFunc("DELETE /api/admin/trash", s.handleAdminTrashEmpty)
adminMux.HandleFunc("POST /api/admin/trash/{id}/restore", s.handleAdminTrashRestore) adminMux.HandleFunc("POST /api/admin/trash/{id}/restore", s.handleAdminTrashRestore)
adminMux.Handle("POST /api/admin/images", middleware.MaxBytes(maxUploadBody)(http.HandlerFunc(s.handleAdminImageUpload))) adminMux.Handle("POST /api/admin/images", middleware.MaxBytes(maxUploadBody)(http.HandlerFunc(s.handleAdminImageUpload)))
adminMux.HandleFunc("GET /api/admin/images", s.handleAdminImages) adminMux.HandleFunc("GET /api/admin/images", s.handleAdminImages)
@@ -291,13 +292,14 @@ func (s *Server) ResolveMeta(r *http.Request) webui.Meta {
ss = &store.SiteSettings{SiteTitle: store.DefaultSiteTitle} ss = &store.SiteSettings{SiteTitle: store.DefaultSiteTitle}
} }
m := webui.Meta{ m := webui.Meta{
Title: ss.SiteTitle, Title: ss.SiteTitle,
Description: ss.SiteDesc, Description: ss.SiteDesc,
OGTitle: ss.SiteTitle, OGTitle: ss.SiteTitle,
OGDescription: ss.SiteDesc, OGDescription: ss.SiteDesc,
OGType: "website", OGType: "website",
SiteName: ss.SiteTitle, SiteName: ss.SiteTitle,
OGURL: baseURL(r) + "/", OGURL: baseURL(r) + "/",
Icon: favicon(ss.SiteLogo),
} }
slug := metaSlug(r.URL.Path) slug := metaSlug(r.URL.Path)
if slug == "" { if slug == "" {
@@ -317,6 +319,14 @@ func (s *Server) ResolveMeta(r *http.Request) webui.Meta {
return s.noteMeta(r, m, note) return s.noteMeta(r, m, note)
} }
// favicon favicon 地址:站点 Logo 优先,未设置回退内置图标。
func favicon(siteLogo string) string {
if siteLogo != "" {
return siteLogo
}
return "/favicon.svg"
}
func (s *Server) noteMeta(r *http.Request, base webui.Meta, n *store.Note) webui.Meta { func (s *Server) noteMeta(r *http.Request, base webui.Meta, n *store.Note) webui.Meta {
desc := n.Summary desc := n.Summary
if desc == "" { if desc == "" {
+6 -6
View File
@@ -154,12 +154,12 @@ func TestVisibilityMatrix(t *testing.T) {
// ---- 图片出口(并集语义 + 缓存头分流,§6.2/§7.4)---- // ---- 图片出口(并集语义 + 缓存头分流,§6.2/§7.4)----
imageCases := []struct { imageCases := []struct {
name string name string
id int64 id int64
anonCode int anonCode int
anonCache string anonCache string
adminCode int adminCode int
adminCache string adminCache string
}{ }{
{"公开图", f.imgPub, 200, "public, max-age=31536000, immutable", 200, "public, max-age=31536000, immutable"}, {"公开图", f.imgPub, 200, "public, max-age=31536000, immutable", 200, "public, max-age=31536000, immutable"},
{"私有图", f.imgPriv, 404, "", 200, "private, no-store"}, {"私有图", f.imgPriv, 404, "", 200, "private, no-store"},
+13 -1
View File
@@ -64,6 +64,16 @@ func (s *Store) ImageIsPublic(id int64) (bool, error) {
// imageRefRe 扫描 content 中对 /api/images/{id} 的引用(§6.2 引用维护)。 // imageRefRe 扫描 content 中对 /api/images/{id} 的引用(§6.2 引用维护)。
var imageRefRe = regexp.MustCompile(`/api/images/(\d+)`) var imageRefRe = regexp.MustCompile(`/api/images/(\d+)`)
// FirstImageRef 返回 content 中按出现顺序的第一张站内图片路径(无则空串)。
// 供公开列表推导封面图使用。
func FirstImageRef(content string) string {
m := imageRefRe.FindStringSubmatch(content)
if len(m) < 2 {
return ""
}
return m[0]
}
// RebuildImageRefs 在**同一事务**内重建笔记的图片引用: // RebuildImageRefs 在**同一事务**内重建笔记的图片引用:
// 删除该笔记全部 refs → 正则扫描 content → 重建(§6.2)。 // 删除该笔记全部 refs → 正则扫描 content → 重建(§6.2)。
func (s *Store) RebuildImageRefs(noteID int64, content string) error { func (s *Store) RebuildImageRefs(noteID int64, content string) error {
@@ -101,11 +111,13 @@ func rebuildRefsTx(tx *sql.Tx, noteID int64, content string) error {
} }
// ListOrphanImages 零引用图片清单(GET /api/admin/images?orphan=1 与 gc 检视用)。 // ListOrphanImages 零引用图片清单(GET /api/admin/images?orphan=1 与 gc 检视用)。
// 站点 Logo 引用的图片虽无笔记引用仍受保护,不视为孤儿。
func (s *Store) ListOrphanImages() ([]Image, error) { func (s *Store) ListOrphanImages() ([]Image, error) {
rows, err := s.db.Query(` rows, err := s.db.Query(`
SELECT i.id, i.sha256, i.mime, i.size, i.created_at FROM images i SELECT i.id, i.sha256, i.mime, i.size, i.created_at FROM images i
WHERE NOT EXISTS (SELECT 1 FROM image_refs r WHERE r.image_id = i.id) WHERE NOT EXISTS (SELECT 1 FROM image_refs r WHERE r.image_id = i.id)
ORDER BY i.created_at DESC`) AND i.id <> ?
ORDER BY i.created_at DESC`, s.logoImageID())
if err != nil { if err != nil {
return nil, err return nil, err
} }
+7 -4
View File
@@ -54,12 +54,14 @@ func (s *Store) GC(now time.Time, dryRun bool) (*GCReport, error) {
} }
rows.Close() rows.Close()
// 孤儿图片:0 引用且超过宽限期 // 孤儿图片:0 引用且超过宽限期;站点 Logo 引用的图片受保护
imageCutoff := now.Add(-OrphanGrace).Unix() imageCutoff := now.Add(-OrphanGrace).Unix()
logoID := s.logoImageID()
orphanRows, err := s.db.Query(` orphanRows, err := s.db.Query(`
SELECT i.id, i.sha256, i.mime, i.size, i.created_at FROM images i SELECT i.id, i.sha256, i.mime, i.size, i.created_at FROM images i
WHERE i.created_at < ? AND NOT EXISTS (SELECT 1 FROM image_refs r WHERE r.image_id = i.id) WHERE i.created_at < ? AND NOT EXISTS (SELECT 1 FROM image_refs r WHERE r.image_id = i.id)
ORDER BY i.created_at`, imageCutoff) AND i.id <> ?
ORDER BY i.created_at`, imageCutoff, logoID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -101,9 +103,10 @@ func (s *Store) GC(now time.Time, dryRun bool) (*GCReport, error) {
} }
} }
for _, img := range rep.OrphanImages { for _, img := range rep.OrphanImages {
// 条件复查含 Logo 保护:SELECT 与 DELETE 之间 Logo 可能指向该图
res, err := s.db.Exec( res, err := s.db.Exec(
`DELETE FROM images WHERE id=? AND NOT EXISTS (SELECT 1 FROM image_refs WHERE image_id=?)`, `DELETE FROM images WHERE id=? AND NOT EXISTS (SELECT 1 FROM image_refs WHERE image_id=?) AND id<>?`,
img.ID, img.ID) img.ID, img.ID, logoID)
if err != nil { if err != nil {
return nil, fmt.Errorf("删除孤儿图片 %d: %w", img.ID, err) return nil, fmt.Errorf("删除孤儿图片 %d: %w", img.ID, err)
} }
+34 -21
View File
@@ -9,31 +9,34 @@ import (
) )
// Note 笔记实体。DeletedAt 非 nil 表示处于回收站(软删除)。 // Note 笔记实体。DeletedAt 非 nil 表示处于回收站(软删除)。
// PublishedAt 为对外展示的发布日期(可自选,§8.2);排序仍按 updated_at。
type Note struct { type Note struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Slug string `json:"slug"` Slug string `json:"slug"`
Title string `json:"title"` Title string `json:"title"`
Summary string `json:"summary"` Summary string `json:"summary"`
Content string `json:"content,omitempty"` Content string `json:"content,omitempty"`
Status string `json:"status"` Status string `json:"status"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
Pinned bool `json:"pinned"` Pinned bool `json:"pinned"`
DeletedAt *int64 `json:"deleted_at,omitempty"` DeletedAt *int64 `json:"deleted_at,omitempty"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"` UpdatedAt int64 `json:"updated_at"`
PublishedAt int64 `json:"published_at"`
} }
// ErrNotFound 统一的「不存在」错误。 // ErrNotFound 统一的「不存在」错误。
var ErrNotFound = errors.New("not found") var ErrNotFound = errors.New("not found")
const noteColumns = "id, slug, title, summary, content, status, tags, pinned, deleted_at, created_at, updated_at" // published_at 经 v2 迁移回填,理论上恒非 NULL;COALESCE 兜底历史异常行。
const noteColumns = "id, slug, title, summary, content, status, tags, pinned, deleted_at, created_at, updated_at, COALESCE(published_at, created_at) AS published_at"
func scanNote(scan func(dest ...any) error) (*Note, error) { func scanNote(scan func(dest ...any) error) (*Note, error) {
var n Note var n Note
var tagsJSON string var tagsJSON string
var pinned int var pinned int
var content string var content string
if err := scan(&n.ID, &n.Slug, &n.Title, &n.Summary, &content, &n.Status, &tagsJSON, &pinned, &n.DeletedAt, &n.CreatedAt, &n.UpdatedAt); err != nil { if err := scan(&n.ID, &n.Slug, &n.Title, &n.Summary, &content, &n.Status, &tagsJSON, &pinned, &n.DeletedAt, &n.CreatedAt, &n.UpdatedAt, &n.PublishedAt); err != nil {
return nil, err return nil, err
} }
n.Content = content n.Content = content
@@ -54,9 +57,9 @@ func (s *Store) CreateNote(n *Note) (int64, error) {
return 0, err return 0, err
} }
res, err := s.db.Exec( res, err := s.db.Exec(
`INSERT INTO notes (slug, title, summary, content, status, tags, pinned, created_at, updated_at) `INSERT INTO notes (slug, title, summary, content, status, tags, pinned, published_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
n.Slug, n.Title, n.Summary, n.Content, n.Status, tags, boolToInt(n.Pinned), n.CreatedAt, n.UpdatedAt) n.Slug, n.Title, n.Summary, n.Content, n.Status, tags, boolToInt(n.Pinned), n.PublishedAt, n.CreatedAt, n.UpdatedAt)
if err != nil { if err != nil {
return 0, err return 0, err
} }
@@ -70,8 +73,8 @@ func (s *Store) UpdateNote(n *Note) error {
return err return err
} }
res, err := s.db.Exec( res, err := s.db.Exec(
`UPDATE notes SET title=?, summary=?, content=?, status=?, tags=?, pinned=?, updated_at=? WHERE id=? AND deleted_at IS NULL`, `UPDATE notes SET title=?, summary=?, content=?, status=?, tags=?, pinned=?, published_at=?, updated_at=? WHERE id=? AND deleted_at IS NULL`,
n.Title, n.Summary, n.Content, n.Status, tags, boolToInt(n.Pinned), n.UpdatedAt, n.ID) n.Title, n.Summary, n.Content, n.Status, tags, boolToInt(n.Pinned), n.PublishedAt, n.UpdatedAt, n.ID)
if err != nil { if err != nil {
return err return err
} }
@@ -164,7 +167,8 @@ func (s *Store) ListPublicNotes(page, pageSize int, tag string) ([]Note, int, er
if err := s.db.QueryRow(`SELECT COUNT(*) FROM notes n WHERE `+where, args...).Scan(&total); err != nil { if err := s.db.QueryRow(`SELECT COUNT(*) FROM notes n WHERE `+where, args...).Scan(&total); err != nil {
return nil, 0, err return nil, 0, err
} }
q := `SELECT n.id, n.slug, n.title, n.summary, '' AS content, n.status, n.tags, n.pinned, n.deleted_at, n.created_at, n.updated_at // 含全文:列表页需从正文推导封面图(FirstImageRef),个人规模条数有限可接受
q := `SELECT n.id, n.slug, n.title, n.summary, n.content, n.status, n.tags, n.pinned, n.deleted_at, n.created_at, n.updated_at, COALESCE(n.published_at, n.created_at) AS published_at
FROM notes n WHERE ` + where + ` FROM notes n WHERE ` + where + `
ORDER BY n.pinned DESC, n.updated_at DESC, n.id DESC LIMIT ? OFFSET ?` ORDER BY n.pinned DESC, n.updated_at DESC, n.id DESC LIMIT ? OFFSET ?`
rows, err := s.db.Query(q, append(args, pageSize, (page-1)*pageSize)...) rows, err := s.db.Query(q, append(args, pageSize, (page-1)*pageSize)...)
@@ -234,7 +238,7 @@ func (s *Store) ListPublicNotesFull(limit int) ([]Note, error) {
// ListAdminNotes 全部正常笔记(含私有,不含回收站),不含全文。 // ListAdminNotes 全部正常笔记(含私有,不含回收站),不含全文。
func (s *Store) ListAdminNotes() ([]Note, error) { func (s *Store) ListAdminNotes() ([]Note, error) {
q := `SELECT id, slug, title, summary, '' AS content, status, tags, pinned, deleted_at, created_at, updated_at q := `SELECT id, slug, title, summary, '' AS content, status, tags, pinned, deleted_at, created_at, updated_at, COALESCE(published_at, created_at) AS published_at
FROM notes WHERE deleted_at IS NULL FROM notes WHERE deleted_at IS NULL
ORDER BY pinned DESC, updated_at DESC, id DESC` ORDER BY pinned DESC, updated_at DESC, id DESC`
return s.queryNotes(q) return s.queryNotes(q)
@@ -242,12 +246,21 @@ func (s *Store) ListAdminNotes() ([]Note, error) {
// ListTrash 回收站列表(软删除中),不含全文。 // ListTrash 回收站列表(软删除中),不含全文。
func (s *Store) ListTrash() ([]Note, error) { func (s *Store) ListTrash() ([]Note, error) {
q := `SELECT id, slug, title, summary, '' AS content, status, tags, pinned, deleted_at, created_at, updated_at q := `SELECT id, slug, title, summary, '' AS content, status, tags, pinned, deleted_at, created_at, updated_at, COALESCE(published_at, created_at) AS published_at
FROM notes WHERE deleted_at IS NOT NULL FROM notes WHERE deleted_at IS NOT NULL
ORDER BY deleted_at DESC` ORDER BY deleted_at DESC`
return s.queryNotes(q) return s.queryNotes(q)
} }
// EmptyTrash 物理删除回收站全部笔记(refs 级联,需 foreign_keys=1),返回删除数。
func (s *Store) EmptyTrash() (int64, error) {
res, err := s.db.Exec(`DELETE FROM notes WHERE deleted_at IS NOT NULL`)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// ListExpiredTrash 过期待物理删除的笔记 id(gc 用)。 // ListExpiredTrash 过期待物理删除的笔记 id(gc 用)。
func (s *Store) ListExpiredTrash(before int64) ([]int64, error) { func (s *Store) ListExpiredTrash(before int64) ([]int64, error) {
rows, err := s.db.Query(`SELECT id FROM notes WHERE deleted_at IS NOT NULL AND deleted_at < ?`, before) rows, err := s.db.Query(`SELECT id FROM notes WHERE deleted_at IS NOT NULL AND deleted_at < ?`, before)
+34 -2
View File
@@ -14,6 +14,7 @@ const (
KeySiteDesc = "site_desc" KeySiteDesc = "site_desc"
KeyPageSize = "page_size" KeyPageSize = "page_size"
KeyBeianNo = "beian_no" KeyBeianNo = "beian_no"
KeySiteLogo = "site_logo"
) )
// GetSetting 读取单个设置。 // GetSetting 读取单个设置。
@@ -39,6 +40,7 @@ type SiteSettings struct {
SiteDesc string `json:"site_desc"` SiteDesc string `json:"site_desc"`
PageSize int `json:"page_size"` PageSize int `json:"page_size"`
BeianNo string `json:"beian_no"` BeianNo string `json:"beian_no"`
SiteLogo string `json:"site_logo"`
} }
// Defaults,未初始化时兜底。 // Defaults,未初始化时兜底。
@@ -49,6 +51,15 @@ const (
DefaultBeianNo = "" DefaultBeianNo = ""
) )
// ValidPageSize 每页条数可选项(前端选择器同源:10/20/30/50)。
func ValidPageSize(n int) bool {
switch n {
case 10, 20, 30, 50:
return true
}
return false
}
// GetSiteSettings 读取站点设置(白名单键,带默认值)。 // GetSiteSettings 读取站点设置(白名单键,带默认值)。
func (s *Store) GetSiteSettings() (*SiteSettings, error) { func (s *Store) GetSiteSettings() (*SiteSettings, error) {
ss := &SiteSettings{SiteTitle: DefaultSiteTitle, SiteDesc: DefaultSiteDesc, PageSize: DefaultPageSize, BeianNo: DefaultBeianNo} ss := &SiteSettings{SiteTitle: DefaultSiteTitle, SiteDesc: DefaultSiteDesc, PageSize: DefaultPageSize, BeianNo: DefaultBeianNo}
@@ -65,8 +76,9 @@ func (s *Store) GetSiteSettings() (*SiteSettings, error) {
if v, ok, err := s.GetSetting(KeyPageSize); err != nil { if v, ok, err := s.GetSetting(KeyPageSize); err != nil {
return nil, err return nil, err
} else if ok { } else if ok {
// strconv.Atoi 全文解析:拒绝 "10abc" 类部分解析的脏值(评审 round2 P2-13) // strconv.Atoi 全文解析:拒绝 "10abc" 类部分解析的脏值(评审 round2 P2-13);
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n >= 1 && n <= 100 { // 脏值/越界值回退默认,而非落入任意 [1,100]
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && ValidPageSize(n) {
ss.PageSize = n ss.PageSize = n
} }
} }
@@ -75,5 +87,25 @@ func (s *Store) GetSiteSettings() (*SiteSettings, error) {
} else if ok { } else if ok {
ss.BeianNo = strings.TrimSpace(v) ss.BeianNo = strings.TrimSpace(v)
} }
if v, ok, err := s.GetSetting(KeySiteLogo); err != nil {
return nil, err
} else if ok {
ss.SiteLogo = strings.TrimSpace(v)
}
return ss, nil return ss, nil
} }
// logoImageID 从 site_logo 设置解析受 GC 保护的图片 id(无 logo 或非本站图片路径返回 0)。
func (s *Store) logoImageID() int64 {
v, ok, err := s.GetSetting(KeySiteLogo)
if err != nil || !ok || v == "" {
return 0
}
if m := imageRefRe.FindStringSubmatch(v); m != nil {
var id int64
if _, err := fmtSscanInt(m[1], &id); err == nil && id > 0 {
return id
}
}
return 0
}
+5
View File
@@ -69,6 +69,11 @@ CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT NOT NULL value TEXT NOT NULL
); );
`,
// v2: 笔记发布日期(可自选);存量笔记回填为 created_at
`
ALTER TABLE notes ADD COLUMN published_at INTEGER;
UPDATE notes SET published_at = created_at WHERE published_at IS NULL;
`, `,
} }
+4 -4
View File
@@ -150,10 +150,10 @@ func TestAdjacentPublicNote(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
} }
mk("a", 100, false, "public") // id=1 mk("a", 100, false, "public") // id=1
mk("b", 100, false, "public") // id=2(与 a 同秒,列表中排在 a 之前) mk("b", 100, false, "public") // id=2(与 a 同秒,列表中排在 a 之前)
mk("c", 80, false, "public") // id=3 mk("c", 80, false, "public") // id=3
mk("p", 50, true, "public") // id=4 置顶 → 列表首 mk("p", 50, true, "public") // id=4 置顶 → 列表首
mk("priv", 200, false, "private") // 更新的私有笔记:不得出现在邻接中 mk("priv", 200, false, "private") // 更新的私有笔记:不得出现在邻接中
get := func(slug string) *Note { get := func(slug string) *Note {
+1
View File
@@ -30,6 +30,7 @@ type Meta struct {
OGURL string OGURL string
OGImage string OGImage string
SiteName string SiteName string
Icon string // favicon:站点 Logo,未设置回退 /favicon.svg
} }
// UI 静态资源服务。 // UI 静态资源服务。
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# 发布产物构建:交叉编译全部目标平台 → dist/,打包并生成 SHA256SUMS。
# 本地 `make dist` 与 Gitea Actions 发版工作流共用本脚本,保证产物一致。
#
# 用法: scripts/build-release.sh [版本号]
# 版本号缺省取 `git describe --tags --always`(无 tag 时回退 dev)。
# 前置条件:internal/webui/dist 已就绪(make web sync-assets)。
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
VERSION="${1:-$(git describe --tags --always 2>/dev/null || echo dev)}"
COMMIT="$(git rev-parse --short HEAD 2>/dev/null || echo none)"
DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
LDFLAGS="-s -w -X main.version=$VERSION -X main.commit=$COMMIT -X main.buildDate=$DATE"
# 纯静态(CGO_ENABLED=0,见 Makefile),单机即可交叉编译全部目标。
# 不含 windows:internal/store 对 syscall.Umask 等 Unix API 有硬依赖(备份权限加固)。
TARGETS=(
linux/amd64
linux/arm64
darwin/amd64
darwin/arm64
)
rm -rf dist
mkdir -p dist
for t in "${TARGETS[@]}"; do
os="${t%/*}" arch="${t#*/}"
name="pn-${VERSION}-${os}-${arch}"
echo "==> 构建 $name"
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" \
go build -trimpath -ldflags="$LDFLAGS" -o "dist/$name" ./cmd/pn
# tar.gz 保留可执行位
(cd dist && tar czf "$name.tar.gz" "$name" && rm "$name")
done
# 生成校验和(Linux 为 sha256sum,macOS 回退 shasum)
echo "==> 生成 SHA256SUMS"
(
cd dist
if command -v sha256sum >/dev/null 2>&1; then
sha256sum -- ./* > SHA256SUMS
else
shasum -a 256 -- ./* > SHA256SUMS
fi
)
echo "==> 完成:dist/"
ls -lh dist/
+2 -1
View File
@@ -2,7 +2,8 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <!-- favicon 随站点 Logo(服务端注入);dev 环境由前端 features/site 兜底替换 -->
<link rel="icon" href="{{.Icon}}" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- <!--
以下占位符由服务端 internal/webui 以 html/template 渲染并自动转义(§8.3-4)。 以下占位符由服务端 internal/webui 以 html/template 渲染并自动转义(§8.3-4)。
+1425 -17
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -14,6 +14,9 @@
"@codemirror/lang-markdown": "^6.5.2", "@codemirror/lang-markdown": "^6.5.2",
"@tanstack/react-query": "^5.102.8", "@tanstack/react-query": "^5.102.8",
"@uiw/react-codemirror": "^4.25.11", "@uiw/react-codemirror": "^4.25.11",
"antd": "^6.6.3",
"antd-style": "^4.1.0",
"clsx": "^2.1.1",
"lucide-react": "^1.42.0", "lucide-react": "^1.42.0",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
+49 -22
View File
@@ -1,7 +1,11 @@
import { lazy, Suspense } from 'react' import { lazy, Suspense, useEffect, useState } from 'react'
import { Navigate, Route, Routes } from 'react-router'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { Navigate, Route, Routes } from 'react-router'
import { App as AntdApp, ConfigProvider, Spin } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import { useAuth } from './lib/auth' import { useAuth } from './lib/auth'
import { useTheme } from './lib/theme'
import useShadcnTheme from './theme/shadcnTheme'
import { BlogLayout, AdminLayout } from './components/Layout' import { BlogLayout, AdminLayout } from './components/Layout'
import Home from './pages/Home' import Home from './pages/Home'
import TagPage from './pages/TagPage' import TagPage from './pages/TagPage'
@@ -16,11 +20,32 @@ import NotFound from './pages/NotFound'
const Note = lazy(() => import('./pages/Note')) const Note = lazy(() => import('./pages/Note'))
const AdminEdit = lazy(() => import('./pages/AdminEdit')) const AdminEdit = lazy(() => import('./pages/AdminEdit'))
// antd ConfigProvider:跟随应用三态主题切换 light/dark 算法(system 监听系统变化)
function AntdProvider({ children }: { children: ReactNode }) {
const { mode } = useTheme()
const [systemDark, setSystemDark] = useState(
() => window.matchMedia('(prefers-color-scheme: dark)').matches,
)
useEffect(() => {
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const onChange = (e: MediaQueryListEvent) => setSystemDark(e.matches)
mq.addEventListener('change', onChange)
return () => mq.removeEventListener('change', onChange)
}, [])
const dark = mode === 'dark' || (mode === 'system' && systemDark)
const configProps = useShadcnTheme(dark)
return (
<ConfigProvider locale={zhCN} {...configProps}>
<AntdApp>{children}</AntdApp>
</ConfigProvider>
)
}
// 登录守卫:401 统一跳转 /admin/login(§8.2) // 登录守卫:401 统一跳转 /admin/login(§8.2)
function RequireAuth({ children }: { children: ReactNode }) { function RequireAuth({ children }: { children: ReactNode }) {
const { authenticated, loading } = useAuth() const { authenticated, loading } = useAuth()
if (loading) { if (loading) {
return <div className="pn-loading">加载中…</div> return <div className="pn-loading"><Spin size="large" /></div>
} }
if (!authenticated) { if (!authenticated) {
return <Navigate to="/admin/login" replace /> return <Navigate to="/admin/login" replace />
@@ -30,24 +55,26 @@ function RequireAuth({ children }: { children: ReactNode }) {
export default function App() { export default function App() {
return ( return (
<Suspense fallback={<div className="pn-loading">加载中…</div>}> <AntdProvider>
<Routes> <Suspense fallback={<div className="pn-loading"><Spin size="large" /></div>}>
<Route element={<BlogLayout />}> <Routes>
<Route path="/" element={<Home />} /> <Route element={<BlogLayout />}>
<Route path="/notes/:slug" element={<Note />} /> <Route path="/" element={<Home />} />
<Route path="/tags" element={<TagPage />} /> <Route path="/notes/:slug" element={<Note />} />
<Route path="/tags/:tag" element={<TagPage />} /> <Route path="/tags" element={<TagPage />} />
</Route> <Route path="/tags/:tag" element={<TagPage />} />
<Route path="/admin/login" element={<Login />} /> </Route>
<Route element={<RequireAuth><AdminLayout /></RequireAuth>}> <Route path="/admin/login" element={<Login />} />
<Route path="/admin" element={<AdminList />} /> <Route element={<RequireAuth><AdminLayout /></RequireAuth>}>
<Route path="/admin/trash" element={<AdminTrash />} /> <Route path="/admin" element={<AdminList />} />
<Route path="/admin/settings" element={<AdminSettings />} /> <Route path="/admin/trash" element={<AdminTrash />} />
<Route path="/admin/notes/new" element={<AdminEdit />} /> <Route path="/admin/settings" element={<AdminSettings />} />
<Route path="/admin/notes/:id/edit" element={<AdminEdit />} /> <Route path="/admin/notes/new" element={<AdminEdit />} />
</Route> <Route path="/admin/notes/:id/edit" element={<AdminEdit />} />
<Route path="*" element={<NotFound />} /> </Route>
</Routes> <Route path="*" element={<NotFound />} />
</Suspense> </Routes>
</Suspense>
</AntdProvider>
) )
} }
+159 -114
View File
@@ -1,88 +1,76 @@
import { useEffect, useRef, useState } from 'react'
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router' import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router'
import { import {
Moon, Sun, Monitor, ExternalLink, House, PenLine, MoreHorizontal, Tags, Trash2, Settings, LogOut, Moon, Sun, Monitor, ExternalLink, House, PenLine, MoreHorizontal, Tags, Trash2, Settings, LogOut,
} from 'lucide-react' } from 'lucide-react'
import { Button, Dropdown, Menu, Tooltip } from 'antd'
import type { MenuProps } from 'antd'
import { useSiteTitle } from '../features/site' import { useSiteTitle } from '../features/site'
import { useTheme } from '../lib/theme' import { useTheme } from '../lib/theme'
import { useAuth } from '../lib/auth' import { useAuth } from '../lib/auth'
/* ── 溢出菜单:触发按钮 + 下拉面板(点击外部 / Esc / 选中项关闭) ── */ /* ── 溢出菜单:antd Dropdown(外点关闭/Esc/焦点管理由 antd 承接) ── */
function MoreMenu({ triggerClassName, triggerLabel, children }: { function MoreMenu({ triggerClassName, triggerLabel, items }: {
triggerClassName: string triggerClassName: string
triggerLabel: string triggerLabel: string
children: React.ReactNode items: MenuProps['items']
}) { }) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const onPointer = (e: PointerEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('pointerdown', onPointer)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('pointerdown', onPointer)
document.removeEventListener('keydown', onKey)
}
}, [open])
return ( return (
<div className="pn-moremenu" ref={ref}> // wrapper:与设计稿一致占一个 flex 槽位(display:none 触发器不产生 gap,
<button // 曾致桌面导航整体横移 4px)
type="button" <div className="pn-moremenu">
className={triggerClassName} <Dropdown trigger={['click']} placement="bottomRight" menu={{ items }}>
aria-haspopup="menu" <Button type="text" className={triggerClassName} aria-label={triggerLabel} icon={<MoreHorizontal size={15} />} />
aria-expanded={open} </Dropdown>
aria-label={triggerLabel}
onClick={() => setOpen((v) => !v)}
>
<MoreHorizontal size={15} />
</button>
{open && (
<div className="pn-moremenu-panel" role="menu" onClick={() => setOpen(false)}>
{children}
</div>
)}
</div> </div>
) )
} }
/* 菜单内路由项:激活态样式见 index.css 的 .pn-dd-item-active 桥接规则 */
function moreNavItem(to: string, end: boolean, icon: React.ReactNode, label: string, active: boolean): NonNullable<MenuProps['items']>[number] {
return {
key: to,
className: active ? 'pn-dd-item-active' : undefined,
label: (
<NavLink to={to} end={end} className="pn-dd-link">
{icon}
{label}
</NavLink>
),
}
}
/* ── 博客端:居中单栏 720px 布局(博客系列设计稿) ─────────── */ /* ── 博客端:居中单栏 720px 布局(博客系列设计稿) ─────────── */
function ThemeButton({ className = 'pn-site-navlink', tip = 'native' }: { function ThemeButton({ className = 'pn-site-navlink' }: { className?: string }) {
className?: string
tip?: 'native' | 'css'
}) {
const { mode, setMode } = useTheme() const { mode, setMode } = useTheme()
const next = mode === 'light' ? 'dark' : mode === 'dark' ? 'system' : 'light' const next = mode === 'light' ? 'dark' : mode === 'dark' ? 'system' : 'light'
const title = `主题:${mode === 'light' ? '浅色' : mode === 'dark' ? '深色' : '跟随系统'}(点击切换)` const title = `主题:${mode === 'light' ? '浅色' : mode === 'dark' ? '深色' : '跟随系统'}(点击切换)`
return ( return (
<button <Button
type="button" type="text"
className={className} className={className}
title={tip === 'native' ? title : undefined} title={title}
data-tip={tip === 'css' ? title : undefined}
aria-label={title} aria-label={title}
icon={mode === 'light' ? <Sun size={14} /> : mode === 'dark' ? <Moon size={14} /> : <Monitor size={14} />}
onClick={() => setMode(next)} onClick={() => setMode(next)}
> />
{mode === 'light' ? <Sun size={14} /> : mode === 'dark' ? <Moon size={14} /> : <Monitor size={14} />}
</button>
) )
} }
export function BlogLayout() { export function BlogLayout() {
const { data } = useSiteTitle() const { data } = useSiteTitle()
const title = data?.site_title ?? 'Pure Note' const title = data?.site_title ?? 'Pure Note'
const logo = data?.site_logo ?? ''
const location = useLocation()
return ( return (
<div className="pn-site-page"> <div className="pn-site-page">
<header className="pn-site-header"> <header className="pn-site-header">
<div className="pn-site-header-inner"> <div className="pn-site-header-inner">
<Link to="/" className="pn-site-wordmark">{title}</Link> <Link to="/" className="pn-site-wordmark">
{logo && <img className="pn-site-logo" src={logo} alt="" aria-hidden />}
{title}
</Link>
<nav className="pn-site-nav" aria-label="站点导航"> <nav className="pn-site-nav" aria-label="站点导航">
<NavLink to="/" end className="pn-site-navlink hide-sm"> <NavLink to="/" end className="pn-site-navlink hide-sm">
<House size={14} />首页 <House size={14} />首页
@@ -95,14 +83,14 @@ export function BlogLayout() {
</NavLink> </NavLink>
<ThemeButton /> <ThemeButton />
{/* <640px 首页/标签收进溢出菜单 */} {/* <640px 首页/标签收进溢出菜单 */}
<MoreMenu triggerClassName="pn-site-navlink pn-site-menu-btn" triggerLabel="更多导航"> <MoreMenu
<NavLink to="/" end className={({ isActive }) => `pn-moremenu-item${isActive ? ' active' : ''}`}> triggerClassName="pn-site-navlink pn-site-menu-btn"
<House size={15} />首页 triggerLabel="更多导航"
</NavLink> items={[
<NavLink to="/tags" className={({ isActive }) => `pn-moremenu-item${isActive ? ' active' : ''}`}> moreNavItem('/', true, <House size={15} />, '首页', location.pathname === '/'),
<Tags size={15} />标签 moreNavItem('/tags', false, <Tags size={15} />, '标签', location.pathname.startsWith('/tags')),
</NavLink> ]}
</MoreMenu> />
</nav> </nav>
</div> </div>
</header> </header>
@@ -135,6 +123,23 @@ export function BlogLayout() {
/* ── 管理端:sidebar + topbar + 移动端 tabbar(dashboard 设计稿) ── */ /* ── 管理端:sidebar + topbar + 移动端 tabbar(dashboard 设计稿) ── */
function AdminThemeButton() {
const { mode, setMode } = useTheme()
const next = mode === 'light' ? 'dark' : mode === 'dark' ? 'system' : 'light'
const label = `主题:${mode === 'light' ? '浅色' : mode === 'dark' ? '深色' : '跟随系统'}(点击切换)`
return (
<Tooltip title={label} placement="bottom">
<Button
type="text"
className="pn-admin-iconbtn pn-admin-desktop-act"
aria-label={label}
icon={mode === 'light' ? <Sun size={14} /> : mode === 'dark' ? <Moon size={14} /> : <Monitor size={14} />}
onClick={() => setMode(next)}
/>
</Tooltip>
)
}
export function AdminTopbar({ title, children }: { title: string; children?: React.ReactNode }) { export function AdminTopbar({ title, children }: { title: string; children?: React.ReactNode }) {
const { logout } = useAuth() const { logout } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
@@ -146,42 +151,75 @@ export function AdminTopbar({ title, children }: { title: string; children?: Rea
<h1>{title}</h1> <h1>{title}</h1>
<div className="pn-admin-actions"> <div className="pn-admin-actions">
{children} {children}
<ThemeButton className="pn-admin-iconbtn tip-below pn-admin-desktop-act" tip="css" /> <AdminThemeButton />
{/* <768px 侧栏隐藏、topbar 空间有限:主题/返回博客/退出登录收进溢出菜单 */} {/* <768px 侧栏隐藏、topbar 空间有限:主题/返回博客/退出登录收进溢出菜单 */}
<MoreMenu triggerClassName="pn-admin-iconbtn pn-admin-mobile-act" triggerLabel="更多操作"> <MoreMenu
<button type="button" className="pn-moremenu-item" onClick={() => setMode(next)}> triggerClassName="pn-admin-iconbtn pn-admin-mobile-act"
{mode === 'light' ? <Sun size={15} /> : mode === 'dark' ? <Moon size={15} /> : <Monitor size={15} />} triggerLabel="更多操作"
主题:{modeLabel} items={[
</button> {
<a href="/" target="_blank" rel="noreferrer" className="pn-moremenu-item"> key: 'theme',
<ExternalLink size={15} />返回博客 onClick: () => setMode(next),
</a> label: (
<button <>
type="button" {mode === 'light' ? <Sun size={15} /> : mode === 'dark' ? <Moon size={15} /> : <Monitor size={15} />}
className="pn-moremenu-item" 主题:{modeLabel}
onClick={() => void logout().then(() => navigate('/'))} </>
> ),
<LogOut size={15} />退出登录 },
</button> {
</MoreMenu> key: 'blog',
label: (
<a href="/" target="_blank" rel="noreferrer">
<ExternalLink size={15} />返回博客
</a>
),
},
{
key: 'logout',
onClick: () => void logout().then(() => navigate('/')),
label: (
<>
<LogOut size={15} />退出登录
</>
),
},
]}
/>
</div> </div>
</header> </header>
) )
} }
const adminNav = [ const adminNav = [
{ to: '/admin', label: '笔记管理', icon: House, end: true, match: 'exact' as const },
{ to: '/admin/notes/new', label: '新建笔记', icon: PenLine, end: false, match: 'prefix' as const }, { to: '/admin/notes/new', label: '新建笔记', icon: PenLine, end: false, match: 'prefix' as const },
{ to: '/admin', label: '笔记管理', icon: House, end: true, match: 'exact' as const },
{ to: '/admin/trash', label: '回收站', icon: Trash2, end: true, match: 'exact' as const }, { to: '/admin/trash', label: '回收站', icon: Trash2, end: true, match: 'exact' as const },
{ to: '/admin/settings', label: '设置', icon: Settings, end: true, match: 'exact' as const }, { to: '/admin/settings', label: '设置', icon: Settings, end: true, match: 'exact' as const },
] ]
const adminMenuPathByKey: Record<string, string> = {
notes: '/admin',
new: '/admin/notes/new',
trash: '/admin/trash',
settings: '/admin/settings',
}
function AdminSidebar() { function AdminSidebar() {
const { logout } = useAuth() const { logout } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
// 新建/编辑共用「新建笔记」高亮(设计稿:编辑页高亮新建入口) // 新建/编辑共用「新建笔记」高亮(设计稿:编辑页高亮新建入口)
const editing = location.pathname.startsWith('/admin/notes') const editing = location.pathname.startsWith('/admin/notes')
const selectedKey = editing
? 'new'
: location.pathname === '/admin'
? 'notes'
: location.pathname.startsWith('/admin/trash')
? 'trash'
: location.pathname.startsWith('/admin/settings')
? 'settings'
: ''
return ( return (
<aside className="pn-admin-sidebar"> <aside className="pn-admin-sidebar">
<Link to="/admin" className="pn-admin-brand"> <Link to="/admin" className="pn-admin-brand">
@@ -192,53 +230,60 @@ function AdminSidebar() {
</span> </span>
</Link> </Link>
<div className="pn-admin-section">内容</div> <Menu
<nav className="pn-admin-nav" aria-label="内容"> mode="inline"
<NavLink to="/admin" end className="pn-admin-item"> className="pn-admin-menu"
<House size={15} /><span>笔记管理</span> selectedKeys={selectedKey ? [selectedKey] : []}
</NavLink> onClick={({ key }) => navigate(adminMenuPathByKey[key])}
<Link to="/admin/notes/new" className={`pn-admin-item${editing ? ' active' : ''}`} aria-current={editing ? 'page' : undefined}> items={[
<PenLine size={15} /><span>新建笔记</span> {
</Link> key: 'g-content',
</nav> type: 'group',
label: '内容',
<div className="pn-admin-section">系统</div> children: [
<nav className="pn-admin-nav" aria-label="系统"> { key: 'new', icon: <PenLine size={15} />, label: '新建笔记' },
<NavLink to="/admin/trash" className="pn-admin-item"> { key: 'notes', icon: <House size={15} />, label: '笔记管理' },
<Trash2 size={15} /><span>回收站</span> ],
</NavLink> },
<NavLink to="/admin/settings" className="pn-admin-item"> {
<Settings size={15} /><span>设置</span> key: 'g-system',
</NavLink> type: 'group',
</nav> label: '系统',
children: [
{ key: 'trash', icon: <Trash2 size={15} />, label: '回收站' },
{ key: 'settings', icon: <Settings size={15} />, label: '设置' },
],
},
]}
/>
<div className="pn-admin-foot"> <div className="pn-admin-foot">
<div className="pn-admin-user"> <div className="pn-admin-user">
<span className="pn-admin-avatar">管</span> <span className="pn-admin-avatar">管</span>
<span className="pn-admin-user-meta"> <span className="pn-admin-user-meta">
<span className="pn-admin-user-name">管理员</span> <span className="pn-admin-user-name">管理员</span>
<span className="pn-admin-user-role">站点所有者</span>
</span> </span>
<span className="pn-admin-user-actions"> <span className="pn-admin-user-actions">
<a <Tooltip title="返回博客" placement="bottom">
href="/" <Button
target="_blank" type="text"
rel="noreferrer" className="pn-admin-iconbtn"
className="pn-admin-iconbtn" href="/"
data-tip="返回博客" target="_blank"
aria-label="返回博客(新窗口打开)" rel="noreferrer"
> aria-label="返回博客(新窗口打开)"
<ExternalLink size={15} /> icon={<ExternalLink size={15} />}
</a> />
<button </Tooltip>
type="button" <Tooltip title="退出登录" placement="bottom">
className="pn-admin-iconbtn" <Button
data-tip="退出登录" type="text"
aria-label="退出登录" className="pn-admin-iconbtn"
onClick={() => void logout().then(() => navigate('/'))} aria-label="退出登录"
> icon={<LogOut size={15} />}
<LogOut size={15} /> onClick={() => void logout().then(() => navigate('/'))}
</button> />
</Tooltip>
</span> </span>
</div> </div>
</div> </div>
+20 -8
View File
@@ -1,7 +1,8 @@
import { memo } from 'react' import { memo, useState } from 'react'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight' import rehypeHighlight from 'rehype-highlight'
import { Button } from 'antd'
import { sanitizePlugin } from '../lib/sanitize' import { sanitizePlugin } from '../lib/sanitize'
/** /**
@@ -25,18 +26,29 @@ function CodeBlock(props: React.HTMLAttributes<HTMLPreElement>) {
} }
function CopyButton() { function CopyButton() {
// 复制按钮在点击时读取最近兄弟 <pre> 的文本;不注入任何 HTML // 复制按钮在点击时读取最近兄弟 <pre> 的文本;不注入任何 HTML。
// MarkdownViewer 会在无 App 上下文的单测中渲染,反馈用本地状态而非 message。
const [copied, setCopied] = useState(false)
return ( return (
<button <Button
type="button" size="small"
className="pn-copy-btn" className={copied ? 'pn-copy-btn is-copied' : 'pn-copy-btn'}
onClick={(e) => { onClick={(e) => {
const pre = (e.currentTarget.parentElement as HTMLElement).querySelector('pre') const pre = (e.currentTarget.parentElement as HTMLElement).querySelector('pre')
if (pre) void navigator.clipboard.writeText(pre.textContent ?? '') const text = pre?.textContent ?? ''
if (!text) return
try {
void navigator.clipboard.writeText(text).then(() => {
setCopied(true)
window.setTimeout(() => setCopied(false), 1600)
})
} catch {
/* clipboard 不可用(非安全上下文等)时静默 */
}
}} }}
> >
复制 {copied ? '已复制' : '复制'}
</button> </Button>
) )
} }
-30
View File
@@ -1,30 +0,0 @@
import { useEffect } from 'react'
import { CircleCheck, TriangleAlert } from 'lucide-react'
/**
* Toast:右下角操作反馈(设计稿 toast 契约)。
* message 为 null 时隐藏;展示 2.6s 后调用 onDone 清除。
*/
export default function Toast({
message,
kind = 'success',
onDone,
}: {
message: string | null
kind?: 'success' | 'error'
onDone: () => void
}) {
useEffect(() => {
if (!message) return
const t = setTimeout(onDone, 2600)
return () => clearTimeout(t)
}, [message, onDone])
if (!message) return null
return (
<div className={`pn-toast${kind === 'error' ? ' is-error' : ''}`} role="status">
{kind === 'error' ? <TriangleAlert size={16} /> : <CircleCheck size={16} />}
<span>{message}</span>
</div>
)
}
+10 -1
View File
@@ -4,11 +4,20 @@ import { api } from '../lib/api'
import type { SiteSettings } from '../lib/api' import type { SiteSettings } from '../lib/api'
export function useSiteTitle() { export function useSiteTitle() {
return useQuery({ const { data } = useQuery({
queryKey: ['site'], queryKey: ['site'],
queryFn: () => api<SiteSettings>('/api/site'), queryFn: () => api<SiteSettings>('/api/site'),
staleTime: 5 * 60_000, staleTime: 5 * 60_000,
}) })
// favicon 随站点 Logo:生产由服务端注入 index.html,此处兜底(dev 模板占位符
// 不会被渲染)并保证设置变更后 SPA 内即时生效
useEffect(() => {
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]')
if (link) link.href = data?.site_logo || '/favicon.svg'
}, [data?.site_logo])
return { data }
} }
/** /**
+25
View File
@@ -0,0 +1,25 @@
import { App } from 'antd'
/**
* 操作反馈:基于 antd notification(经 ConfigProvider 主题 + App 上下文)。
* 保留旧 toast 契约——成功/错误两种、2.6s 自动消失、右下角(移动端底部通栏)。
*/
type ToastInput = {
description: string
variant?: 'success' | 'error'
}
export function useToast() {
const notification = App.useApp().notification
return {
toast: ({ description, variant }: ToastInput) => {
const notify = variant === 'error' ? notification.error : notification.success
notify({
message: '',
description,
placement: 'bottomRight',
duration: 2.6,
})
},
}
}
+366 -1228
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -88,6 +88,7 @@ export interface SiteSettings {
site_desc: string site_desc: string
page_size: number page_size: number
beian_no: string beian_no: string
site_logo: string
} }
export interface NoteItem { export interface NoteItem {
@@ -98,9 +99,12 @@ export interface NoteItem {
status?: string status?: string
tags: string[] tags: string[]
pinned: boolean pinned: boolean
/** 公开列表项的封面图(正文第一张图;无图缺省) */
cover?: string
deleted_at?: number | null deleted_at?: number | null
created_at: number created_at: number
updated_at: number updated_at: number
published_at: number
} }
export interface NoteDetail extends NoteItem { export interface NoteDetail extends NoteItem {
-4
View File
@@ -1,7 +1,3 @@
export function cn(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(' ')
}
export function formatDate(unix: number): string { export function formatDate(unix: number): string {
return new Date(unix * 1000).toLocaleDateString('zh-CN', { return new Date(unix * 1000).toLocaleDateString('zh-CN', {
year: 'numeric', year: 'numeric',
+115 -123
View File
@@ -1,18 +1,20 @@
import { lazy, Suspense, useEffect, useRef, useState } from 'react' import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router' import { useNavigate, useParams } from 'react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import dayjs from 'dayjs'
import { import {
Bold, Calendar, CirclePlus, Code, Eye, EyeOff, Italic, Link2, List, Quote, Bold, Code, Eye, EyeOff, Italic, Link2, List, Quote,
Save, Table, Trash2, X, Save, Table, Trash2,
} from 'lucide-react' } from 'lucide-react'
import { Alert, Badge, Button, Card, DatePicker, Divider, Input, Select, Spin, Switch } from 'antd'
import { api, ApiError } from '../lib/api' import { api, ApiError } from '../lib/api'
import type { NoteItem } from '../lib/api' import type { NoteItem } from '../lib/api'
import { invalidatePublic } from '../lib/invalidate' import { invalidatePublic } from '../lib/invalidate'
import { useDocumentTitle } from '../features/site' import { useDocumentTitle } from '../features/site'
import MarkdownViewer from '../components/MarkdownViewer' import MarkdownViewer from '../components/MarkdownViewer'
import Toast from '../components/Toast'
import { AdminTopbar } from '../components/Layout' import { AdminTopbar } from '../components/Layout'
import { formatDateISO, slugPreview } from '../lib/utils' import { useToast } from '@/hooks/use-toast'
import { slugPreview } from '../lib/utils'
// CodeMirror 体积大,单独拆 chunk,编辑器在首帧后异步加载 // CodeMirror 体积大,单独拆 chunk,编辑器在首帧后异步加载
const Editor = lazy(() => import('../components/Editor')) const Editor = lazy(() => import('../components/Editor'))
@@ -31,9 +33,10 @@ type Form = {
status: 'public' | 'private' status: 'public' | 'private'
tags: string[] tags: string[]
pinned: boolean pinned: boolean
publishedAt: number
} }
const emptyForm: Form = { const emptyForm = (): Form => ({
title: '', title: '',
slug: '', slug: '',
summary: '', summary: '',
@@ -41,7 +44,8 @@ const emptyForm: Form = {
status: 'private', status: 'private',
tags: [], tags: [],
pinned: false, pinned: false,
} publishedAt: Math.floor(Date.now() / 1000),
})
export default function AdminEdit() { export default function AdminEdit() {
const { id } = useParams() const { id } = useParams()
@@ -50,6 +54,7 @@ export default function AdminEdit() {
useDocumentTitle(isNew ? '新建笔记' : '编辑笔记') useDocumentTitle(isNew ? '新建笔记' : '编辑笔记')
const navigate = useNavigate() const navigate = useNavigate()
const qc = useQueryClient() const qc = useQueryClient()
const { toast } = useToast()
const { data: existing, isLoading } = useQuery({ const { data: existing, isLoading } = useQuery({
queryKey: ['admin', 'note', noteId], queryKey: ['admin', 'note', noteId],
@@ -57,18 +62,28 @@ export default function AdminEdit() {
enabled: !isNew, enabled: !isNew,
}) })
// 既有标签(含私有笔记)作为选择器候选项
const { data: allNotes } = useQuery({
queryKey: ['admin', 'notes'],
queryFn: () => api<{ items: NoteItem[] }>('/api/admin/notes'),
})
const [form, setForm] = useState<Form>(emptyForm) const [form, setForm] = useState<Form>(emptyForm)
const [savedAt, setSavedAt] = useState<number | null>(null)
const [errorMsg, setErrorMsg] = useState<string | null>(null) const [errorMsg, setErrorMsg] = useState<string | null>(null)
const [slugFieldError, setSlugFieldError] = useState<string | null>(null) const [slugFieldError, setSlugFieldError] = useState<string | null>(null)
const [showPreview, setShowPreview] = useState(false) const [showPreview, setShowPreview] = useState(false)
const [uploadMsg, setUploadMsg] = useState<string | null>(null) const [uploadMsg, setUploadMsg] = useState<{ kind: 'busy' | 'error'; text: string } | null>(null)
const [tagInput, setTagInput] = useState<string | null>(null)
const [toast, setToast] = useState<{ msg: string; kind: 'success' | 'error' } | null>(null)
const dirtyRef = useRef(false) const dirtyRef = useRef(false)
const slugTouchedRef = useRef(false) const slugTouchedRef = useRef(false)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null) const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const tagOptions = useMemo(() => {
const seen = new Set<string>()
for (const n of allNotes?.items ?? []) for (const t of n.tags) seen.add(t)
for (const t of form.tags) seen.add(t)
return [...seen].map((t) => ({ value: t, label: t }))
}, [allNotes, form.tags])
useEffect(() => { useEffect(() => {
if (existing) { if (existing) {
setForm({ setForm({
@@ -79,6 +94,7 @@ export default function AdminEdit() {
status: existing.status === 'public' ? 'public' : 'private', status: existing.status === 'public' ? 'public' : 'private',
tags: existing.tags, tags: existing.tags,
pinned: existing.pinned, pinned: existing.pinned,
publishedAt: existing.published_at || existing.created_at,
}) })
slugTouchedRef.current = false slugTouchedRef.current = false
dirtyRef.current = false dirtyRef.current = false
@@ -103,6 +119,7 @@ export default function AdminEdit() {
status: f.status, status: f.status,
tags: f.tags, tags: f.tags,
pinned: f.pinned, pinned: f.pinned,
published_at: f.publishedAt,
} }
if (isNew) { if (isNew) {
// 新建:slug 由服务端从标题自动生成(首次保存即定稿,§8.2) // 新建:slug 由服务端从标题自动生成(首次保存即定稿,§8.2)
@@ -117,11 +134,10 @@ export default function AdminEdit() {
}) })
}, },
onSuccess: (saved, vars) => { onSuccess: (saved, vars) => {
setSavedAt(Date.now())
setSlugFieldError(null) setSlugFieldError(null)
setErrorMsg(null) setErrorMsg(null)
dirtyRef.current = false dirtyRef.current = false
setToast({ msg: vars.auto ? '已自动保存' : '已保存', kind: 'success' }) toast({ description: vars.auto ? '已自动保存' : '已保存' })
// 仅失效列表与公共缓存;不失效当前编辑详情(['admin','note',id]), // 仅失效列表与公共缓存;不失效当前编辑详情(['admin','note',id]),
// 防 refetch 回滚保存期间继续输入的内容并抑制下一轮自动保存(评审 round2 P1-4) // 防 refetch 回滚保存期间继续输入的内容并抑制下一轮自动保存(评审 round2 P1-4)
void qc.invalidateQueries({ queryKey: ['admin', 'notes'] }) void qc.invalidateQueries({ queryKey: ['admin', 'notes'] })
@@ -131,7 +147,7 @@ export default function AdminEdit() {
} }
}, },
onError: (e) => { onError: (e) => {
setToast({ msg: '保存失败,请重试', kind: 'error' }) toast({ description: '保存失败,请重试', variant: 'error' })
if (e instanceof ApiError && e.code === 'slug_conflict') { if (e instanceof ApiError && e.code === 'slug_conflict') {
setSlugFieldError(e.message) setSlugFieldError(e.message)
} else { } else {
@@ -147,7 +163,7 @@ export default function AdminEdit() {
invalidatePublic(qc) invalidatePublic(qc)
navigate('/admin') navigate('/admin')
}, },
onError: () => setToast({ msg: '操作失败,请重试', kind: 'error' }), onError: () => toast({ description: '操作失败,请重试', variant: 'error' }),
}) })
const canSave = form.title.trim().length > 0 const canSave = form.title.trim().length > 0
@@ -170,19 +186,11 @@ export default function AdminEdit() {
const insert = (snippet: string) => set('content', form.content + snippet) const insert = (snippet: string) => set('content', form.content + snippet)
const addTag = () => {
const t = (tagInput ?? '').trim()
if (t && !form.tags.includes(t)) {
set('tags', [...form.tags, t])
}
setTagInput(null)
}
if (!isNew && isLoading) { if (!isNew && isLoading) {
return ( return (
<> <>
<AdminTopbar title="编辑笔记" /> <AdminTopbar title="编辑笔记" />
<div className="pn-admin-content"><div className="table-card empty">加载中…</div></div> <div className="pn-admin-content"><div className="pn-admin-loading"><Spin /></div></div>
</> </>
) )
} }
@@ -192,29 +200,28 @@ export default function AdminEdit() {
return ( return (
<> <>
<AdminTopbar title={isNew ? '新建笔记' : '编辑笔记'}> <AdminTopbar title={isNew ? '新建笔记' : '编辑笔记'}>
<button <Button
type="button" type="text"
className="btn ghost" icon={showPreview ? <EyeOff size={15} /> : <Eye size={15} />}
onClick={() => setShowPreview((v) => !v)} onClick={() => setShowPreview((v) => !v)}
> >
{showPreview ? <EyeOff size={15} /> : <Eye size={15} />} {showPreview ? '隐藏预览' : '预览'}
<span>{showPreview ? '隐藏预览' : '预览'}</span> </Button>
</button> <Button
<button type="primary"
type="button" icon={<Save size={15} />}
className="btn primary"
disabled={!canSave || save.isPending} disabled={!canSave || save.isPending}
onClick={() => save.mutate({ f: form, auto: false })} onClick={() => save.mutate({ f: form, auto: false })}
> >
<Save size={15} /> {save.isPending ? '保存中…' : isNew ? '保存草稿' : '保存'}
<span>{save.isPending ? '保存中…' : isNew ? '保存草稿' : '保存'}</span> </Button>
</button>
</AdminTopbar> </AdminTopbar>
<div className="pn-admin-content"> <div className="pn-admin-content">
<div className="pn-ae-grid"> <div className="pn-ae-grid">
<article className="card pn-ae-editor"> <Card className="pn-ae-main min-w-0">
<input <Input
variant="filled"
className="pn-ae-title-input" className="pn-ae-title-input"
type="text" type="text"
value={form.title} value={form.title}
@@ -229,124 +236,106 @@ export default function AdminEdit() {
<span>/notes/{slugDisplay}</span> <span>/notes/{slugDisplay}</span>
</span> </span>
<div className="pn-ae-tags"> <div className="pn-ae-tags">
{form.tags.map((t) => ( <Select
<span key={t} className="pill pn-ae-tag"> mode="tags"
{t} className="pn-ae-tags-select"
<button value={form.tags}
type="button" options={tagOptions}
className="pn-ae-tag-x" placeholder="添加或选择标签"
aria-label={`移除标签:${t}`} aria-label="标签"
onClick={() => set('tags', form.tags.filter((x) => x !== t))} tokenSeparators={[',']}
> onChange={(v) => set('tags', v)}
<X size={12} /> />
</button>
</span>
))}
{tagInput === null ? (
<button type="button" className="btn ghost pn-ae-add-tag" onClick={() => setTagInput('')}>
<CirclePlus size={13} /><span>添加标签</span>
</button>
) : (
<input
className="pn-ae-tag-input"
autoFocus
value={tagInput}
placeholder="标签名"
aria-label="新标签名"
onChange={(e) => setTagInput(e.target.value)}
onBlur={addTag}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
addTag()
}
if (e.key === 'Escape') setTagInput(null)
}}
/>
)}
</div> </div>
</div> </div>
<div className="pn-ae-toolbar" role="toolbar" aria-label="格式工具"> <div className="pn-ae-toolbar" role="toolbar" aria-label="格式工具">
<button type="button" className="btn ghost pn-ae-tool" aria-label="加粗" title="加粗" onClick={() => insert('**文本**')}><Bold size={15} /></button> <Button type="text" className="pn-ae-tool" aria-label="加粗" title="加粗" icon={<Bold size={15} />} onClick={() => insert('**文本**')} />
<button type="button" className="btn ghost pn-ae-tool" aria-label="斜体" title="斜体" onClick={() => insert('*文本*')}><Italic size={15} /></button> <Button type="text" className="pn-ae-tool" aria-label="斜体" title="斜体" icon={<Italic size={15} />} onClick={() => insert('*文本*')} />
<button type="button" className="btn ghost pn-ae-tool" aria-label="插入链接" title="插入链接" onClick={() => insert('[标题](https://)')}><Link2 size={15} /></button> <Button type="text" className="pn-ae-tool" aria-label="插入链接" title="插入链接" icon={<Link2 size={15} />} onClick={() => insert('[标题](https://)')} />
<button type="button" className="btn ghost pn-ae-tool" aria-label="插入代码" title="插入代码" onClick={() => insert('\n```\n代码\n```\n')}><Code size={15} /></button> <Button type="text" className="pn-ae-tool" aria-label="插入代码" title="插入代码" icon={<Code size={15} />} onClick={() => insert('\n```\n代码\n```\n')} />
<button type="button" className="btn ghost pn-ae-tool" aria-label="无序列表" title="无序列表" onClick={() => insert('\n- 列表项\n')}><List size={15} /></button> <Button type="text" className="pn-ae-tool" aria-label="无序列表" title="无序列表" icon={<List size={15} />} onClick={() => insert('\n- 列表项\n')} />
<button type="button" className="btn ghost pn-ae-tool" aria-label="引用" title="引用" onClick={() => insert('\n> 引用\n')}><Quote size={15} /></button> <Button type="text" className="pn-ae-tool" aria-label="引用" title="引用" icon={<Quote size={15} />} onClick={() => insert('\n> 引用\n')} />
<button type="button" className="btn ghost pn-ae-tool" aria-label="插入表格" title="插入表格" onClick={() => insert('\n| 列1 | 列2 |\n| --- | --- |\n| a | b |\n')}><Table size={15} /></button> <Button type="text" className="pn-ae-tool" aria-label="插入表格" title="插入表格" icon={<Table size={15} />} onClick={() => insert('\n| 列1 | 列2 |\n| --- | --- |\n| a | b |\n')} />
<span className="pn-ae-tool-hint">纯文本 · Markdown</span> <span className="pn-ae-tool-hint">纯文本 · Markdown</span>
</div> </div>
<div className="pn-ae-body-wrap"> <div className="pn-ae-body-wrap">
<Suspense fallback={<div className="table-card empty" style={{ minHeight: 420 }}>编辑器加载中…</div>}> <Suspense fallback={<div className="pn-admin-loading" style={{ minHeight: 420 }}><Spin /></div>}>
<Editor <Editor
value={form.content} value={form.content}
onChange={(v) => set('content', v)} onChange={(v) => set('content', v)}
onUploadStart={(name) => setUploadMsg(`上传中:${name}…`)} onUploadStart={(name) => setUploadMsg({ kind: 'busy', text: `上传中:${name}…` })}
onUploadEnd={(err) => setUploadMsg(err ?? null)} onUploadEnd={(err) => setUploadMsg(err ? { kind: 'error', text: err } : null)}
/> />
</Suspense> </Suspense>
</div> </div>
{uploadMsg && <p className="pn-ae-upload-msg">{uploadMsg}</p>} {uploadMsg && (
<Alert
className="pn-ae-upload-alert"
type={uploadMsg.kind === 'busy' ? 'info' : 'error'}
showIcon
message={uploadMsg.text}
/>
)}
{showPreview && ( {showPreview && (
<div className="pn-ae-preview"> <div className="pn-ae-preview">
<MarkdownViewer source={form.content} /> <MarkdownViewer source={form.content} />
</div> </div>
)} )}
</article> </Card>
<aside className="pn-ae-side"> <aside className="pn-ae-side">
<section className="card pn-ae-publish"> <Card styles={{ body: { padding: 20 } }}>
<span className="title">发布设置</span> <h3 className="pn-ae-side-title">发布设置</h3>
<div className="pn-ae-prow"> <div className="pn-ae-prow">
<span className="pn-ae-plabel">状态</span> <span className="pn-ae-plabel">状态</span>
<span <Badge
className={`pn-ae-pvalue pn-ae-mono ${form.status === 'public' ? '' : 'is-muted'}`} status={form.status === 'public' ? 'success' : 'default'}
style={form.status === 'public' ? { color: 'var(--success-text)' } : undefined} text={form.status === 'public' ? '公开' : '私有'}
> />
<span className="dot" />{form.status === 'public' ? '公开' : '私有'} <Switch
</span> checked={form.status === 'public'}
<button onChange={(checked) => set('status', checked ? 'public' : 'private')}
type="button"
role="switch"
className="pn-switch"
aria-checked={form.status === 'public'}
aria-label="公开笔记" aria-label="公开笔记"
onClick={() => set('status', form.status === 'public' ? 'private' : 'public')}
/> />
</div> </div>
<div className="pn-ae-prow"> <div className="pn-ae-prow">
<span className="pn-ae-plabel">置顶</span> <span className="pn-ae-plabel">置顶</span>
<button <Switch
type="button" checked={form.pinned}
role="switch" onChange={(checked) => set('pinned', checked)}
className="pn-switch"
aria-checked={form.pinned}
aria-label="置顶笔记" aria-label="置顶笔记"
onClick={() => set('pinned', !form.pinned)}
/> />
</div> </div>
<div className="pn-ae-prow"> <div className="pn-ae-prow">
<span className="pn-ae-plabel">日期</span> <span className="pn-ae-plabel">发布日期</span>
<span className="pn-ae-pvalue pn-ae-mono"> <DatePicker
<Calendar size={14} />{formatDateISO(savedAt ? Math.floor(savedAt / 1000) : (existing?.updated_at ?? Math.floor(Date.now() / 1000)))} className="pn-ae-datepicker"
</span> size="small"
value={dayjs.unix(form.publishedAt)}
onChange={(d) => d && set('publishedAt', d.unix())}
allowClear={false}
showTime={{ format: 'HH:mm' }}
format="YYYY-MM-DD HH:mm"
aria-label="发布日期"
/>
</div> </div>
<div className="pn-ae-pfield"> <div className="pn-ae-pfield">
<label htmlFor="pn-ae-slug-input">固定链接</label> <label className="pn-field-label text-xs" htmlFor="pn-ae-slug-input">固定链接</label>
<input <Input
id="pn-ae-slug-input" id="pn-ae-slug-input"
className={`pn-ae-input${slugFieldError ? ' is-error' : ''}`}
type="text" type="text"
value={form.slug} value={form.slug}
spellCheck={false} spellCheck={false}
status={slugFieldError ? 'error' : undefined}
placeholder={form.title ? slugPreview(form.title, new Date()) : '保存后自动生成'} placeholder={form.title ? slugPreview(form.title, new Date()) : '保存后自动生成'}
styles={{ input: { fontFamily: 'var(--font-mono)', fontSize: 12.5, lineHeight: 1.6 } }}
onChange={(e) => set('slug', e.target.value)} onChange={(e) => set('slug', e.target.value)}
/> />
<span className="pn-ae-phint">留空保持不变;首次保存自动生成后定稿</span> <span className="pn-ae-phint">留空保持不变;首次保存自动生成后定稿</span>
@@ -354,11 +343,11 @@ export default function AdminEdit() {
</div> </div>
<div className="pn-ae-pfield"> <div className="pn-ae-pfield">
<label htmlFor="pn-ae-summary">摘要</label> <label className="pn-field-label text-xs" htmlFor="pn-ae-summary">摘要</label>
<textarea <Input.TextArea
id="pn-ae-summary" id="pn-ae-summary"
className="pn-ae-input pn-ae-textarea"
rows={3} rows={3}
styles={{ textarea: { fontSize: 12.5, lineHeight: 1.6 } }}
value={form.summary} value={form.summary}
placeholder="留空自动截取正文" placeholder="留空自动截取正文"
onChange={(e) => set('summary', e.target.value)} onChange={(e) => set('summary', e.target.value)}
@@ -369,19 +358,22 @@ export default function AdminEdit() {
{!isNew && ( {!isNew && (
<> <>
<hr className="divider pn-ae-divider" /> <Divider className="pn-ae-divider" />
<button type="button" className="pn-ae-danger" disabled={trashMutation.isPending} onClick={() => trashMutation.mutate()}> <Button
<Trash2 size={15} /> danger
<span>{trashMutation.isPending ? '删除中…' : '移至回收站'}</span> block
</button> icon={<Trash2 size={15} />}
disabled={trashMutation.isPending}
onClick={() => trashMutation.mutate()}
>
{trashMutation.isPending ? '删除中…' : '移至回收站'}
</Button>
</> </>
)} )}
</section> </Card>
</aside> </aside>
</div> </div>
</div> </div>
<Toast message={toast?.msg ?? null} kind={toast?.kind} onDone={() => setToast(null)} />
</> </>
) )
} }
+182 -163
View File
@@ -1,14 +1,16 @@
import { Link } from 'react-router' import { useMemo, useState } from 'react'
import { Link, useNavigate } from 'react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { CirclePlus, Eye, EyeOff, Pencil, Pin, Search, Trash2 } from 'lucide-react' import { CirclePlus, Eye, EyeOff, Pencil, Pin, Search, Trash2 } from 'lucide-react'
import { useMemo, useState } from 'react' import { Badge, Button, Card, Empty, Input, Modal, Segmented, Table, Tag } from 'antd'
import type { TableProps } from 'antd'
import { api } from '../lib/api' import { api } from '../lib/api'
import type { NoteItem } from '../lib/api' import type { NoteItem } from '../lib/api'
import { invalidatePublic } from '../lib/invalidate' import { invalidatePublic } from '../lib/invalidate'
import { useDocumentTitle } from '../features/site' import { useDocumentTitle } from '../features/site'
import { formatDateISO } from '../lib/utils' import { formatDateISO } from '../lib/utils'
import { AdminTopbar } from '../components/Layout' import { AdminTopbar } from '../components/Layout'
import Toast from '../components/Toast' import { useToast } from '@/hooks/use-toast'
type StatusFilter = 'all' | 'public' | 'private' type StatusFilter = 'all' | 'public' | 'private'
@@ -23,20 +25,21 @@ export default function AdminList() {
useDocumentTitle('笔记管理') useDocumentTitle('笔记管理')
const { data, isLoading } = useAdminNotes() const { data, isLoading } = useAdminNotes()
const qc = useQueryClient() const qc = useQueryClient()
const navigate = useNavigate()
const { toast } = useToast()
const [confirmNote, setConfirmNote] = useState<NoteItem | null>(null) const [confirmNote, setConfirmNote] = useState<NoteItem | null>(null)
const [filter, setFilter] = useState<StatusFilter>('all') const [filter, setFilter] = useState<StatusFilter>('all')
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [toast, setToast] = useState<{ msg: string; kind: 'success' | 'error' } | null>(null)
const trashMutation = useMutation({ const trashMutation = useMutation({
mutationFn: (id: number) => api(`/api/admin/notes/${id}`, { method: 'DELETE' }), mutationFn: (id: number) => api(`/api/admin/notes/${id}`, { method: 'DELETE' }),
onSuccess: () => { onSuccess: () => {
setConfirmNote(null) setConfirmNote(null)
setToast({ msg: '笔记已移至回收站', kind: 'success' }) toast({ description: '笔记已移至回收站' })
void qc.invalidateQueries({ queryKey: ['admin'] }) void qc.invalidateQueries({ queryKey: ['admin'] })
invalidatePublic(qc) invalidatePublic(qc)
}, },
onError: () => setToast({ msg: '操作失败,请重试', kind: 'error' }), onError: () => toast({ description: '操作失败,请重试', variant: 'error' }),
}) })
const toggleStatus = useMutation({ const toggleStatus = useMutation({
@@ -57,11 +60,11 @@ export default function AdminList() {
}) })
}, },
onSuccess: (_d, note) => { onSuccess: (_d, note) => {
setToast({ msg: note.status === 'public' ? '已转为私有' : '已转为公开', kind: 'success' }) toast({ description: note.status === 'public' ? '已转为私有' : '已转为公开' })
void qc.invalidateQueries({ queryKey: ['admin'] }) void qc.invalidateQueries({ queryKey: ['admin'] })
invalidatePublic(qc) invalidatePublic(qc)
}, },
onError: () => setToast({ msg: '操作失败,请重试', kind: 'error' }), onError: () => toast({ description: '操作失败,请重试', variant: 'error' }),
}) })
const items = data?.items ?? [] const items = data?.items ?? []
@@ -81,180 +84,196 @@ export default function AdminList() {
}) })
}, [items, filter, query]) }, [items, filter, query])
const columns: TableProps<NoteItem>['columns'] = [
{
title: '标题',
key: 'title',
render: (_, n) => (
<>
<Link
to={`/admin/notes/${n.id}/edit`}
className="pn-al-note-title"
style={{ textDecoration: 'none' }}
>
{n.pinned && <Pin size={12} aria-label="置顶" />}
<span>{n.title}</span>
</Link>
<span className="pn-al-note-slug">/{n.slug}</span>
</>
),
},
{
title: '标签',
key: 'tags',
render: (_, n) =>
n.tags.length > 0 ? (
<div className="pn-al-tags">
{n.tags.map((t) => <Tag key={t}>{t}</Tag>)}
</div>
) : (
<span className="pn-al-note-slug">—</span>
),
},
{
title: '日期',
key: 'date',
render: (_, n) => <span className="pn-al-date">{formatDateISO(n.updated_at)}</span>,
},
{
title: '状态',
key: 'status',
render: (_, n) => (
<Badge status={n.status === 'public' ? 'success' : 'default'} text={n.status === 'public' ? '已发布' : '私有'} />
),
},
{
title: '操作',
key: 'actions',
className: 'pn-al-col-actions',
render: (_, n) => {
const isPublic = n.status === 'public'
return (
<div className="pn-al-actions-cell">
<Button
type="text"
icon={isPublic ? <EyeOff size={15} /> : <Eye size={15} />}
title={isPublic ? '转为私有' : '转为公开'}
aria-label={isPublic ? '转为私有' : '转为公开'}
disabled={toggleStatus.isPending}
onClick={() => void toggleStatus.mutate(n)}
/>
<Button
type="text"
icon={<Pencil size={15} />}
title="编辑"
aria-label={`编辑笔记:${n.title}`}
onClick={() => void navigate(`/admin/notes/${n.id}/edit`)}
/>
<Button
type="text"
danger
icon={<Trash2 size={15} />}
title="移至回收站"
aria-label={`移至回收站:${n.title}`}
onClick={() => setConfirmNote(n)}
/>
</div>
)
},
},
]
return ( return (
<> <>
<AdminTopbar title="笔记管理"> <AdminTopbar title="笔记管理">
<label className="pn-admin-search"> <Input
<Search size={14} aria-hidden /> className="pn-al-search"
<input allowClear
type="search" placeholder="搜索笔记…"
placeholder="搜索笔记…" aria-label="搜索笔记"
aria-label="搜索笔记" value={query}
value={query} onChange={(e) => setQuery(e.target.value)}
onChange={(e) => setQuery(e.target.value)} prefix={<Search size={14} aria-hidden />}
/> />
</label> <Button
<Link to="/admin/notes/new" className="btn primary"> type="primary"
<CirclePlus size={15} /> icon={<CirclePlus size={15} />}
<span>新建笔记</span> onClick={() => void navigate('/admin/notes/new')}
</Link> >
新建笔记
</Button>
</AdminTopbar> </AdminTopbar>
<div className="pn-admin-content"> <div className="pn-admin-content">
<section className="pn-al-metrics"> <section className="pn-al-metrics">
<article className="card metric"> <Card className="pn-al-metric" styles={{ body: { padding: '16px 18px' } }}>
<span className="eyebrow">笔记</span> <span className="text-[11.5px] tracking-[0.04em] text-muted-foreground">笔记</span>
<strong className="value">{data?.total ?? items.length}</strong> <strong className="my-0.5 text-[28px] font-bold leading-[1.2] tracking-[-0.03em] text-card-foreground max-md:text-[22px]">
<span className="meta">全部笔记,含 {privateCount} 篇私有</span> {data?.total ?? items.length}
</article> </strong>
<article className="card metric"> <span className="text-xs leading-normal text-muted-foreground max-md:hidden">
<span className="eyebrow">已发布</span> 全部笔记,含 {privateCount} 篇私有
<strong className="value">{publicCount}</strong> </span>
<span className="meta">对外公开的笔记</span> </Card>
</article> <Card className="pn-al-metric" styles={{ body: { padding: '16px 18px' } }}>
<article className="card metric"> <span className="text-[11.5px] tracking-[0.04em] text-muted-foreground">已发布</span>
<span className="eyebrow">私有</span> <strong className="my-0.5 text-[28px] font-bold leading-[1.2] tracking-[-0.03em] text-card-foreground max-md:text-[22px]">
<strong className="value">{privateCount}</strong> {publicCount}
<span className="meta">尚未公开,仅自己可见</span> </strong>
</article> <span className="text-xs leading-normal text-muted-foreground max-md:hidden">
对外公开的笔记
</span>
</Card>
<Card className="pn-al-metric" styles={{ body: { padding: '16px 18px' } }}>
<span className="text-[11.5px] tracking-[0.04em] text-muted-foreground">私有</span>
<strong className="my-0.5 text-[28px] font-bold leading-[1.2] tracking-[-0.03em] text-card-foreground max-md:text-[22px]">
{privateCount}
</strong>
<span className="text-xs leading-normal text-muted-foreground max-md:hidden">
尚未公开,仅自己可见
</span>
</Card>
</section> </section>
<section className="table-card pn-al-table-card"> <Card className="pn-al-card min-w-0 flex-1 overflow-hidden" styles={{ body: { padding: 0 } }}>
<div className="pn-al-table-head"> <div className="pn-al-table-head">
<nav className="tabs" aria-label="笔记筛选"> <Segmented
<button type="button" className={filter === 'all' ? 'active' : undefined} onClick={() => setFilter('all')}> value={filter}
全部<span className="pn-al-tab-num">{items.length}</span> onChange={(v) => setFilter(v as StatusFilter)}
</button> className="min-w-0"
<button type="button" className={filter === 'public' ? 'active' : undefined} onClick={() => setFilter('public')}> options={[
已发布<span className="pn-al-tab-num">{publicCount}</span> { value: 'all', label: <>全部<Badge count={items.length} showZero size="small" className="pn-al-tab-badge" /></> },
</button> { value: 'public', label: <>已发布<Badge count={publicCount} showZero size="small" className="pn-al-tab-badge" /></> },
<button type="button" className={filter === 'private' ? 'active' : undefined} onClick={() => setFilter('private')}> { value: 'private', label: <>私有<Badge count={privateCount} showZero size="small" className="pn-al-tab-badge" /></> },
私有<span className="pn-al-tab-num">{privateCount}</span> ]}
</button> />
</nav>
<span className="pn-al-table-note">共 {visible.length} 篇笔记</span> <span className="pn-al-table-note">共 {visible.length} 篇笔记</span>
</div> </div>
{isLoading ? ( <Table
<div className="empty">加载中…</div> className="pn-al-table"
) : visible.length === 0 ? ( columns={columns}
<div className="empty"> dataSource={visible}
{items.length === 0 ? '还没有笔记,点击「新建笔记」开始' : '没有符合筛选条件的笔记'} rowKey="id"
</div> loading={isLoading}
) : ( pagination={false}
<div className="pn-table-scroll"> scroll={{ x: 600 }}
<table className="table" aria-label="笔记列表"> rowClassName={(n) => (n.status === 'public' ? '' : 'pn-al-is-draft')}
<thead> locale={{
<tr> emptyText: (
<th>标题</th> <Empty
<th>标签</th> image={Empty.PRESENTED_IMAGE_SIMPLE}
<th>日期</th> description={items.length === 0 ? '还没有笔记,点击「新建笔记」开始' : '没有符合筛选条件的笔记'}
<th>状态</th> />
<th className="pn-al-col-actions">操作</th> ),
</tr> }}
</thead> />
<tbody>
{visible.map((n) => {
const isPublic = n.status === 'public'
return (
<tr key={n.id} className={isPublic ? undefined : 'pn-al-is-draft'}>
<td>
<Link to={`/admin/notes/${n.id}/edit`} className="pn-al-note-title" style={{ textDecoration: 'none' }}>
{n.pinned && <Pin size={12} aria-label="置顶" />}
<span>{n.title}</span>
</Link>
<span className="pn-al-note-slug">/{n.slug}</span>
</td>
<td>
{n.tags.length > 0 ? (
<div className="pn-al-tags">
{n.tags.map((t) => <span key={t} className="pn-al-tag">{t}</span>)}
</div>
) : (
<span className="pn-al-note-slug">—</span>
)}
</td>
<td><span className="pn-al-date">{formatDateISO(n.updated_at)}</span></td>
<td>
<span className={`pn-al-status ${isPublic ? 'pn-al-is-public' : 'pn-al-is-draft'}`}>
<span className="dot" />{isPublic ? '已发布' : '私有'}
</span>
</td>
<td>
<div className="pn-al-actions-cell">
<button
type="button"
className="btn ghost pn-icon-btn"
title={isPublic ? '转为私有' : '转为公开'}
aria-label={isPublic ? '转为私有' : '转为公开'}
disabled={toggleStatus.isPending}
onClick={() => void toggleStatus.mutate(n)}
>
{isPublic ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
<Link
to={`/admin/notes/${n.id}/edit`}
className="btn ghost pn-icon-btn"
title="编辑"
aria-label={`编辑笔记:${n.title}`}
>
<Pencil size={15} />
</Link>
<button
type="button"
className="btn ghost pn-icon-btn danger"
title="移至回收站"
aria-label={`移至回收站:${n.title}`}
onClick={() => setConfirmNote(n)}
>
<Trash2 size={15} />
</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
<div className="pn-al-table-foot"> <div className="pn-al-table-foot">
<span className="pn-al-foot-note">共 {data?.total ?? 0} 篇 · 显示 {visible.length} 篇</span> <span className="pn-al-foot-note">共 {data?.total ?? 0} 篇 · 显示 {visible.length} 篇</span>
</div> </div>
</section> </Card>
</div> </div>
{confirmNote && ( <Modal
<div open={confirmNote !== null}
className="pn-modal-overlay" onCancel={() => setConfirmNote(null)}
role="dialog" title="移入回收站?"
aria-modal="true" width={380}
aria-label="移入回收站" centered
onClick={(e) => { maskClosable={false}
if (e.target === e.currentTarget) setConfirmNote(null) cancelText="取消"
}} okText={trashMutation.isPending ? '删除中…' : '确认删除'}
> confirmLoading={trashMutation.isPending}
<div className="card pn-modal"> onOk={() => {
<h3>移入回收站?</h3> if (confirmNote) trashMutation.mutate(confirmNote.id)
<p>「{confirmNote.title}」将被软删除,30 天内可在回收站恢复。</p> }}
{trashMutation.isError && <p style={{ color: 'var(--destructive-text)' }}>操作失败,请重试</p>} >
<div className="pn-modal-actions"> <p className="pn-modal-desc">
<button type="button" className="btn secondary" onClick={() => setConfirmNote(null)}>取消</button> 「{confirmNote?.title}」将被软删除,30 天内可在回收站恢复。
<button </p>
type="button" {trashMutation.isError && <p className="pn-modal-error">操作失败,请重试</p>}
className="btn danger" </Modal>
disabled={trashMutation.isPending}
onClick={() => trashMutation.mutate(confirmNote.id)}
>
{trashMutation.isPending ? '删除中…' : '确认删除'}
</button>
</div>
</div>
</div>
)}
<Toast message={toast?.msg ?? null} kind={toast?.kind} onDone={() => setToast(null)} />
</> </>
) )
} }
+115 -82
View File
@@ -1,16 +1,20 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Eye, EyeOff, KeyRound, Lock, Save } from 'lucide-react' import { Eye, EyeOff, ImageUp, KeyRound, Lock, RotateCcw, Save } from 'lucide-react'
import { Button, Card, Divider, Input, Select, Upload } from 'antd'
import { api, ApiError } from '../lib/api' import { api, ApiError } from '../lib/api'
import type { SiteSettings } from '../lib/api' import type { SiteSettings } from '../lib/api'
import { invalidatePublic } from '../lib/invalidate' import { invalidatePublic } from '../lib/invalidate'
import { useDocumentTitle } from '../features/site' import { useDocumentTitle } from '../features/site'
import { AdminTopbar } from '../components/Layout' import { AdminTopbar } from '../components/Layout'
import Toast from '../components/Toast' import { useToast } from '@/hooks/use-toast'
const PAGE_SIZE_OPTIONS = [10, 20, 30, 50].map((n) => ({ value: n, label: `${n} 篇` }))
export default function AdminSettings() { export default function AdminSettings() {
useDocumentTitle('设置') useDocumentTitle('设置')
const qc = useQueryClient() const qc = useQueryClient()
const { toast } = useToast()
const { data } = useQuery({ const { data } = useQuery({
queryKey: ['admin', 'settings'], queryKey: ['admin', 'settings'],
queryFn: () => api<SiteSettings>('/api/admin/settings'), queryFn: () => api<SiteSettings>('/api/admin/settings'),
@@ -20,7 +24,7 @@ export default function AdminSettings() {
const [desc, setDesc] = useState('') const [desc, setDesc] = useState('')
const [beian, setBeian] = useState('') const [beian, setBeian] = useState('')
const [pageSize, setPageSize] = useState(10) const [pageSize, setPageSize] = useState(10)
const [toast, setToast] = useState<{ msg: string; kind: 'success' | 'error' } | null>(null) const [logo, setLogo] = useState('')
useEffect(() => { useEffect(() => {
if (data) { if (data) {
@@ -28,6 +32,7 @@ export default function AdminSettings() {
setDesc(data.site_desc) setDesc(data.site_desc)
setBeian(data.beian_no) setBeian(data.beian_no)
setPageSize(data.page_size) setPageSize(data.page_size)
setLogo(data.site_logo ?? '')
} }
}, [data]) }, [data])
@@ -35,62 +40,78 @@ export default function AdminSettings() {
mutationFn: () => mutationFn: () =>
api('/api/admin/settings', { api('/api/admin/settings', {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ site_title: title, site_desc: desc, beian_no: beian, page_size: pageSize }), body: JSON.stringify({ site_title: title, site_desc: desc, beian_no: beian, page_size: pageSize, site_logo: logo }),
}), }),
onSuccess: () => { onSuccess: () => {
setToast({ msg: '设置已保存', kind: 'success' }) toast({ description: '设置已保存' })
void qc.invalidateQueries({ queryKey: ['site'] }) void qc.invalidateQueries({ queryKey: ['site'] })
invalidatePublic(qc) // page_size 等设置影响公共列表 invalidatePublic(qc) // page_size 等设置影响公共列表
}, },
onError: (e) => setToast({ msg: e instanceof ApiError ? e.message : '保存失败', kind: 'error' }), onError: (e) => toast({ description: e instanceof ApiError ? e.message : '保存失败', variant: 'error' }),
}) })
// antd Upload customRequest 的最小结构化类型(避免引入 rc-upload 内部类型路径)
const uploadLogo = async ({ file, onSuccess, onError }: {
file: unknown
onSuccess?: (ret?: unknown, xhr?: XMLHttpRequest) => void
onError?: (event: Error, body?: unknown) => void
}) => {
const fd = new FormData()
fd.append('file', file as File)
try {
const res = await api<{ url: string }>('/api/admin/images', { method: 'POST', body: fd })
setLogo(res.url)
onSuccess?.(res)
toast({ description: 'Logo 已上传,记得保存更改' })
} catch (e) {
onError?.(e instanceof Error ? e : new Error('上传失败'))
toast({ description: e instanceof ApiError ? e.message : '上传失败', variant: 'error' })
}
}
return ( return (
<> <>
<AdminTopbar title="站点设置"> <AdminTopbar title="站点设置">
<button <Button
type="button" type="primary"
className="btn primary" icon={<Save size={15} />}
disabled={saveSettings.isPending} disabled={saveSettings.isPending}
onClick={() => saveSettings.mutate()} onClick={() => saveSettings.mutate()}
> >
<Save size={15} />
{saveSettings.isPending ? '保存中…' : '保存更改'} {saveSettings.isPending ? '保存中…' : '保存更改'}
</button> </Button>
</AdminTopbar> </AdminTopbar>
<div className="pn-admin-content pn-as-content"> <div className="pn-admin-content pn-as-content">
<section className="card pn-as-card"> <Card className="pn-as-card">
<div className="pn-as-card-head"> <div className="pn-as-card-head">
<h2 className="title">站点信息</h2> <h3 className="pn-as-card-title">站点信息</h3>
<p className="meta">这些信息会显示在博客的公开页面</p> <p className="pn-as-card-desc">这些信息会显示在博客的公开页面</p>
</div> </div>
<hr className="divider" /> <Divider style={{ margin: '16px 0 20px' }} />
<div className="pn-as-fields"> <div className="pn-as-fields">
<div className="pn-as-field"> <div className="pn-as-field">
<label className="pn-as-label" htmlFor="site-title">站点名称</label> <label htmlFor="site-title" className="pn-field-label">站点名称</label>
<input <Input
id="site-title" id="site-title"
className="pn-as-input"
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
/> />
</div> </div>
<div className="pn-as-field"> <div className="pn-as-field">
<label className="pn-as-label" htmlFor="site-desc">站点描述</label> <label htmlFor="site-desc" className="pn-field-label">站点描述</label>
<textarea <Input.TextArea
id="site-desc" id="site-desc"
className="pn-as-input"
rows={3} rows={3}
styles={{ textarea: { minHeight: 76, padding: '8px 12px', lineHeight: 1.7 } }}
value={desc} value={desc}
onChange={(e) => setDesc(e.target.value)} onChange={(e) => setDesc(e.target.value)}
/> />
</div> </div>
<div className="pn-as-field"> <div className="pn-as-field">
<label className="pn-as-label" htmlFor="beian-no">公安备案号</label> <label htmlFor="beian-no" className="pn-field-label">公安备案号</label>
<input <Input
id="beian-no" id="beian-no"
className="pn-as-input"
placeholder="如:京公网安备11000000000000号" placeholder="如:京公网安备11000000000000号"
value={beian} value={beian}
onChange={(e) => setBeian(e.target.value)} onChange={(e) => setBeian(e.target.value)}
@@ -98,25 +119,46 @@ export default function AdminSettings() {
<p className="pn-as-hint">显示在博客页脚并链接至公安部备案官网;留空则不显示。</p> <p className="pn-as-hint">显示在博客页脚并链接至公安部备案官网;留空则不显示。</p>
</div> </div>
<div className="pn-as-field"> <div className="pn-as-field">
<label className="pn-as-label" htmlFor="page-size">首页每页条数(1–100)</label> <label htmlFor="page-size" className="pn-field-label">首页每页条数</label>
<input <Select
id="page-size" id="page-size"
className="pn-as-input"
type="number"
min={1}
max={100}
style={{ width: 140 }} style={{ width: 140 }}
options={PAGE_SIZE_OPTIONS}
value={pageSize} value={pageSize}
onChange={(e) => setPageSize(Number(e.target.value))} onChange={(v) => setPageSize(v)}
/> />
</div> </div>
<div className="pn-as-field">
<span className="pn-field-label">站点 Logo</span>
<div className="pn-as-logo-row">
{logo ? (
<img className="pn-as-logo-preview" src={logo} alt="站点 Logo 预览" />
) : (
<span className="pn-as-logo-empty">未设置</span>
)}
<Upload
accept="image/png,image/jpeg,image/webp,image/gif"
showUploadList={false}
customRequest={uploadLogo}
>
<Button icon={<ImageUp size={15} />}>{logo ? '更换图片' : '上传图片'}</Button>
</Upload>
{logo && (
<Button
icon={<RotateCcw size={15} />}
onClick={() => setLogo('')}
>
重置默认
</Button>
)}
</div>
<p className="pn-as-hint">显示在博客顶部站点名称前,并用作站点 favicon,建议正方形图片;「重置默认」恢复内置图标。修改后需点击「保存更改」。</p>
</div>
</div> </div>
</section> </Card>
<ChangePassword onToast={setToast} /> <ChangePassword onToast={toast} />
</div> </div>
<Toast message={toast?.msg ?? null} kind={toast?.kind} onDone={() => setToast(null)} />
</> </>
) )
} }
@@ -124,7 +166,7 @@ export default function AdminSettings() {
function ChangePassword({ function ChangePassword({
onToast, onToast,
}: { }: {
onToast: (t: { msg: string; kind: 'success' | 'error' } | null) => void onToast: ReturnType<typeof useToast>['toast']
}) { }) {
const [oldPw, setOldPw] = useState('') const [oldPw, setOldPw] = useState('')
const [newPw, setNewPw] = useState('') const [newPw, setNewPw] = useState('')
@@ -137,85 +179,76 @@ function ChangePassword({
body: JSON.stringify({ old_password: oldPw, new_password: newPw }), body: JSON.stringify({ old_password: oldPw, new_password: newPw }),
}), }),
onSuccess: () => { onSuccess: () => {
onToast({ msg: '密码已修改', kind: 'success' }) onToast({ description: '密码已修改' })
setOldPw('') setOldPw('')
setNewPw('') setNewPw('')
}, },
onError: (e) => { onError: (e) => {
if (e instanceof ApiError) { if (e instanceof ApiError) {
onToast({ onToast({
msg: description:
e.code === 'weak_password' e.code === 'weak_password'
? '新密码长度至少 12 个字符' ? '新密码长度至少 12 个字符'
: e.status === 429 : e.status === 429
? '尝试过于频繁,请稍后再试' ? '尝试过于频繁,请稍后再试'
: '旧密码不正确', : '旧密码不正确',
kind: 'error', variant: 'error',
}) })
} else { } else {
onToast({ msg: '修改失败', kind: 'error' }) onToast({ description: '修改失败', variant: 'error' })
} }
}, },
}) })
return ( return (
<section className="card pn-as-card"> <Card className="pn-as-card">
<div className="pn-as-card-head"> <div className="pn-as-card-head">
<h2 className="title">修改密码</h2> <h3 className="pn-as-card-title">修改密码</h3>
<p className="meta">更新管理后台的登录密码</p> <p className="pn-as-card-desc">更新管理后台的登录密码</p>
</div> </div>
<hr className="divider" /> <Divider style={{ margin: '16px 0 20px' }} />
<div className="pn-as-fields"> <div className="pn-as-fields">
<div className="pn-as-field"> <div className="pn-as-field">
<label className="pn-as-label" htmlFor="old-pw">旧密码</label> <label htmlFor="old-pw" className="pn-field-label">旧密码</label>
<div className="pn-as-control"> <Input
<Lock size={15} className="pn-as-leading-icon" aria-hidden /> id="old-pw"
<input type={showPw ? 'text' : 'password'}
id="old-pw" autoComplete="current-password"
className="pn-as-input" prefix={<Lock size={15} className="pn-input-icon" aria-hidden />}
type={showPw ? 'text' : 'password'} value={oldPw}
autoComplete="current-password" onChange={(e) => setOldPw(e.target.value)}
value={oldPw} />
onChange={(e) => setOldPw(e.target.value)}
/>
</div>
</div> </div>
<div className="pn-as-field"> <div className="pn-as-field">
<label className="pn-as-label" htmlFor="new-pw">新密码(≥12 字符)</label> <label htmlFor="new-pw" className="pn-field-label">新密码(≥12 字符)</label>
<div className="pn-as-control has-toggle"> <Input
<Lock size={15} className="pn-as-leading-icon" aria-hidden /> id="new-pw"
<input type={showPw ? 'text' : 'password'}
id="new-pw" autoComplete="new-password"
className="pn-as-input" placeholder="请输入新密码,至少 12 个字符"
type={showPw ? 'text' : 'password'} prefix={<Lock size={15} className="pn-input-icon" aria-hidden />}
autoComplete="new-password" suffix={
placeholder="请输入新密码,至少 12 个字符" <Button
value={newPw} type="text"
onChange={(e) => setNewPw(e.target.value)} size="small"
/> aria-label={showPw ? '隐藏密码' : '显示密码'}
<button onClick={() => setShowPw((v) => !v)}
type="button" icon={showPw ? <EyeOff size={15} /> : <Eye size={15} />}
className="pn-as-eye" />
aria-label={showPw ? '隐藏密码' : '显示密码'} }
onClick={() => setShowPw((v) => !v)} />
>
{showPw ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
</div>
<p className="pn-as-hint">新密码长度至少 12 个字符,建议混合字母、数字与符号。</p> <p className="pn-as-hint">新密码长度至少 12 个字符,建议混合字母、数字与符号。</p>
</div> </div>
</div> </div>
<div className="pn-as-card-foot"> <div className="pn-as-card-foot">
<button <Button
type="button"
className="btn secondary"
disabled={change.isPending || oldPw.length === 0 || newPw.length === 0} disabled={change.isPending || oldPw.length === 0 || newPw.length === 0}
onClick={() => change.mutate()} onClick={() => change.mutate()}
> >
<KeyRound size={15} /> <KeyRound size={15} />
{change.isPending ? '提交中…' : '修改密码'} {change.isPending ? '提交中…' : '修改密码'}
</button> </Button>
</div> </div>
</section> </Card>
) )
} }
+106 -64
View File
@@ -1,18 +1,20 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Info, RotateCcw } from 'lucide-react' import { RotateCcw, Trash2 } from 'lucide-react'
import { useState } from 'react' import { Alert, App, Button, Card, Empty, Table, Tag } from 'antd'
import type { TableProps } from 'antd'
import { api } from '../lib/api' import { api } from '../lib/api'
import type { NoteItem } from '../lib/api' import type { NoteItem } from '../lib/api'
import { invalidatePublic } from '../lib/invalidate' import { invalidatePublic } from '../lib/invalidate'
import { useDocumentTitle } from '../features/site' import { useDocumentTitle } from '../features/site'
import { daysRemaining, formatDateISO } from '../lib/utils' import { daysRemaining, formatDateISO } from '../lib/utils'
import { AdminTopbar } from '../components/Layout' import { AdminTopbar } from '../components/Layout'
import Toast from '../components/Toast' import { useToast } from '@/hooks/use-toast'
export default function AdminTrash() { export default function AdminTrash() {
useDocumentTitle('回收站') useDocumentTitle('回收站')
const qc = useQueryClient() const qc = useQueryClient()
const [toast, setToast] = useState<{ msg: string; kind: 'success' | 'error' } | null>(null) const { toast } = useToast()
const { modal } = App.useApp()
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['admin', 'trash'], queryKey: ['admin', 'trash'],
queryFn: () => api<{ items: NoteItem[]; total: number }>('/api/admin/trash'), queryFn: () => api<{ items: NoteItem[]; total: number }>('/api/admin/trash'),
@@ -21,83 +23,123 @@ export default function AdminTrash() {
const restore = useMutation({ const restore = useMutation({
mutationFn: (id: number) => api(`/api/admin/trash/${id}/restore`, { method: 'POST' }), mutationFn: (id: number) => api(`/api/admin/trash/${id}/restore`, { method: 'POST' }),
onSuccess: () => { onSuccess: () => {
setToast({ msg: '笔记已恢复', kind: 'success' }) toast({ description: '笔记已恢复' })
void qc.invalidateQueries({ queryKey: ['admin'] }) void qc.invalidateQueries({ queryKey: ['admin'] })
invalidatePublic(qc) invalidatePublic(qc)
}, },
onError: () => setToast({ msg: '恢复失败,请重试', kind: 'error' }), onError: () => toast({ description: '恢复失败,请重试', variant: 'error' }),
}) })
const clear = useMutation({
mutationFn: () => api<{ deleted: number }>('/api/admin/trash', { method: 'DELETE' }),
onSuccess: (res) => {
toast({ description: `已清空回收站(${res.deleted} 篇)` })
void qc.invalidateQueries({ queryKey: ['admin'] })
invalidatePublic(qc)
},
onError: () => toast({ description: '操作失败,请重试', variant: 'error' }),
})
const confirmEmpty = () => {
modal.confirm({
title: '清空回收站?',
content: `将永久删除回收站中的 ${items.length} 篇笔记,此操作不可恢复。`,
okText: '清空',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: () => clear.mutateAsync(),
})
}
const items = data?.items ?? [] const items = data?.items ?? []
const columns: TableProps<NoteItem>['columns'] = [
{
title: '标题',
key: 'title',
className: 'pn-at-title-cell',
render: (_, n) => (
<>
<span className="pn-at-note-title">{n.title}</span>
<span className="pn-at-note-slug">/{n.slug}</span>
</>
),
},
{
title: '删除时间',
key: 'deleted',
render: (_, n) => (
<span className="pn-at-mono">{n.deleted_at ? formatDateISO(n.deleted_at) : '—'}</span>
),
},
{
title: '剩余天数',
key: 'days',
render: (_, n) => {
const left = n.deleted_at ? daysRemaining(n.deleted_at) : 0
return left <= 3 ? <Tag color="red">{left} 天</Tag> : <span className="pn-at-days">{left} 天</span>
},
},
{
title: '操作',
key: 'actions',
className: 'pn-al-col-actions',
render: (_, n) => (
<div className="pn-at-actions">
<Button
type="text"
size="small"
icon={<RotateCcw size={14} />}
disabled={restore.isPending}
onClick={() => restore.mutate(n.id)}
>
恢复
</Button>
</div>
),
},
]
return ( return (
<> <>
<AdminTopbar title="回收站" /> <AdminTopbar title="回收站">
<Button
danger
icon={<Trash2 size={15} />}
disabled={isLoading || items.length === 0 || clear.isPending}
onClick={confirmEmpty}
>
清空回收站
</Button>
</AdminTopbar>
<div className="pn-admin-content"> <div className="pn-admin-content">
<div className="pn-at-alert" role="note"> <Alert
<Info size={16} /> role="note"
<p>回收站中的笔记会在删除 30 天后永久删除,请及时恢复需要的内容。</p> type="info"
</div> showIcon
message="回收站中的笔记会在删除 30 天后永久删除,请及时恢复需要的内容。"
/>
<section className="table-card pn-at-table-card"> <Card className="pn-al-card min-w-0 flex-1 overflow-hidden" styles={{ body: { padding: 0 } }}>
{isLoading ? ( <Table
<div className="empty">加载中…</div> className="pn-al-table"
) : items.length === 0 ? ( columns={columns}
<div className="empty">回收站是空的</div> dataSource={items}
) : ( rowKey="id"
<div className="pn-table-scroll"> loading={isLoading}
<table className="table" aria-label="回收站笔记列表"> pagination={false}
<thead> scroll={{ x: 560 }}
<tr> locale={{
<th>标题</th> emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="回收站是空的" />,
<th>删除时间</th> }}
<th>剩余天数</th> />
<th className="pn-al-col-actions">操作</th>
</tr>
</thead>
<tbody>
{items.map((n) => {
const left = n.deleted_at ? daysRemaining(n.deleted_at) : 0
return (
<tr key={n.id}>
<td className="pn-at-title-cell">
<span className="pn-at-note-title">{n.title}</span>
<span className="pn-at-note-slug">/{n.slug}</span>
</td>
<td>
<span className="pn-at-mono">{n.deleted_at ? formatDateISO(n.deleted_at) : '—'}</span>
</td>
<td>
<span className={`pn-at-days${left <= 3 ? ' pn-at-days-urgent' : ''}`}>{left} 天</span>
</td>
<td>
<div className="pn-at-actions">
<button
type="button"
className="btn ghost"
disabled={restore.isPending}
onClick={() => restore.mutate(n.id)}
>
<RotateCcw size={14} />恢复
</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
<div className="pn-al-table-foot"> <div className="pn-al-table-foot">
<span className="pn-al-foot-note">共 {data?.total ?? 0} 篇已删除笔记</span> <span className="pn-al-foot-note">共 {data?.total ?? 0} 篇已删除笔记</span>
</div> </div>
</section> </Card>
</div> </div>
<Toast message={toast?.msg ?? null} kind={toast?.kind} onDone={() => setToast(null)} />
</> </>
) )
} }
+52 -75
View File
@@ -1,9 +1,10 @@
import { useState } from 'react' import { useState } from 'react'
import { Link } from 'react-router' import { Link } from 'react-router'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { ArrowRight, ChevronLeft, ChevronRight, Pin } from 'lucide-react' import { ArrowRight, Pin } from 'lucide-react'
import { Empty, Flex, Pagination, Skeleton, Tag } from 'antd'
import { api } from '../lib/api' import { api } from '../lib/api'
import type { NoteItem, NoteList, TagCount } from '../lib/api' import type { NoteItem, NoteList } from '../lib/api'
import { useDocumentTitle } from '../features/site' import { useDocumentTitle } from '../features/site'
import { formatDateISO } from '../lib/utils' import { formatDateISO } from '../lib/utils'
@@ -15,38 +16,45 @@ function useNotes(page: number) {
}) })
} }
function useTags() {
return useQuery({
queryKey: ['tags'],
queryFn: () => api<{ tags: TagCount[] }>('/api/tags'),
})
}
function NotePost({ note }: { note: NoteItem }) { function NotePost({ note }: { note: NoteItem }) {
return ( return (
<article className="pn-home-post"> <article className="pn-home-post">
<time className="pn-home-date" dateTime={formatDateISO(note.updated_at)}> {note.cover && (
{note.pinned && ( <Link
<span className="pn-home-pin"> to={`/notes/${encodeURIComponent(note.slug)}`}
<Pin size={11} aria-hidden />置顶 className="pn-home-cover"
</span> tabIndex={-1}
)} aria-hidden
{formatDateISO(note.updated_at)} >
</time> <img src={note.cover} alt="" loading="lazy" />
<h2 className="pn-home-post-title">
<Link to={`/notes/${encodeURIComponent(note.slug)}`}>
{note.title}
<ArrowRight className="pn-home-arrow" size={17} aria-hidden />
</Link> </Link>
</h2>
{note.summary && <p className="pn-home-summary">{note.summary}</p>}
{note.tags.length > 0 && (
<div className="pn-home-tags">
{note.tags.map((t) => (
<Link key={t} to={`/tags/${encodeURIComponent(t)}`} className="pn-home-tag">{t}</Link>
))}
</div>
)} )}
<div className="pn-home-post-main">
<time className="pn-home-date" dateTime={formatDateISO(note.published_at)}>
{note.pinned && (
<span className="pn-home-pin">
<Pin size={11} aria-hidden />置顶
</span>
)}
{formatDateISO(note.published_at)}
</time>
<h2 className="pn-home-post-title">
<Link to={`/notes/${encodeURIComponent(note.slug)}`}>
{note.title}
<ArrowRight className="pn-home-arrow" size={17} aria-hidden />
</Link>
</h2>
{note.summary && <p className="pn-home-summary">{note.summary}</p>}
{note.tags.length > 0 && (
<div className="pn-home-tags">
{note.tags.map((t) => (
<Tag key={t}>
<Link className="pn-tag-link" to={`/tags/${encodeURIComponent(t)}`}>{t}</Link>
</Tag>
))}
</div>
)}
</div>
</article> </article>
) )
} }
@@ -55,26 +63,19 @@ export default function Home() {
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
useDocumentTitle() useDocumentTitle()
const { data, isLoading } = useNotes(page) const { data, isLoading } = useNotes(page)
const { data: tagsData } = useTags()
const totalPages = data ? Math.max(1, Math.ceil(data.total / data.page_size)) : 1 const totalPages = data ? Math.max(1, Math.ceil(data.total / data.page_size)) : 1
const topTags = (tagsData?.tags ?? []).slice(0, 4)
return ( return (
<div className="pn-home-content"> <div className="pn-home-content">
{topTags.length > 0 && (
<nav className="tabs pn-home-filter" aria-label="按标签筛选笔记">
<Link to="/" className={page === 1 ? 'active' : undefined}>全部</Link>
{topTags.map((t) => (
<Link key={t.name} to={`/tags/${encodeURIComponent(t.name)}`}>{t.name}</Link>
))}
</nav>
)}
<section aria-label="笔记列表"> <section aria-label="笔记列表">
{isLoading ? ( {isLoading ? (
<p className="pn-home-list-empty">加载中…</p> <Flex vertical gap={44} className="pn-home-skeleton">
{[0, 1, 2].map((i) => (
<Skeleton key={i} active title paragraph={{ rows: 2 }} />
))}
</Flex>
) : !data || data.items.length === 0 ? ( ) : !data || data.items.length === 0 ? (
<p className="pn-home-list-empty">还没有公开的笔记</p> <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="还没有公开的笔记" />
) : ( ) : (
data.items.map((n) => <NotePost key={n.slug} note={n} />) data.items.map((n) => <NotePost key={n.slug} note={n} />)
)} )}
@@ -82,39 +83,15 @@ export default function Home() {
{totalPages > 1 && ( {totalPages > 1 && (
<nav className="pn-home-pager" aria-label="分页"> <nav className="pn-home-pager" aria-label="分页">
<span className="pn-home-pager-note"> <Pagination
共 {data?.total ?? 0} 篇 · 第 {page} 页 / 共 {totalPages} 页 size="small"
</span> current={page}
<div className="pn-pager"> pageSize={data?.page_size ?? 10}
<button total={data?.total ?? 0}
type="button" showSizeChanger={false}
className="btn ghost" showTotal={() => `共 ${data?.total ?? 0} 篇 · 第 ${page} 页 / 共 ${totalPages} 页`}
aria-label="上一页" onChange={(p) => setPage(p)}
disabled={page <= 1} />
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft size={16} />
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
<button
key={p}
type="button"
className={`pn-page-btn${p === page ? ' is-active' : ''}`}
onClick={() => setPage(p)}
>
{p}
</button>
))}
<button
type="button"
className="btn ghost"
aria-label="下一页"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight size={16} />
</button>
</div>
</nav> </nav>
)} )}
</div> </div>
+57 -48
View File
@@ -1,6 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { Link, useNavigate } from 'react-router' import { useNavigate } from 'react-router'
import { ArrowLeft, ArrowRight, CircleAlert, Eye, EyeOff, Lock } from 'lucide-react' import { ArrowLeft, ArrowRight, Eye, EyeOff, Lock } from 'lucide-react'
import { Alert, Button, Card, Input } from 'antd'
import { useAuth } from '../lib/auth' import { useAuth } from '../lib/auth'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { useDocumentTitle } from '../features/site' import { useDocumentTitle } from '../features/site'
@@ -45,28 +46,27 @@ export default function Login() {
<span className="pn-login-wordmark">Pure Note</span> <span className="pn-login-wordmark">Pure Note</span>
</div> </div>
<form className="card pn-login-card" onSubmit={onSubmit}> <Card className="pn-login-card" styles={{ body: { padding: 0 } }}>
<h1 className="pn-login-title">管理员登录</h1> <form onSubmit={onSubmit}>
<p className="pn-login-sub">请输入管理密码以继续</p> <h1 className="pn-login-title">管理员登录</h1>
<label htmlFor="pn-login-username" className="sr-only">用户名</label> <label htmlFor="pn-login-username" className="sr-only">用户名</label>
<input
id="pn-login-username"
className="sr-only"
type="text"
name="username"
autoComplete="username"
value="admin"
readOnly
tabIndex={-1}
/>
<label htmlFor="pn-login-password" className="sr-only">管理密码</label>
<div className="pn-login-field">
<Lock size={16} className="pn-login-icon" aria-hidden />
<input <input
id="pn-login-username"
className="sr-only"
type="text"
name="username"
autoComplete="username"
value="admin"
readOnly
tabIndex={-1}
/>
<label htmlFor="pn-login-password" className="sr-only">管理密码</label>
<Input
id="pn-login-password" id="pn-login-password"
className="pn-login-input" size="large"
variant="outlined"
type={showPw ? 'text' : 'password'} type={showPw ? 'text' : 'password'}
name="password" name="password"
placeholder="请输入管理密码" placeholder="请输入管理密码"
@@ -75,36 +75,45 @@ export default function Login() {
required required
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
prefix={<Lock size={16} className="pn-login-lock" aria-hidden />}
suffix={
<Button
type="text"
size="small"
aria-label={showPw ? '隐藏密码' : '显示密码'}
onClick={() => setShowPw((v) => !v)}
icon={showPw ? <EyeOff size={16} /> : <Eye size={16} />}
/>
}
/> />
<button
type="button" {error && (
className="pn-login-eye" <Alert role="alert" type="error" showIcon message={error} className="pn-login-alert" />
aria-label={showPw ? '隐藏密码' : '显示密码'} )}
onClick={() => setShowPw((v) => !v)}
<Button
htmlType="submit"
type="primary"
size="large"
block
className="mt-5"
disabled={submitting || password.length === 0}
> >
{showPw ? <EyeOff size={16} /> : <Eye size={16} />} <span>{submitting ? '登录中…' : '登录'}</span>
</button> <ArrowRight size={16} />
</div> </Button>
</form>
</Card>
{error && ( <Button
<div className="pn-login-error" role="alert"> type="text"
<CircleAlert size={15} /> size="small"
<span>{error}</span> className="pn-login-back"
</div> icon={<ArrowLeft size={14} />}
)} onClick={() => navigate('/')}
>
<button type="submit" className="btn primary pn-login-submit" disabled={submitting || password.length === 0}> 返回博客
<span>{submitting ? '登录中…' : '登录'}</span> </Button>
<ArrowRight size={16} />
</button>
<p className="pn-login-hint">连续失败将被暂时锁定</p>
</form>
<Link to="/" className="btn ghost pn-login-back">
<ArrowLeft size={14} />
<span>返回博客</span>
</Link>
</div> </div>
) )
} }
+5 -4
View File
@@ -1,19 +1,20 @@
import { Link } from 'react-router' import { useNavigate } from 'react-router'
import { ArrowLeft } from 'lucide-react' import { ArrowLeft } from 'lucide-react'
import { Button } from 'antd'
import { useDocumentTitle } from '../features/site' import { useDocumentTitle } from '../features/site'
export default function NotFound() { export default function NotFound() {
useDocumentTitle('页面不存在') useDocumentTitle('页面不存在')
const navigate = useNavigate()
return ( return (
<div className="pn-404-page"> <div className="pn-404-page">
<p className="pn-404-code">404</p> <p className="pn-404-code">404</p>
<h1 className="pn-404-title">页面不存在</h1> <h1 className="pn-404-title">页面不存在</h1>
<p className="pn-404-desc">这篇笔记可能已被删除,或链接有误。</p> <p className="pn-404-desc">这篇笔记可能已被删除,或链接有误。</p>
<div className="pn-404-actions"> <div className="pn-404-actions">
<Link to="/" className="btn primary"> <Button type="primary" size="small" icon={<ArrowLeft size={16} />} onClick={() => navigate('/')}>
<ArrowLeft size={16} />
返回首页 返回首页
</Link> </Button>
</div> </div>
</div> </div>
) )
+53 -24
View File
@@ -1,6 +1,7 @@
import { Link, useParams } from 'react-router' import { Link, useNavigate, useParams } from 'react-router'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { ArrowLeft, ArrowRight, Clock, Eye } from 'lucide-react' import { ArrowLeft, ArrowRight, Clock, Eye } from 'lucide-react'
import { Alert, Button, Card, Empty, Skeleton, Tag } from 'antd'
import { api } from '../lib/api' import { api } from '../lib/api'
import type { NoteDetail } from '../lib/api' import type { NoteDetail } from '../lib/api'
import { useAuth } from '../lib/auth' import { useAuth } from '../lib/auth'
@@ -10,6 +11,7 @@ import { formatDateISO, readingMinutes } from '../lib/utils'
export default function Note() { export default function Note() {
const { slug = '' } = useParams() const { slug = '' } = useParams()
const navigate = useNavigate()
const { authenticated } = useAuth() const { authenticated } = useAuth()
const { data: note, isLoading, error } = useQuery({ const { data: note, isLoading, error } = useQuery({
queryKey: ['note', slug], queryKey: ['note', slug],
@@ -18,15 +20,28 @@ export default function Note() {
}) })
useDocumentTitle(note?.title) useDocumentTitle(note?.title)
if (isLoading) return <p className="pn-home-list-empty">加载中…</p> if (isLoading) {
return (
<article className="pn-note-article">
<Skeleton active title paragraph={{ rows: 6 }} />
</article>
)
}
if (error || !note) { if (error || !note) {
return ( return (
<div className="pn-note-article"> <article className="pn-note-article">
<p className="pn-home-list-empty">笔记不存在或未公开</p> <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="笔记不存在或未公开">
<p style={{ textAlign: 'center', marginTop: -40 }}> <Button
<Link to="/" className="btn ghost pn-note-back"><ArrowLeft size={14} />返回首页</Link> type="text"
</p> size="small"
</div> className="pn-note-back"
icon={<ArrowLeft size={14} />}
onClick={() => navigate('/')}
>
返回首页
</Button>
</Empty>
</article>
) )
} }
@@ -34,22 +49,30 @@ export default function Note() {
return ( return (
<article className="pn-note-article"> <article className="pn-note-article">
<Link to="/" className="btn ghost pn-note-back"> <Button type="text" size="small" className="pn-note-back" icon={<ArrowLeft size={14} />} onClick={() => navigate('/')}>
<ArrowLeft size={14} />返回首页 返回首页
</Link> </Button>
{privatePreview && authenticated && ( {privatePreview && authenticated && (
<div className="pn-note-private" role="note"> <Alert
<Eye size={15} /> role="note"
私有预览:此笔记未公开,仅管理员可见 type="info"
</div> showIcon={false}
className="pn-note-preview-alert"
message={
<>
<Eye size={15} />
私有预览:此笔记未公开,仅管理员可见
</>
}
/>
)} )}
<h1 className="pn-note-title">{note.title}</h1> <h1 className="pn-note-title">{note.title}</h1>
<div className="pn-note-meta"> <div className="pn-note-meta">
<time className="pn-note-meta-date" dateTime={formatDateISO(note.created_at)}> <time className="pn-note-meta-date" dateTime={formatDateISO(note.published_at)}>
{formatDateISO(note.created_at)} {formatDateISO(note.published_at)}
</time> </time>
<span className="pn-note-meta-sep">·</span> <span className="pn-note-meta-sep">·</span>
<span className="pn-note-meta-read"><Clock size={14} />约 {readingMinutes(note.content)} 分钟阅读</span> <span className="pn-note-meta-read"><Clock size={14} />约 {readingMinutes(note.content)} 分钟阅读</span>
@@ -57,7 +80,9 @@ export default function Note() {
<> <>
<span className="pn-note-meta-sep">·</span> <span className="pn-note-meta-sep">·</span>
{note.tags.map((t) => ( {note.tags.map((t) => (
<Link key={t} to={`/tags/${encodeURIComponent(t)}`} className="pn-note-tag">{t}</Link> <Tag key={t}>
<Link className="pn-tag-link" to={`/tags/${encodeURIComponent(t)}`}>{t}</Link>
</Tag>
))} ))}
</> </>
)} )}
@@ -67,17 +92,21 @@ export default function Note() {
<nav className="pn-note-postnav" aria-label="笔记导航"> <nav className="pn-note-postnav" aria-label="笔记导航">
{note.prev ? ( {note.prev ? (
<Link to={`/notes/${encodeURIComponent(note.prev.slug)}`} className="card pn-note-nav-card"> <Link to={`/notes/${encodeURIComponent(note.prev.slug)}`} className="pn-note-nav-link">
<span className="pn-note-nav-label"><ArrowLeft size={14} />上一篇</span> <Card className="pn-note-nav-card" styles={{ body: { padding: 0 } }}>
<span className="pn-note-nav-title">{note.prev.title}</span> <span className="pn-note-nav-label"><ArrowLeft size={14} />上一篇</span>
<span className="pn-note-nav-title">{note.prev.title}</span>
</Card>
</Link> </Link>
) : ( ) : (
<span /> <span />
)} )}
{note.next ? ( {note.next ? (
<Link to={`/notes/${encodeURIComponent(note.next.slug)}`} className="card pn-note-nav-card pn-note-nav-next"> <Link to={`/notes/${encodeURIComponent(note.next.slug)}`} className="pn-note-nav-link">
<span className="pn-note-nav-label">下一篇<ArrowRight size={14} /></span> <Card className="pn-note-nav-card pn-note-nav-next" styles={{ body: { padding: 0 } }}>
<span className="pn-note-nav-title">{note.next.title}</span> <span className="pn-note-nav-label">下一篇<ArrowRight size={14} /></span>
<span className="pn-note-nav-title">{note.next.title}</span>
</Card>
</Link> </Link>
) : ( ) : (
<span /> <span />
+32 -23
View File
@@ -1,6 +1,7 @@
import { Link, useParams } from 'react-router' import { Link, useNavigate, useParams } from 'react-router'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { ArrowRight, Tag } from 'lucide-react' import { ArrowRight, Tag } from 'lucide-react'
import { Empty, Skeleton, Spin, Tag as AntTag } from 'antd'
import { api } from '../lib/api' import { api } from '../lib/api'
import type { NoteList, TagCount } from '../lib/api' import type { NoteList, TagCount } from '../lib/api'
import { useDocumentTitle } from '../features/site' import { useDocumentTitle } from '../features/site'
@@ -15,6 +16,7 @@ function useTags() {
export default function TagPage() { export default function TagPage() {
const { tag } = useParams() const { tag } = useParams()
const navigate = useNavigate()
useDocumentTitle(tag ? `标签:${tag}` : '标签') useDocumentTitle(tag ? `标签:${tag}` : '标签')
const { data: tagsData } = useTags() const { data: tagsData } = useTags()
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
@@ -31,26 +33,33 @@ export default function TagPage() {
<p>按主题浏览所有公开笔记</p> <p>按主题浏览所有公开笔记</p>
</section> </section>
<nav className="pn-tag-cloud" aria-label="全部标签"> {/* 单选 CheckableTagGroup:选中态由组件承接;再次点击已选标签 = 取消(回全部) */}
{tagsData?.tags.map((t) => ( <AntTag.CheckableTagGroup
<Link multiple={false}
key={t.name} className="pn-tag-cloud"
to={`/tags/${encodeURIComponent(t.name)}`} value={tag ?? null}
className={`pn-tag-pill${t.name === tag ? ' active' : ''}`} onChange={(v) => navigate(v === null ? '/tags' : `/tags/${encodeURIComponent(v)}`)}
aria-current={t.name === tag ? 'true' : undefined} options={(tagsData?.tags ?? []).map((t) => ({
> value: t.name,
<Tag size={14} /> className: 'pn-cloud-tag',
<span>{t.name}</span> label: (
<span className="pn-tag-count">{t.count}</span> <>
</Link> <Tag size={14} />
))} <span>{t.name}</span>
{!tagsData && ( <span className="pn-cloud-count">{t.count}</span>
<span className="pn-tag-count" style={{ padding: 8 }}>加载中…</span> </>
)} ),
{tagsData && tagsData.tags.length === 0 && ( }))}
<span className="pn-tag-count" style={{ padding: 8 }}>还没有标签</span> aria-label="全部标签"
)} />
</nav> {!tagsData && (
<div className="pn-tag-cloud"><Spin size="small" /></div>
)}
{tagsData && tagsData.tags.length === 0 && (
<div className="pn-tag-empty">
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="还没有标签" />
</div>
)}
{tag === undefined && tagsData && ( {tag === undefined && tagsData && (
<p className="pn-home-list-empty" style={{ marginTop: 48 }}>选择一个标签,查看对应笔记</p> <p className="pn-home-list-empty" style={{ marginTop: 48 }}>选择一个标签,查看对应笔记</p>
@@ -62,11 +71,11 @@ export default function TagPage() {
<h3 className="pn-tag-group-title"><Tag size={16} />{tag}</h3> <h3 className="pn-tag-group-title"><Tag size={16} />{tag}</h3>
<div className="pn-tag-rows"> <div className="pn-tag-rows">
{isLoading ? ( {isLoading ? (
<p className="pn-home-list-empty">加载中…</p> <Skeleton active title paragraph={{ rows: 3 }} />
) : error ? ( ) : error ? (
<p className="pn-home-list-empty">加载失败,请稍后重试</p> <p className="pn-home-list-empty">加载失败,请稍后重试</p>
) : !data || data.items.length === 0 ? ( ) : !data || data.items.length === 0 ? (
<p className="pn-home-list-empty">该标签下暂无公开笔记</p> <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="该标签下暂无公开笔记" />
) : ( ) : (
data.items.map((n) => ( data.items.map((n) => (
<Link key={n.slug} to={`/notes/${encodeURIComponent(n.slug)}`} className="pn-tag-row"> <Link key={n.slug} to={`/notes/${encodeURIComponent(n.slug)}`} className="pn-tag-row">
+415
View File
@@ -0,0 +1,415 @@
import { useMemo } from 'react'
import { theme } from 'antd'
import type { ConfigProviderProps } from 'antd'
import { createStyles } from 'antd-style'
import { clsx } from 'clsx'
/**
* ConfigProvider 主题:把应用的黑白灰 zinc 体系映射到 antd token。
* 浅色 token 与设计稿 theme-vars 一致;深色走 darkAlgorithm 并覆盖
* index.css `.dark` 中定义的语义色。组件样式跟随 antd,本文件只做
* 品牌色/圆角/阴影的对齐,不再逐像素复刻旧手搓样式。
*/
const useStyles = createStyles(({ css }) => {
return {
buttonPrimaryLight: css({
backgroundColor: '#18181b',
color: '#ffffff',
border: '1px solid #18181b',
fontWeight: 500,
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
}),
buttonPrimaryDark: css({
backgroundColor: '#fafafa',
color: '#18181b',
border: '1px solid #fafafa',
fontWeight: 500,
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
}),
buttonDefaultLight: css({
backgroundColor: '#ffffff',
color: '#18181b',
border: '1px solid #e4e4e7',
fontWeight: 500,
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
}),
buttonDefaultDark: css({
backgroundColor: '#27272a',
color: '#fafafa',
border: '1px solid #3f3f46',
fontWeight: 500,
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
}),
buttonDangerLight: css({
backgroundColor: '#dc2626',
color: '#ffffff',
border: '1px solid #dc2626',
fontWeight: 500,
}),
buttonDangerDark: css({
backgroundColor: '#ef4444',
color: '#fafafa',
border: '1px solid #ef4444',
fontWeight: 500,
}),
inputRootLight: css({
borderColor: '#e4e4e7',
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
}),
inputRootDark: css({
borderColor: '#3f3f46',
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
}),
inputElementLight: css({
color: '#18181b',
}),
inputElementDark: css({
color: '#fafafa',
}),
inputErrorLight: css({
borderColor: '#dc2626',
}),
inputErrorDark: css({
borderColor: '#ef4444',
}),
selectRoot: css({
borderColor: '#e4e4e7',
}),
notificationRootLight: css({
'&.ant-notification-notice, & .ant-notification-notice': {
border: '1px solid #e4e4e7',
borderRadius: 8,
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
},
}),
notificationRootDark: css({
'&.ant-notification-notice, & .ant-notification-notice': {
border: '1px solid #3f3f46',
borderRadius: 8,
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3)',
},
}),
notificationTitle: css({
fontWeight: 600,
}),
notificationDescriptionLight: css({
color: '#525252',
}),
notificationDescriptionDark: css({
color: '#d4d4d8',
}),
}
})
/* eslint-disable react-hooks/exhaustive-deps */
const useShadcnTheme = (dark: boolean) => {
const { styles } = useStyles()
return useMemo<ConfigProviderProps>(
() => ({
theme: {
algorithm: dark ? theme.darkAlgorithm : theme.defaultAlgorithm,
token: dark
? {
// 深色:与 index.css `.dark` 语义变量对齐(zinc 深色 ramp)
fontFamily: 'var(--font-sans)',
// 控件高度对齐设计稿:普通按钮 34 / 紧凑 24 / 登录大按钮 44
controlHeight: 34,
controlHeightSM: 24,
controlHeightLG: 44,
colorPrimary: '#fafafa',
colorSuccess: '#22c55e',
colorWarning: '#f97316',
colorError: '#ef4444',
colorInfo: '#e4e4e7',
colorTextBase: '#fafafa',
colorBgBase: '#18181b',
colorPrimaryBg: '#27272a',
colorPrimaryBgHover: '#3f3f46',
colorPrimaryBorder: '#3f3f46',
colorPrimaryBorderHover: '#52525b',
colorPrimaryHover: '#ffffff',
colorPrimaryActive: '#d4d4d8',
colorPrimaryText: '#fafafa',
colorPrimaryTextHover: '#ffffff',
colorPrimaryTextActive: '#e4e4e7',
colorSuccessBg: '#0f2417',
colorSuccessBgHover: '#163321',
colorSuccessBorder: '#166534',
colorSuccessBorderHover: '#15803d',
colorSuccessHover: '#16a34a',
colorSuccessActive: '#15803d',
colorSuccessText: '#4ade80',
colorSuccessTextHover: '#4ade80',
colorSuccessTextActive: '#22c55e',
colorWarningBg: '#271a0c',
colorWarningBgHover: '#33220f',
colorWarningBorder: '#9a3412',
colorWarningBorderHover: '#c2410c',
colorWarningHover: '#ea580c',
colorWarningActive: '#c2410c',
colorWarningText: '#fb923c',
colorWarningTextHover: '#fb923c',
colorWarningTextActive: '#f97316',
colorErrorBg: '#2a1416',
colorErrorBgHover: '#38191c',
colorErrorBorder: '#7f1d1d',
colorErrorBorderHover: '#991b1b',
colorErrorHover: '#dc2626',
colorErrorActive: '#b91c1c',
colorErrorText: '#fca5a5',
colorErrorTextHover: '#fca5a5',
colorErrorTextActive: '#f87171',
colorInfoBg: '#27272a',
colorInfoBgHover: '#3f3f46',
colorInfoBorder: '#3f3f46',
colorInfoBorderHover: '#52525b',
colorInfoHover: '#f4f4f5',
colorInfoActive: '#e4e4e7',
colorInfoText: '#fafafa',
colorInfoTextHover: '#f4f4f5',
colorInfoTextActive: '#e4e4e7',
colorText: '#fafafa',
colorTextSecondary: '#d4d4d8',
colorTextTertiary: '#a1a1aa',
colorTextQuaternary: '#71717a',
colorTextDisabled: '#71717a',
colorBgContainer: '#27272a',
colorBgElevated: '#27272a',
colorBgLayout: '#18181b',
colorBgSpotlight: 'rgba(250, 250, 250, 0.85)',
colorBgMask: 'rgba(0, 0, 0, 0.6)',
colorBorder: '#3f3f46',
colorBorderSecondary: '#27272a',
borderRadius: 10,
borderRadiusXS: 2,
borderRadiusSM: 6,
borderRadiusLG: 14,
padding: 16,
paddingSM: 12,
paddingLG: 24,
margin: 16,
marginSM: 12,
marginLG: 24,
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.3), 0 1px 2px -1px rgba(0, 0, 0, 0.25)',
boxShadowSecondary:
'0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3)',
}
: {
fontFamily: 'var(--font-sans)',
// 控件高度对齐设计稿:普通按钮 34 / 紧凑 24 / 登录大按钮 44
controlHeight: 34,
controlHeightSM: 24,
controlHeightLG: 44,
colorPrimary: '#262626',
colorSuccess: '#22c55e',
colorWarning: '#f97316',
colorError: '#ef4444',
colorInfo: '#262626',
colorTextBase: '#262626',
colorBgBase: '#ffffff',
colorPrimaryBg: '#f5f5f5',
colorPrimaryBgHover: '#e5e5e5',
colorPrimaryBorder: '#d4d4d4',
colorPrimaryBorderHover: '#a3a3a3',
colorPrimaryHover: '#404040',
colorPrimaryActive: '#171717',
colorPrimaryText: '#262626',
colorPrimaryTextHover: '#404040',
colorPrimaryTextActive: '#171717',
colorSuccessBg: '#f0fdf4',
colorSuccessBgHover: '#dcfce7',
colorSuccessBorder: '#bbf7d0',
colorSuccessBorderHover: '#86efac',
colorSuccessHover: '#16a34a',
colorSuccessActive: '#15803d',
colorSuccessText: '#16a34a',
colorSuccessTextHover: '#16a34a',
colorSuccessTextActive: '#15803d',
colorWarningBg: '#fff7ed',
colorWarningBgHover: '#fed7aa',
colorWarningBorder: '#fdba74',
colorWarningBorderHover: '#fb923c',
colorWarningHover: '#ea580c',
colorWarningActive: '#c2410c',
colorWarningText: '#ea580c',
colorWarningTextHover: '#ea580c',
colorWarningTextActive: '#c2410c',
colorErrorBg: '#fef2f2',
colorErrorBgHover: '#fecaca',
colorErrorBorder: '#fca5a5',
colorErrorBorderHover: '#f87171',
colorErrorHover: '#dc2626',
colorErrorActive: '#b91c1c',
colorErrorText: '#dc2626',
colorErrorTextHover: '#dc2626',
colorErrorTextActive: '#b91c1c',
colorInfoBg: '#f5f5f5',
colorInfoBgHover: '#e5e5e5',
colorInfoBorder: '#d4d4d4',
colorInfoBorderHover: '#a3a3a3',
colorInfoHover: '#404040',
colorInfoActive: '#171717',
colorInfoText: '#262626',
colorInfoTextHover: '#404040',
colorInfoTextActive: '#171717',
colorText: '#262626',
colorTextSecondary: '#525252',
colorTextTertiary: '#737373',
colorTextQuaternary: '#a3a3a3',
colorTextDisabled: '#a3a3a3',
colorBgContainer: '#ffffff',
colorBgElevated: '#ffffff',
colorBgLayout: '#fafafa',
colorBgSpotlight: 'rgba(38, 38, 38, 0.85)',
colorBgMask: 'rgba(38, 38, 38, 0.45)',
colorBorder: '#e5e5e5',
colorBorderSecondary: '#f5f5f5',
borderRadius: 10,
borderRadiusXS: 2,
borderRadiusSM: 6,
borderRadiusLG: 14,
padding: 16,
paddingSM: 12,
paddingLG: 24,
margin: 16,
marginSM: 12,
marginLG: 24,
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1)',
boxShadowSecondary:
'0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
},
components: {
Button: {
primaryShadow: 'none',
defaultShadow: 'none',
dangerShadow: 'none',
defaultBorderColor: dark ? '#3f3f46' : '#e4e4e7',
defaultColor: dark ? '#fafafa' : '#18181b',
defaultBg: dark ? '#27272a' : '#ffffff',
defaultHoverBg: dark ? '#3f3f46' : '#f4f4f5',
defaultHoverBorderColor: dark ? '#52525b' : '#d4d4d8',
defaultHoverColor: dark ? '#fafafa' : '#18181b',
defaultActiveBg: dark ? '#52525b' : '#e4e4e7',
defaultActiveBorderColor: dark ? '#52525b' : '#d4d4d8',
borderRadius: 6,
},
Input: {
activeShadow: 'none',
hoverBorderColor: dark ? '#a1a1aa' : '#a1a1aa',
activeBorderColor: dark ? '#d4d4d8' : '#18181b',
borderRadius: 6,
},
Select: {
optionSelectedBg: dark ? '#3f3f46' : '#f4f4f5',
optionActiveBg: dark ? '#27272a' : '#fafafa',
optionSelectedFontWeight: 500,
borderRadius: 6,
},
Alert: {
borderRadiusLG: 8,
},
Modal: {
borderRadiusLG: 12,
},
Switch: {
trackHeight: 22,
trackMinWidth: 44,
innerMinMargin: 4,
innerMaxMargin: 24,
},
Checkbox: {
borderRadiusSM: 4,
},
Notification: {
colorSuccessBg: dark ? '#0f2417' : '#f0fdf4',
colorErrorBg: dark ? '#2a1416' : '#fef2f2',
colorInfoBg: dark ? '#27272a' : '#f5f5f5',
colorWarningBg: dark ? '#271a0c' : '#fff7ed',
},
Card: {},
Tooltip: {},
Radio: {},
},
},
button: {
classNames: ({ props }) => ({
root: clsx(
(props as { type?: string }).type === 'primary' &&
(dark ? styles.buttonPrimaryDark : styles.buttonPrimaryLight),
(props as { type?: string }).type === 'default' &&
(dark ? styles.buttonDefaultDark : styles.buttonDefaultLight),
(props as { danger?: boolean; type?: string }).danger &&
(props as { type?: string }).type !== 'text' &&
(dark ? styles.buttonDangerDark : styles.buttonDangerLight),
),
}),
},
input: {
classNames: ({ props }) => ({
root: clsx(
dark ? styles.inputRootDark : styles.inputRootLight,
(props as { status?: string }).status === 'error' &&
(dark ? styles.inputErrorDark : styles.inputErrorLight),
),
input: dark ? styles.inputElementDark : styles.inputElementLight,
}),
},
select: {
classNames: {
root: styles.selectRoot,
},
},
notification: {
classNames: {
root: dark ? styles.notificationRootDark : styles.notificationRootLight,
title: styles.notificationTitle,
description: dark
? styles.notificationDescriptionDark
: styles.notificationDescriptionLight,
},
},
wave: {},
app: {},
card: {},
modal: {},
alert: {},
colorPicker: {},
checkbox: {},
dropdown: {},
datePicker: {},
inputNumber: {},
popover: {},
tooltip: {},
switch: {},
radio: {},
segmented: {},
progress: {},
}),
[
dark,
styles.buttonDangerDark,
styles.buttonDangerLight,
styles.buttonDefaultDark,
styles.buttonDefaultLight,
styles.buttonPrimaryDark,
styles.buttonPrimaryLight,
styles.inputElementDark,
styles.inputElementLight,
styles.inputErrorDark,
styles.inputErrorLight,
styles.inputRootDark,
styles.inputRootLight,
styles.notificationDescriptionDark,
styles.notificationDescriptionLight,
styles.notificationRootDark,
styles.notificationRootLight,
styles.notificationTitle,
styles.selectRoot,
],
)
}
/* eslint-enable react-hooks/exhaustive-deps */
export default useShadcnTheme
+5 -1
View File
@@ -15,7 +15,11 @@
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"types": ["vite/client"] "types": ["vite/client"],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}, },
"include": ["src", "tests"] "include": ["src", "tests"]
} }
+7
View File
@@ -1,3 +1,4 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
@@ -8,6 +9,12 @@ export default defineConfig({
// /notes/assets/… 导致白屏(设计 §8.3-2,禁止修改) // /notes/assets/… 导致白屏(设计 §8.3-2,禁止修改)
base: '/', base: '/',
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
resolve: {
// shadcn 组件别名的运行时解析(components.json aliases)
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: { server: {
proxy: { proxy: {
// 同源是 Cookie/CSRF 成立的前提(§10.5) // 同源是 Cookie/CSRF 成立的前提(§10.5)
+7
View File
@@ -1,8 +1,15 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vitest/config' import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
resolve: {
// 与 vite.config 一致:shadcn 组件的 @/ 别名
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
test: { test: {
environment: 'happy-dom', environment: 'happy-dom',
include: ['tests/**/*.test.?(c|m)[jt]s?(x)'], include: ['tests/**/*.test.?(c|m)[jt]s?(x)'],