Compare commits
31
Commits
27ec9badf2
..
v0.0.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8cdb56bdd | ||
|
|
f80cb74d11 | ||
|
|
9fcd8cd7cd | ||
|
|
5c5ab3dfc3 | ||
|
|
9b0aff6f9f | ||
|
|
b1ea89ba3d | ||
|
|
9b0f2785cf | ||
|
|
ccc858bfdd | ||
|
|
2be5d4d25b | ||
|
|
95cac4688d | ||
|
|
ad4a87fc9f | ||
|
|
92d89717c5 | ||
|
|
969d537aee | ||
|
|
614bfe2852 | ||
|
|
5bac272f6d | ||
|
|
5457aeaa33 | ||
|
|
87d9bab5dc | ||
|
|
5a78de6aa6 | ||
|
|
646a49a26e | ||
|
|
13ad202924 | ||
|
|
f16f583978 | ||
|
|
dda5ad1e5a | ||
|
|
51f528f516 | ||
|
|
412821684b | ||
|
|
b94e9b1721 | ||
|
|
3105b0415c | ||
|
|
1272d680a8 | ||
|
|
ba7d5dd01f | ||
|
|
a35e7fdfd3 | ||
|
|
132c4a1ba2 | ||
|
|
a885b52f18 |
@@ -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}"
|
||||||
@@ -13,5 +13,8 @@ internal/webui/dist/*
|
|||||||
/pn
|
/pn
|
||||||
/pn-linux-*
|
/pn-linux-*
|
||||||
|
|
||||||
|
# 发布产物(make dist / CI 发版构建输出)
|
||||||
|
/dist/
|
||||||
|
|
||||||
# 系统文件
|
# 系统文件
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -3,15 +3,16 @@
|
|||||||
极简高安全私人笔记 + 博客。最终交付 = **一个二进制程序 + 一个数据文件(目录)**:
|
极简高安全私人笔记 + 博客。最终交付 = **一个二进制程序 + 一个数据文件(目录)**:
|
||||||
无数据库服务、无缓存服务、无 Node 运行时、无外部依赖。
|
无数据库服务、无缓存服务、无 Node 运行时、无外部依赖。
|
||||||
|
|
||||||
> 设计文档:[docs/design.md](docs/design.md)(v1.1);实施决策记录:[docs/decisions.md](docs/decisions.md)
|
> 设计文档:[docs/design.md](docs/design.md)(v1.1);实施决策记录:[docs/decisions.md](docs/decisions.md);提交规范:[docs/contributing.md](docs/contributing.md)
|
||||||
|
|
||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
- **笔记**: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` 到期物理清除
|
||||||
- **运维**:`init` / `passwd`(重设口令并吊销全部会话)/ `start` / `backup`(在线一致快照)/ `gc`(默认 dry-run)/ `version`(亦可 `-v`)
|
- **界面**:9 页面响应式 SPA(React 19 + Tailwind 4 + Ant Design 6),深色/浅色/跟随系统三态主题
|
||||||
|
- **运维**:`init`(含 Markdown 示例文档与示例图片种子)/ `passwd`(重设口令并吊销全部会话)/ `start` / `backup`(在线一致快照)/ `gc`(默认 dry-run)/ `version`(亦可 `-v`)
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
@@ -19,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
|
||||||
|
|
||||||
@@ -39,6 +40,8 @@ make build
|
|||||||
cd web && npm install && npm run dev # /api 代理到 127.0.0.1:8080
|
cd web && npm install && npm run dev # /api 代理到 127.0.0.1:8080
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **为什么必须带 `--dev`**:明文 HTTP 下 Safari/WebKit 不保存 `Secure` Cookie(Chrome/Firefox 有 localhost 豁免,Safari 没有),不带 `--dev` 时 `__Host-` 会话被静默丢弃,Safari 登录成功后仍会被弹回登录页。`--dev` 改用无 `Secure` 的 `pn_session` Cookie 并强制仅监听 loopback(§7.3-3);生产走 HTTPS 不带 `--dev`,自动恢复 `__Host-` + `Secure` 完整加固。
|
||||||
|
|
||||||
忘记口令(CLI 可达即具备服务器权限,可直接重设;重设后全部会话被吊销):
|
忘记口令(CLI 可达即具备服务器权限,可直接重设;重设后全部会话被吊销):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -75,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/`)。
|
||||||
|
|
||||||
## 架构
|
## 架构
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -87,6 +106,11 @@ internal/middleware/ 安全头 / 日志 / 限流 / Origin 校验 / MaxBytes
|
|||||||
internal/httpapi/ 路由与 handler(公共 / 认证 / 管理 / feed)
|
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/components/ Layout(导航与主题切换)、Editor、MarkdownViewer
|
||||||
|
├─ src/theme/ shadcnTheme(antd ConfigProvider 主题:品牌 token + 明暗算法 + 桥接样式)
|
||||||
|
├─ src/hooks/ use-toast(基于 antd notification 的操作反馈)
|
||||||
|
└─ src/lib/ api(CSRF 注入)/ auth(会话上下文)/ theme(三态主题)/ sanitize / invalidate
|
||||||
deploy/ systemd 单元 ×3 + Caddyfile
|
deploy/ systemd 单元 ×3 + Caddyfile
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+7
-3
@@ -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
|
||||||
}
|
}
|
||||||
@@ -238,7 +242,7 @@ func runResetPassword(args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
st, err := store.Open(cfg.DBPath(), false)
|
st, err := store.OpenData(cfg.DBPath())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -313,7 +317,7 @@ func runBackup(args []string) error {
|
|||||||
if dest == "" {
|
if dest == "" {
|
||||||
dest = filepath.Join(cfg.DataDir, fmt.Sprintf("pn-backup-%s.db", time.Now().Format("20060102-150405")))
|
dest = filepath.Join(cfg.DataDir, fmt.Sprintf("pn-backup-%s.db", time.Now().Format("20060102-150405")))
|
||||||
}
|
}
|
||||||
st, err := store.Open(cfg.DBPath(), false)
|
st, err := store.OpenData(cfg.DBPath())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -337,7 +341,7 @@ func runGC(args []string) error {
|
|||||||
commit = true
|
commit = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
st, err := store.Open(cfg.DBPath(), false)
|
st, err := store.OpenData(cfg.DBPath())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
+183
@@ -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),下面这张就是初始化时自动生成的示例图:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 其他
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
私有笔记仅自己可见,公开笔记会出现在首页与 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[:])
|
||||||
|
}
|
||||||
+34
-4
@@ -43,6 +43,7 @@
|
|||||||
| slug | 中文 post-YYYYMMDD、同日冲突 -2、回收站占用、手改 409 字段级错误、非法格式 400 | 同上 |
|
| slug | 中文 post-YYYYMMDD、同日冲突 -2、回收站占用、手改 409 字段级错误、非法格式 400 | 同上 |
|
||||||
| 元信息注入 | 公开注入笔记 meta;私有/不存在回退站点默认;html/template 转义 | visibility_test.go + webui_test.go |
|
| 元信息注入 | 公开注入笔记 meta;私有/不存在回退站点默认;html/template 转义 | visibility_test.go + webui_test.go |
|
||||||
| 前端 vitest | sanitize schema 快照 + 恶意 Markdown 冒烟 + GFM 回归 + node prop 泄漏检查 | web/tests/markdown.test.tsx(6/6) |
|
| 前端 vitest | sanitize schema 快照 + 恶意 Markdown 冒烟 + GFM 回归 + node prop 泄漏检查 | web/tests/markdown.test.tsx(6/6) |
|
||||||
|
| 即时渲染装饰层 | 语法区间识别 / reveal(含二分等价)/ 安全 / 文首文末边界 / 跨原子区间选区 / 不变量 / 3000 行性能基准 | web/tests/editor-live-preview.test.ts(29)+ editor-boundaries.test.ts(32) |
|
||||||
|
|
||||||
**回归命令**:`make test`(go vet + go test ./... + vitest)与 `make smoke`。
|
**回归命令**:`make test`(go vet + go test ./... + vitest)与 `make smoke`。
|
||||||
|
|
||||||
@@ -50,11 +51,13 @@
|
|||||||
|
|
||||||
| 里程碑 | 验收门 | 状态 |
|
| 里程碑 | 验收门 | 状态 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| M0 骨架 | `make build` 产出可运行二进制;serve 起服务;/api/health 200 | ✅ |
|
| M0 骨架 | `make build` 产出可运行二进制;start 起服务;/api/health 200 | ✅ |
|
||||||
| M1 公开浏览 | 笔记/标签 API + 博客三页 + 渲染管线 + RSS/sitemap + meta 注入;公开侧可见性用例;构建冒烟 | ✅ |
|
| M1 公开浏览 | 笔记/标签 API + 博客三页 + 渲染管线 + RSS/sitemap + meta 注入;公开侧可见性用例;构建冒烟 | ✅ |
|
||||||
| M2 管理端 | 登录/会话/CSRF + CRUD + 回收站 + 图片上传 + 编辑器 + 改密;完整矩阵 + 各组用例随功能交付 | ✅ |
|
| M2 管理端 | 登录/会话/CSRF + CRUD + 回收站 + 图片上传 + 编辑器 + 改密;完整矩阵 + 各组用例随功能交付 | ✅ |
|
||||||
| M3 安全加固 | §9.3 全项通过(见上表);CSP nonce 硬化按设计留 M3+ 待办 | ✅ |
|
| M3 安全加固 | §9.3 全项通过(见上表);CSP nonce 硬化按设计留 M3+ 待办 | ✅ |
|
||||||
| M4 运维 | systemd ×3 + Caddyfile + 备份恢复演练真还原 | ✅(压测 hey 未做:单机个人规模非验收必需,工具未安装;`SetMaxOpenConns(1)` 串行模型按设计实现) |
|
| M4 运维 | systemd ×3 + Caddyfile + 备份恢复演练真还原 | ✅(压测 hey 未做:单机个人规模非验收必需,工具未安装;`SetMaxOpenConns(1)` 串行模型按设计实现) |
|
||||||
|
| M5 编辑器即时渲染(P0–P3) | 编辑态=阅读态排版(`--md-*` 契约)、全语法即时渲染、光标行展开/失焦整篇、任务框回写源文本、源码模式逃生舱;边界与选区回归(`web/tests/editor-boundaries.test.ts` 32 条);3000 行性能基准达成 §7.4 的 <16ms 目标 | ✅ 代码与自动化(实测数据见 [editor-live-rendering.md](editor-live-rendering.md) §12.5) |
|
||||||
|
| **M5-G 人工门槛:真机输入法** | 系统拼音 / 搜狗拼音按 §12.6 的 I1–I8 用例走查并签字 | ⛔ **未执行 —— 阻塞发布** |
|
||||||
|
|
||||||
## 五、真实浏览器走查(补充验收)
|
## 五、真实浏览器走查(补充验收)
|
||||||
|
|
||||||
@@ -64,23 +67,50 @@
|
|||||||
2. **详情页**:面包屑、标题/日期/标签、GFM 表格与任务列表(disabled checkbox)渲染、上一篇/下一篇;
|
2. **详情页**:面包屑、标题/日期/标签、GFM 表格与任务列表(disabled checkbox)渲染、上一篇/下一篇;
|
||||||
3. **登录**:错误口令提示、成功跳转 /admin;
|
3. **登录**:错误口令提示、成功跳转 /admin;
|
||||||
4. **管理列表**:公/私徽标、slug/时间/标签、状态切换/编辑/删除;
|
4. **管理列表**:公/私徽标、slug/时间/标签、状态切换/编辑/删除;
|
||||||
5. **编辑器**:CodeMirror 输入、分屏实时预览、保存后自动生成 slug("go")、摘要自动截取、URL 迁移到 /notes/3/edit;
|
5. **编辑器**:CodeMirror 输入、~~分屏实时预览~~(v1.1 起改为**即时渲染**,方案见 [editor-live-rendering.md](editor-live-rendering.md);源码模式为逃生舱)、保存后自动生成 slug("go")、摘要自动截取、URL 迁移到 /notes/3/edit;
|
||||||
6. **回收站**:删除确认弹窗 → 列表移除 → 回收站出现 → 恢复 → 公开页重新可见(标签页标题即 meta 注入验证)。
|
6. **回收站**:删除确认弹窗 → 列表移除 → 回收站出现 → 恢复 → 公开页重新可见(标签页标题即 meta 注入验证)。
|
||||||
|
|
||||||
|
### v1.1 编辑器即时渲染复走(2026-09-11)
|
||||||
|
|
||||||
|
在 `pn start --dev` + `vite dev` 上对内联渲染逐项复走:标题/粗体/斜体/删除线/行内代码/链接/嵌套引用/
|
||||||
|
三级无序圆点/有序序号保留/任务框/图片(原尺寸+圆角)/水平线/围栏语言标签与底色/表格等宽淡化均按预期;
|
||||||
|
光标行展开源码、失焦整篇渲染;点击任务框改写源文本并自动保存;源码模式反复切换无残留。
|
||||||
|
细节与剩余人工项见 [editor-live-rendering.md](editor-live-rendering.md) §12。
|
||||||
|
|
||||||
|
### v1.2 P3 加固复走(2026-09-11)
|
||||||
|
|
||||||
|
- **边界**:文档以标题/任务项/围栏/水平线开头、以水平线/图片/围栏/任务结尾均正确渲染;整篇只有一条水平线可用;
|
||||||
|
文首连续退格不损坏内容;文末追加内容后装饰正确重建。
|
||||||
|
- **跨原子区间选区**:3000 行文档 `Cmd+A` + 退格清空、`Cmd+Z` 完整恢复;跨隐藏标记的选区替换不残留标记碎片;
|
||||||
|
只选中可见内容时标记完整保留。
|
||||||
|
- **性能**:3000 行 / 15.6 万字符文档,视口装饰采集中位数 0.26ms、全篇最坏 5.0ms、最坏选区(Cmd+A)0.98ms、
|
||||||
|
真实输入同步耗时中位数 0.1ms、冷启动直跳文末最大帧间隔 7.1ms 且零 long task(详见方案 §12.5.2)。
|
||||||
|
- **修复**:长文档惰性解析导致未解析区间显示裸 Markdown(已修,方案 §12.5.1 P3-1);
|
||||||
|
`Cmd+A` 全文档装饰构建与线性 reveal 扫描(已修,P3-2)。
|
||||||
|
- **编辑区高度**(2026-09-11,用户要求):编辑卡默认吃满视口剩余高度、长文改为编辑器内部滚动。
|
||||||
|
1280×720 下空文档与 3000 行长文的编辑卡均为 610px;长文页面保持一屏(此前 8.4 万像素),
|
||||||
|
`.cm-scroller` 内部可滚;窄屏 ≤900px 回落内容高度。方案 §12.7。
|
||||||
|
|
||||||
|
### ⛔ 未通过的人工门槛(阻塞发布)
|
||||||
|
|
||||||
|
真机输入法回归(系统拼音 / 搜狗拼音)**尚未执行**,用例、环境要求与签字要求见
|
||||||
|
[editor-live-rendering.md](editor-live-rendering.md) §12.6。**在签字通过前不得发布**。
|
||||||
|
|
||||||
## 六、交付物清单
|
## 六、交付物清单
|
||||||
|
|
||||||
```
|
```
|
||||||
pn(单二进制,CGO_ENABLED=0,go:embed 前端产物)
|
pn(单二进制,CGO_ENABLED=0,go:embed 前端产物)
|
||||||
├── cmd/pn/ start / init / passwd / backup / gc / version
|
├── cmd/pn/ start / init / passwd / backup / gc / version
|
||||||
├── internal/{config,store,auth,markdown,middleware,httpapi,webui}
|
├── internal/{config,store,auth,markdown,middleware,httpapi,webui}
|
||||||
├── web/ React 19 + Vite 8 + Tailwind 4(vitest 6 用例)
|
├── web/ React 19 + Vite 8 + Tailwind 4(vitest 71 用例)+ editor/ 即时渲染装饰层
|
||||||
├── deploy/ pn.service、pn-maint.{service,timer}、Caddyfile
|
├── deploy/ pn.service、pn-maint.{service,timer}、Caddyfile
|
||||||
├── scripts/smoke.sh 构建冒烟(make smoke)
|
├── scripts/smoke.sh 构建冒烟(make smoke)
|
||||||
├── Makefile / README.md / .gitignore
|
├── Makefile / README.md / .gitignore
|
||||||
└── docs/{design.md, review-round1.md, decisions.md, acceptance.md}
|
└── docs/{design.md, review-round1.md, decisions.md, acceptance.md}
|
||||||
```
|
```
|
||||||
|
|
||||||
依赖面:Go 直接依赖 6 个(预算 ≤6);npm 生产依赖与设计一致,0 漏洞。
|
依赖面:Go 直接依赖 6 个(预算 ≤6);npm 生产依赖与设计一致(运行时零新增),0 漏洞;
|
||||||
|
新增 `@codemirror/commands` 为 devDependency(本就在依赖树中,用于边界回归真实执行编辑命令)。
|
||||||
锁定文件 go.sum / package-lock.json 入库。
|
锁定文件 go.sum / package-lock.json 入库。
|
||||||
|
|
||||||
## 七、遗留事项(不阻塞验收)
|
## 七、遗留事项(不阻塞验收)
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# 提交规范(Conventional Commits)
|
||||||
|
|
||||||
|
本仓库提交信息遵循 [Conventional Commits 1.0.0](https://www.conventionalcommits.org/zh-hans/v1.0.0/),
|
||||||
|
格式如下:
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<scope>): <subject>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
```
|
||||||
|
|
||||||
|
## type(必填)
|
||||||
|
|
||||||
|
| type | 用途 |
|
||||||
|
| --- | --- |
|
||||||
|
| `feat` | 新功能(用户可感知的行为变化) |
|
||||||
|
| `fix` | 缺陷修复(bug、安全漏洞、评审发现的功能缺陷) |
|
||||||
|
| `docs` | 仅文档(设计/决策/评审报告/README/注释性文档) |
|
||||||
|
| `refactor` | 重构(不改行为、不修缺陷的代码整理) |
|
||||||
|
| `perf` | 性能优化 |
|
||||||
|
| `test` | 仅测试代码(新增/修正用例与测试基建) |
|
||||||
|
| `build` | 构建系统或外部依赖(Makefile、go.mod、npm、打包脚本) |
|
||||||
|
| `ci` | 持续集成配置 |
|
||||||
|
| `chore` | 其他杂项(不触及 src 与 test) |
|
||||||
|
| `revert` | 回滚某次提交(subject 为被回滚提交的摘要,footer 注明 hash) |
|
||||||
|
|
||||||
|
## scope(可选)
|
||||||
|
|
||||||
|
按模块/目录取值,多个模块的横切改动可省略 scope 或在 body 中说明:
|
||||||
|
|
||||||
|
`cmd` / `config` / `store` / `auth` / `markdown` / `middleware` / `httpapi` / `webui` / `web` / `deploy` / `docs`
|
||||||
|
|
||||||
|
## subject 规则
|
||||||
|
|
||||||
|
- 中文,动词开头(如「修正」「新增」「移除」「收尾」);
|
||||||
|
- 不超过 50 字;
|
||||||
|
- 不加句号;描述「做了什么」,而非「做了什么活」。
|
||||||
|
|
||||||
|
## body(可选)
|
||||||
|
|
||||||
|
- 一行一要点,说明「为什么改」与「影响范围」;
|
||||||
|
- 引用来源:评审条目(如 `round2 P1-2`)、设计章节(如 `§9.2`)、issue 编号。
|
||||||
|
|
||||||
|
## footer(可选)
|
||||||
|
|
||||||
|
- `BREAKING CHANGE:` 开头(或 type 后加 `!`)注明破坏性变更;
|
||||||
|
- 回滚注明被回滚提交的 hash。
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
```
|
||||||
|
fix(store): 修正上一篇/下一篇邻接查询 tie-break 与 pinned 排序键
|
||||||
|
|
||||||
|
同秒多篇更新时 id 比较方向与列表序相反导致漏链/错链;
|
||||||
|
邻接语义纳入 pinned,与 ListPublicNotes 排序键一致。
|
||||||
|
round2 P1-1
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(cmd): 新增 passwd 子命令免旧口令重设管理员口令
|
||||||
|
|
||||||
|
重设成功后吊销全部会话;口令经 PN_ADMIN_PASSWORD 或交互输入。
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
docs: 新增提交规范并链接至 README
|
||||||
|
```
|
||||||
|
|
||||||
|
## 提交拆分
|
||||||
|
|
||||||
|
- 一个提交只做一件事(文档、功能、修复、测试分开);
|
||||||
|
- 修复缺陷时**同提交**附带其回归测试;
|
||||||
|
- 不提交构建产物、密钥与本地配置(`pn` 二进制、`data/`、`.env` 等已在 `.gitignore`)。
|
||||||
+24
-2
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
| # | 决策 | 依据 |
|
| # | 决策 | 依据 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| D6 | `backup` / `gc` 子命令**不执行迁移、不做 user_version 守卫**(只有 `serve`/`init` 迁移) | 维护命令应是纯数据操作:在陌生(更新)schema 上跑迁移反而危险。设计 §10.4 的守卫语境是「启动服务」 |
|
| D6 | `backup` / `gc` / `passwd` 子命令**不执行迁移、不做 user_version 守卫**(只有 `start`/`init` 迁移) | 维护命令应是纯数据操作:在陌生(更新)schema 上跑迁移反而危险。设计 §10.4 的守卫语境是「启动服务」 |
|
||||||
| D7 | `init` 在口令已存在时**拒绝并提示走管理界面改密**(无 `--force`) | 防止误操作覆盖口令;单管理员场景下改密有专门界面(§7.1 POST /api/admin/password) |
|
| D7 | `init` 在口令已存在时**拒绝并提示走管理界面改密**(无 `--force`) | 防止误操作覆盖口令;单管理员场景下改密有专门界面(§7.1 POST /api/admin/password) |
|
||||||
| D8 | `GET /api/site` 公开端点(返回 site_title/site_desc/page_size 白名单三键) | 设计 §8.1 首页要显示「站点标题」,但 §7.1 公共 API 无设置出口;`/api/admin/settings` 需要会话。新增白名单只读端点,**永不含 admin_password_hash**(测试守护) |
|
| D8 | `GET /api/site` 公开端点(返回 site_title/site_desc/page_size 白名单三键) | 设计 §8.1 首页要显示「站点标题」,但 §7.1 公共 API 无设置出口;`/api/admin/settings` 需要会话。新增白名单只读端点,**永不含 admin_password_hash**(测试守护) |
|
||||||
| D9 | `GET /api/notes/{slug}` 响应含 `prev`/`next`(相邻公开笔记 slug+title) | §8.1 详情页要求「上一篇/下一篇」;放在详情响应里避免前端额外请求与分页边界问题。排序与公开列表一致(updated_at DESC, id DESC) |
|
| D9 | `GET /api/notes/{slug}` 响应含 `prev`/`next`(相邻公开笔记 slug+title) | §8.1 详情页要求「上一篇/下一篇」;放在详情响应里避免前端额外请求与分页边界问题。排序与公开列表一致(updated_at DESC, id DESC) |
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
| # | 决策 | 依据 |
|
| # | 决策 | 依据 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| D27 | §13 各测试组全部落为 Go 集成测试(httptest + 临时目录真实 SQLite):可见性矩阵(主体 × 状态 × 出口表驱动)、迁移守卫、登录/改密/会话轮换、CSRF、上传、回收站+gc、slug、设置白名单;webui 用 fstest.MapFS 单测 meta 转义/缓存头/fallback | 设计「其他测试组」要求 httptest + 临时目录真实 SQLite |
|
| D27 | §13 各测试组全部落为 Go 集成测试(httptest + 临时目录真实 SQLite):可见性矩阵(主体 × 状态 × 出口表驱动)、迁移守卫、登录/改密/会话轮换、CSRF、上传、回收站+gc、slug、设置白名单;webui 用 fstest.MapFS 单测 meta 转义/缓存头/fallback | 设计「其他测试组」要求 httptest + 临时目录真实 SQLite |
|
||||||
| D28 | 构建冒烟 = `scripts/smoke.sh`(`make smoke`):真实构建 → init → serve → 断言 SPA script/link 200 + 正确 MIME、meta 注入、安全头、私有不可见 | §8.3-6「真实构建 → 启动二进制 → 请求任一公开 slug 页面」;比 Go test 内嵌前端产物更贴近 CI 语义 |
|
| D28 | 构建冒烟 = `scripts/smoke.sh`(`make smoke`):真实构建 → init → start → 断言 SPA script/link 200 + 正确 MIME、meta 注入、安全头、私有不可见 | §8.3-6「真实构建 → 启动二进制 → 请求任一公开 slug 页面」;比 Go test 内嵌前端产物更贴近 CI 语义 |
|
||||||
| D29 | 测试注入限流器:`httpapi.NewWithLimiters` 允许测试替换高容量桶;登录限流测试单独用真实参数构造器 | 避免全局限流 429 干扰矩阵测试,同时保留限流本身的专项测试(两全) |
|
| D29 | 测试注入限流器:`httpapi.NewWithLimiters` 允许测试替换高容量桶;登录限流测试单独用真实参数构造器 | 避免全局限流 429 干扰矩阵测试,同时保留限流本身的专项测试(两全) |
|
||||||
| D30 | 夹具图片用「合法 PNG + IEND 后差异化尾部」绕开 sha256 去重合并——去重本身另用同字节上传断言 | 实测发现同字节图片被去重合并为同一行(正确行为,§14 已预告「去重会合并」),矩阵需要四张不同图 |
|
| D30 | 夹具图片用「合法 PNG + IEND 后差异化尾部」绕开 sha256 去重合并——去重本身另用同字节上传断言 | 实测发现同字节图片被去重合并为同一行(正确行为,§14 已预告「去重会合并」),矩阵需要四张不同图 |
|
||||||
|
|
||||||
@@ -63,3 +63,25 @@
|
|||||||
| D33 | webui 资产缓存:`assets/`(内容 hash 命名)immutable 一年;其他静态文件 1h;index.html no-cache;缺失资产 404 不回退 HTML | §8.3-5 + 防止把 JS 404 伪装成 SPA 页面造成误判 |
|
| D33 | webui 资产缓存:`assets/`(内容 hash 命名)immutable 一年;其他静态文件 1h;index.html no-cache;缺失资产 404 不回退 HTML | §8.3-5 + 防止把 JS 404 伪装成 SPA 页面造成误判 |
|
||||||
| D34 | 无内嵌 index.html(M0 占位)时 HTML 路径返回 503 占位说明页 | 部署缺产物属于配置错误,503 比 200 空页更诚实 |
|
| D34 | 无内嵌 index.html(M0 占位)时 HTML 路径返回 503 占位说明页 | 部署缺产物属于配置错误,503 比 200 空页更诚实 |
|
||||||
| D35 | `robots.txt` 额外 `Disallow: /admin` | §7.1 仅要求「允许全部 + sitemap 指向」;Disallow /admin 是 SEO 常规增强,不构成功能偏离 |
|
| D35 | `robots.txt` 额外 `Disallow: /admin` | §7.1 仅要求「允许全部 + sitemap 指向」;Disallow /admin 是 SEO 常规增强,不构成功能偏离 |
|
||||||
|
| D36 | 第 2 轮评审(review-round2.md)修复随附的实现决策:维护命令经 `store.OpenData` 打开(落实 D6,`--allow-newer` 相应从维护子命令移除);Origin 校验增加 scheme 比对(可信反代后采信 X-Forwarded-Proto);邻接查询纳入 pinned 排序键;上传解码前以 `DecodeConfig` 限制像素 ≤2²⁵;备份经 umask 收紧使创建即 0600;前端写操作后统一失效公共查询缓存 | review-round2.md P1-1/P1-5/P2-11/P2-12/P2-7/P2-15 |
|
||||||
|
|
||||||
|
## 6. 编辑器即时渲染改造(2026-09-11,方案 `docs/editor-live-rendering.md` v1.1)
|
||||||
|
|
||||||
|
| # | 决策 | 依据 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| D37 | 采用 **CM6 装饰器即时渲染**(路线 A),不引入 ProseMirror/Tiptap 富文本模型;**零新增 npm 依赖** | 方案 §2.3 / E1;真相源保持 Markdown 原文,公开页渲染管线与 sanitize 防线完全不动 |
|
||||||
|
| D38 | 全部装饰由**单一 ViewPlugin** 产出,不引入 StateField | 方案 E10 / §12.2 D1:实现中不存在真正的块级装饰(水平线用整行 inline-block 承接、闭围栏只隐藏内容),免去全文档遍历与块 widget 布局/选区风险;代价是闭围栏行留下 1 行空白作为代码块底部内边距 |
|
||||||
|
| D39 | 模块合并 `markers.ts`/`inline.ts`/`blocks.ts` → `syntax.ts` + `decorations.ts` | 方案 §12.2 D2;三者职责同为「节点 → 装饰」,拆分只增加跳转成本;单测边界改为「识别」与「构建/展开」 |
|
||||||
|
| D40 | reveal 以**编辑器聚焦**为前提(失焦整篇渲染);粒度 v1 取**行级** | 方案 E11 / E6:避免载入即显示首行 `#`;行级对 IME 最安全,span 级列入 §10.2 |
|
||||||
|
| D41 | **图片与任务复选框不参与 reveal**:图片恒渲染(改 URL 走源码模式),复选框恒可点 | 方案 E12 / §12.2 D3-D4:与 Typora 一致;避免「段落内行内图片在编辑该行时永远显示源码」与「光标落到任务行后复选框变文本无法点击」 |
|
||||||
|
| D42 | 行号/活动行高亮用 CSS(`.pn-cm-live .cm-gutters{display:none}`)隐藏,不改 `basicSetup` | 方案 §12.2 D5:不依赖 `@uiw/react-codemirror` 对 `basicSetup` 的运行时重配置行为 |
|
||||||
|
| D43 | 排版参数抽为 `--md-*` CSS 变量,**阅读态 `.pn-note-body` 与编辑态 `.cm-md-*` 共同引用** | 方案 §4.6 / E9:编辑态视觉=阅读态视觉;实机核对公开页计算样式未变(15px/27px、标题衬线、段落 24px) |
|
||||||
|
| D44 | 表格 v1 只做**源码等宽对齐 + 竖线淡化**(普通 55%、分隔行 38% 透明),不渲染 HTML | 方案 E5 / §10.1;`TableDelimiter` 在 lezer 中对分隔行是整段节点,故用单个带 `delimiter` 标记的 spec 表达 |
|
||||||
|
| D45 | `AdminEdit`「预览」按钮改为**「源码模式 / 退出源码」**,删除分屏预览块、`MarkdownViewer` import 与 `.pn-ae-preview` 样式 | 方案 §4.7:逃生舱;CM 体积仍走 lazy chunk,`MarkdownViewer` 继续服务公开页 |
|
||||||
|
| D46 | 真机输入法回归**列为发布前硬门槛未执行**;自动化仅覆盖合成组合事件(`compositionstart/end`)与 `insertText` 中文输入 | 方案 §7.1 / §12.4:自动化测不出真实 IME 行为,冻结逻辑(`view.composing` 时只映射不重建)已实现但需人工真机确认 |
|
||||||
|
| D47 | 长文档必须**补解析**:装饰前用 `ensureSyntaxTree(state, 可见区间末端, 20ms)`,并在 ViewPlugin 增加「语法树对象换代 → 重建」触发 | P3 实测 `syntaxTree(state)` 对 3000 行/15.5 万字符文档首次只覆盖 3061 字符(lezer 惰性增量),不补解析则未解析区间显示裸 Markdown;后台解析推进的事务没有 doc/selection 变化,必须靠树对象换代触发重算。20ms 预算有界(整篇解析实测仅 15.4ms,未真正耗尽) |
|
||||||
|
| D48 | 装饰采集范围 = `visibleRanges` + 选区**端点**行,而非选区跨越的全部行 | CM 只把选区端点所在的块渲染到视口之外(其余用 `cm-gap` 占位);全量取会让 `Cmd+A` 在长文档上退化为全文档装饰构建。仅取端点行即可让「凡渲染出来的行都有装饰」成立 |
|
||||||
|
| D49 | `applyReveal` 改为「区间合并 + 二分」,替代线性扫描 | 整篇选区在 3000 行文档下产生 3000 个展开区间,原 O(装饰数×区间数) 最坏约数千万次比较;二分后最坏选区(Cmd+A)构建中位数 0.98ms。另加「二分/线性结果等价」单测守护 |
|
||||||
|
| D50 | 新增 `@codemirror/commands` 为 **devDependency**(运行时零新增依赖不变) | 边界回归需要真实执行 `deleteCharBackward` 等编辑命令来验证文首退格/原子区间行为;该包本就在依赖树中(`@uiw/react-codemirror` 的依赖),不新增安装体积、不进入产物 |
|
||||||
|
| D51 | 真机输入法回归形式化为**人工验收门槛(阻塞发布)**,见方案 §12.6 与 `acceptance.md` M5 | §7.1 早已声明其为硬门槛;自动化(合成 composition 事件 + `insertText`)只能证明冻结逻辑不崩、文本不乱序,无法替代真实 IME。门槛含 8 项用例、覆盖系统拼音与搜狗、要求执行人与环境签字;当前状态:**未执行** |
|
||||||
|
| D52 | 编辑卡默认**吃满视口剩余高度**(`.pn-ae-grid: calc(100vh - 110px)`),长文改为编辑器内部滚动(`.pn-ae-body-wrap` 吃掉剩余高度,滚动由 `.cm-scroller` 承担) | 用户要求(2026-09-11)。原实现 `.pn-ae-body-wrap` 仅 `min-height: 420px` 且高度随内容撑开:空文档编辑区只有 420px,3000 行长文把页面撑到 8.4 万像素、只能整页滚动。实测:两种场景下编辑卡均 610px(1280×720),长文 `.cm-scroller` 内部滚动 85087px、页面保持一屏;≤900px 堆叠断点回落 `height: auto`。方案 §12.7 |
|
||||||
|
|||||||
+28
-18
@@ -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 | 即时渲染(Typora 式:隐藏语法标记 + 光标处展开源码,见 [editor-live-rendering.md](editor-live-rendering.md)),零魔法、可预期;图片粘贴自写 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 即时渲染(Typora 式,见 [editor-live-rendering.md](editor-live-rendering.md))、元信息侧栏(标题/slug/标签选择器/发布日期/摘要/公开开关/置顶)、图片粘贴上传 |
|
||||||
| `*` | 404 | 统一兜底 |
|
| `*` | 404 | 统一兜底 |
|
||||||
|
|
||||||
### 8.2 核心技术要点
|
### 8.2 核心技术要点
|
||||||
@@ -418,7 +422,13 @@ Markdown 原文
|
|||||||
|
|
||||||
**编辑器(编辑态)**
|
**编辑器(编辑态)**
|
||||||
|
|
||||||
- CodeMirror 6 + `@codemirror/lang-markdown`,`minimalSetup` + 行号 + 等宽字体;工具栏提供加粗/斜体/链接/代码/表格等 Markdown 辅助插入;
|
> 即时渲染(Typora 式)改造方案见 **[docs/editor-live-rendering.md](editor-live-rendering.md)**(v1.2,P0–P3 已实施并完成实机验证与性能实测);本段原「分屏实时预览」表述已被该方案取代,数据层与浏览态渲染管线不变。
|
||||||
|
> **发布门槛**:该方案 §12.6 的真机输入法回归(系统拼音 / 搜狗)是**阻塞发布的人工验收项**,当前未执行。
|
||||||
|
|
||||||
|
- CodeMirror 6 + `@codemirror/lang-markdown`,**即时渲染**:语法标记默认隐藏、光标所在行展开为源码(`web/src/editor/` 装饰层);
|
||||||
|
行号与活动行高亮仅在**源码模式**下显示,「源码模式」按钮即逃生舱(方案 §4.7);
|
||||||
|
正文排版与公开页共用 `--md-*` 契约变量(`index.css`),编辑态视觉 = 阅读态视觉;
|
||||||
|
工具栏提供加粗/斜体/链接/代码/表格等 Markdown 辅助插入;
|
||||||
- **图片粘贴/拖拽**:自写 paste/drop handler(≤50 行)→ `POST /api/admin/images` → 光标处插入 ``;
|
- **图片粘贴/拖拽**:自写 paste/drop handler(≤50 行)→ `POST /api/admin/images` → 光标处插入 ``;
|
||||||
- **slug 策略**:首次保存时由标题自动生成——中文标题退化为 `post-<YYYYMMDD>`,与现有记录(含回收站)冲突时追加 `-2`/`-3` 直至唯一;**首次保存即定稿,此后除非用户手动修改否则永不变化**;用户手改与他人冲突 → 服务端 409 + 字段级错误提示,前端就地高亮;
|
- **slug 策略**:首次保存时由标题自动生成——中文标题退化为 `post-<YYYYMMDD>`,与现有记录(含回收站)冲突时追加 `-2`/`-3` 直至唯一;**首次保存即定稿,此后除非用户手动修改否则永不变化**;用户手改与他人冲突 → 服务端 409 + 字段级错误提示,前端就地高亮;
|
||||||
- 自动保存:防抖 2s;大改动前本地兜底(失败时保留编辑态不丢内容)。
|
- 自动保存:防抖 2s;大改动前本地兜底(失败时保留编辑态不丢内容)。
|
||||||
@@ -564,7 +574,7 @@ example.com {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> 幂等启动:`serve` 时若 settings 无口令哈希则拒绝启动并提示先跑 `init`;`user_version` 越界同样拒绝(§10.4)。
|
> 幂等启动:`start` 时若 settings 无口令哈希则拒绝启动并提示先跑 `init`;`user_version` 越界同样拒绝(§10.4)。
|
||||||
|
|
||||||
### 10.3 备份与恢复
|
### 10.3 备份与恢复
|
||||||
|
|
||||||
@@ -587,7 +597,7 @@ timer 触发 `pn-maint.service`:先 `pn gc --commit`,后 `pn backup /backup/
|
|||||||
|
|
||||||
**要点**
|
**要点**
|
||||||
|
|
||||||
- 备份进程独立于服务进程运行,不受 unit 沙箱约束;`VACUUM INTO` 在 WAL 下与 serve 并发安全(在线一致快照);
|
- 备份进程独立于服务进程运行,不受 unit 沙箱约束;`VACUUM INTO` 在 WAL 下与 start 并发安全(在线一致快照);
|
||||||
- `/backup` 目录属主 `purenote`、权限 0700;备份文件**chmod 0600**(内含全部私有笔记、图片、口令哈希、会话哈希的明文副本);
|
- `/backup` 目录属主 `purenote`、权限 0700;备份文件**chmod 0600**(内含全部私有笔记、图片、口令哈希、会话哈希的明文副本);
|
||||||
- 保留 30 天;**语义声明**:备份保留期内被删除的数据物理上仍存在于备份中;
|
- 保留 30 天;**语义声明**:备份保留期内被删除的数据物理上仍存在于备份中;
|
||||||
- 异地同步(可选):`rclone` 远端启用 **crypt**(或先 `age` 加密再上传),禁止明文上云;
|
- 异地同步(可选):`rclone` 远端启用 **crypt**(或先 `age` 加密再上传),禁止明文上云;
|
||||||
@@ -625,7 +635,7 @@ timer 触发 `pn-maint.service`:先 `pn gc --commit`,后 `pn backup /backup/
|
|||||||
|
|
||||||
| 里程碑 | 内容 | 测试/验收门 |
|
| 里程碑 | 内容 | 测试/验收门 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| M0 骨架 | Go 模块 + SQLite 迁移(user_version 守卫)+ 配置子命令 + 中间件链 + embed 构链(拷贝 + 占位 dist)+ `make build`/`make dev` | `make build` 产出可运行二进制;`serve` 起服务;`/api/health` 200 |
|
| M0 骨架 | Go 模块 + SQLite 迁移(user_version 守卫)+ 配置子命令 + 中间件链 + embed 构链(拷贝 + 占位 dist)+ `make build`/`make dev` | `make build` 产出可运行二进制;`start` 起服务;`/api/health` 200 |
|
||||||
| M1 公开浏览 | 笔记/标签 API + React 博客三页 + Markdown 渲染管线 + RSS/sitemap + 元信息注入(可见性规则) | 可见性矩阵**公开侧**用例起步;**构建冒烟测试**入 CI |
|
| M1 公开浏览 | 笔记/标签 API + React 博客三页 + Markdown 渲染管线 + RSS/sitemap + 元信息注入(可见性规则) | 可见性矩阵**公开侧**用例起步;**构建冒烟测试**入 CI |
|
||||||
| M2 管理端 | 登录/会话/CSRF(`/api/me` 重取)+ 笔记 CRUD + 回收站 + 图片上传(魔数/缓存头分流)+ 编辑器 + 改密 | **表驱动可见性矩阵完整**(§13)+ 限流/CSRF/魔数/回收站用例**随功能同 PR 交付** |
|
| M2 管理端 | 登录/会话/CSRF(`/api/me` 重取)+ 笔记 CRUD + 回收站 + 图片上传(魔数/缓存头分流)+ 编辑器 + 改密 | **表驱动可见性矩阵完整**(§13)+ 限流/CSRF/魔数/回收站用例**随功能同 PR 交付** |
|
||||||
| M3 安全加固 | 全量 §9.3 检查单、CSP 确认(记录 nonce 硬化待办)、CI 齐备(vet+vulncheck+测试+冒烟) | §9.3 全项通过 |
|
| M3 安全加固 | 全量 §9.3 检查单、CSP 确认(记录 nonce 硬化待办)、CI 齐备(vet+vulncheck+测试+冒烟) | §9.3 全项通过 |
|
||||||
@@ -705,7 +715,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 +729,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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,586 @@
|
|||||||
|
# Pure Note — Markdown 即时渲染(Typora 式)设计方案
|
||||||
|
|
||||||
|
> 版本:v1.2
|
||||||
|
> 日期:2026-09-10(v1.0 方案)/ 2026-09-11(v1.1 实施、v1.2 P3 加固)
|
||||||
|
> 状态:**P0–P3 代码与自动化验证全部完成**(§12);唯一剩余项是 §12.6 的**人工输入法门槛(阻塞发布)**
|
||||||
|
> 关联:`docs/design.md` §8.2、§8.1;`docs/decisions.md` D5 / D26 / D37–D52
|
||||||
|
> 定位:本文档**取代** `docs/design.md` §8.2「编辑器(编辑态)」中「分屏实时预览」的表述;
|
||||||
|
> 数据层、服务端渲染管线、安全防线均不在本次变更范围内。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景与目标
|
||||||
|
|
||||||
|
### 1.1 现状(改造前基线)
|
||||||
|
|
||||||
|
改造前编辑体验是「一块源码编辑区 + 一块渲染预览区」,两者物理分离:
|
||||||
|
|
||||||
|
| 层 | 位置 | 实现(改造前) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 编辑态 | `web/src/components/Editor.tsx` | CodeMirror 6 源码 + 行号 + 等宽字体 |
|
||||||
|
| 预览触发 | `web/src/pages/AdminEdit.tsx` | `showPreview` 开关,`预览 / 隐藏预览` 按钮 + 条件渲染 |
|
||||||
|
| 浏览态 | `web/src/components/MarkdownViewer.tsx` | react-markdown → rehype-sanitize → rehype-highlight |
|
||||||
|
| 排版契约 | `web/src/index.css` | `.pn-note-body` 正文排版 |
|
||||||
|
| 真相源 | SQLite `notes.content` | Markdown **原文**,编辑与展示双向都不落地富文本中间态 |
|
||||||
|
|
||||||
|
> 上表是改造前的基线,便于对照回归面;**改造后的实际落地见 §12**。
|
||||||
|
|
||||||
|
### 1.2 问题
|
||||||
|
|
||||||
|
1. 编辑区显示的是源码:`#`、`**`、`>`、`| --- |` 全部裸露,且用等宽字体,与阅读页视觉完全割裂;
|
||||||
|
2. 想确认效果必须手动切换/分屏,写作—核对反复来回,长文与表格尤其痛苦;
|
||||||
|
3. 分屏在窄屏下不可用(当前实现是「切换」而非真分屏),移动端等于没有预览。
|
||||||
|
|
||||||
|
### 1.3 目标 / 非目标
|
||||||
|
|
||||||
|
**目标(IN SCOPE)**
|
||||||
|
|
||||||
|
- **单一编辑面**:输入即渲染,语法标记默认隐藏、按渲染态显示,光标进入处展开为源码可编辑(Typora 核心手感);
|
||||||
|
- **编辑态视觉 = 阅读态视觉**:编辑区与公开笔记页共用同一套排版契约;
|
||||||
|
- **数据层零改动**:真相源仍是 Markdown 原文,公开页渲染管线(react-markdown + sanitize + highlight)与全部安全防线不动;
|
||||||
|
- **一键源码模式**作为逃生舱,装饰层出问题时用户永远可退回可用状态。
|
||||||
|
|
||||||
|
**非目标(OUT OF SCOPE,后续方向见 §10)**
|
||||||
|
|
||||||
|
- 表格的 HTML 渲染与可视化编辑;
|
||||||
|
- 数学公式(KaTeX/MathJax)与 Mermaid/流程图;
|
||||||
|
- 引入 ProseMirror / Tiptap / Milkdown 等富文本模型;
|
||||||
|
- 多光标协同、版本历史、移动端专门适配。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 调研:Typora 即时渲染的实现原理
|
||||||
|
|
||||||
|
### 2.1 Typora 的可观察实现(闭源)
|
||||||
|
|
||||||
|
Typora 不公开源码,以下为官方文档 + DOM 逆向 + 用户报告可确认的事实。
|
||||||
|
|
||||||
|
**不是「文本区 + 预览区」,而是单一 contenteditable 编辑面**,整篇文档即一棵渲染后的 DOM 树。公开的 DOM dump([typora-issues#6629](https://github.com/typora/typora-issues/issues/6629))显示:
|
||||||
|
|
||||||
|
- 根容器 `#write`;块级元素带 `mdtype`(`heading` / `plain` / `table` / `list-item` / `fences`…)与稳定内容 id `cid`;
|
||||||
|
- 行内片段带 `md-inline`(`plain` / `code`…);语法的成对定界符是独立 span(`md-pair-s`);
|
||||||
|
- `md-expand` 标记「该片段已展开为源码」的状态。
|
||||||
|
|
||||||
|
**三条核心机制**(官方 [Markdown Reference](https://support.typora.io/Markdown-Reference/)):
|
||||||
|
|
||||||
|
1. **输入即渲染**:"Span elements will be parsed and rendered right after typing."
|
||||||
|
2. **光标进入即展开源码**:"Moving the cursor to the middle of a span element will expand that element into the Markdown source." —— 平时 `**粗体**` 显示为粗体,光标移入后 `**` 才现身可编辑;
|
||||||
|
3. **块级元素整体渲染**:代码围栏、表格、公式、图表、图片各有专门呈现;另有全局源码模式(Cmd/Ctrl+/)。
|
||||||
|
|
||||||
|
内部算法(AST ↔ DOM 映射、脏块增量重解析)无公开文档。但从用户报告看并非高度增量:>5000 行、>10000 词即明显卡顿([#6542](https://github.com/typora/typora-issues/issues/6542)、[#6551](https://github.com/typora/typora-issues/issues/6551))。**结论:即时渲染的交互模型不难复刻,难点在大文档性能、IME 与选区边界。**
|
||||||
|
|
||||||
|
### 2.2 开源对照
|
||||||
|
|
||||||
|
| 编辑器 | 技术栈 | 真相源 | 保真度 | 备注 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| **Obsidian Live Preview** | CodeMirror 6(闭源) | Markdown 原文 | 逐字节 | 与本方案同路线,最接近的生产级范例 |
|
||||||
|
| **Vditor IR** | 自有 Lute 引擎 | Markdown 原文 | 逐字节 | 三模式 sv/wysiwyg/ir |
|
||||||
|
| **HyperMD** | CodeMirror 5 | Markdown 原文 | 逐字节 | 插件式隐藏,CM5 时代产物 |
|
||||||
|
| **Milkdown / Crepe** | ProseMirror + remark | ProseMirror 文档 | 序列化规范化 | markdown 仅为导入导出格式 |
|
||||||
|
| **Tiptap + `@tiptap/markdown`** | ProseMirror + MarkedJS | Tiptap JSON | 有损 | 官方标注 early release,往返缺陷多 |
|
||||||
|
| **Toast UI Editor** | ProseMirror | Markdown 字符串 API | 规范化 | 富模型驱动 |
|
||||||
|
| **ByteMD** | CodeMirror + remark | Markdown 原文 | 编辑侧精确 | 预览为单向 HTML |
|
||||||
|
|
||||||
|
### 2.3 路线选型
|
||||||
|
|
||||||
|
| | 路线 A:CM6 装饰器即时渲染 | 路线 B:富文本模型 WYSIWYG |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 真相源 | Markdown 原文(不变) | ProseMirror/Tiptap JSON |
|
||||||
|
| 改动面 | 仅 `Editor.tsx` 内部 + 新增装饰模块 | 编辑器 + 序列化 + 预览管线全部替换 |
|
||||||
|
| 数据/安全 | 不动 | 需重新论证 |
|
||||||
|
| 保真度 | 原文逐字节不变 | 表格/转义/代码围栏/脚注有损 |
|
||||||
|
| 依赖 | **零新增**(已装 CM6 全部够用,见附录 A) | 新增 prosemirror 全家桶(bundle 显著增大) |
|
||||||
|
| 主要风险 | 光标/选区/IME 的边角情况 | 富模型 ↔ Markdown 往返缺陷 |
|
||||||
|
|
||||||
|
**选路线 A。** 理由:
|
||||||
|
|
||||||
|
1. 与项目定位一致 —— `design.md:118` 明确选 CM6 就是为了「不需要维护富文本中间态」;换富模型等于把这条好处还回去;
|
||||||
|
2. 命中既有预留 —— `design.md:684` 已声明「`MarkdownViewer` 组件边界独立;若日后要 WYSIWYG,仅替换 Editor,数据层不变」;
|
||||||
|
3. 零新增依赖、公开页渲染管线与安全防线完全不动,回归面最小;
|
||||||
|
4. 路线 B 的往返有损已被大量实证(Tiptap 转义块丢失 [#8134](https://github.com/ueberdosis/tiptap/issues/8134)、行内 code 含反引号被破坏 [#8298](https://github.com/ueberdosis/tiptap/issues/8298)、表格内转义竖线丢失 [#8294](https://github.com/ueberdosis/tiptap/issues/8294)),对「极简健壮、可预期」是倒退。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 总体设计
|
||||||
|
|
||||||
|
### 3.1 架构分层
|
||||||
|
|
||||||
|
```
|
||||||
|
Editor.tsx(React 外壳,接口:value / onChange / onUploadStart / onUploadEnd / sourceMode)
|
||||||
|
│ 职责:拖拽/粘贴上传、主题、源码模式开关、拖拽遮罩
|
||||||
|
└── web/src/editor/livePreview.ts 扩展集入口(ViewPlugin + atomicRanges + Compartment)
|
||||||
|
├── syntax.ts 基于 lezer syntaxTree 识别语法区间 → 装饰描述 Spec(纯函数,可单测)
|
||||||
|
├── reveal.ts 光标处展开(行粒度):装饰集是 selection 的函数(纯函数,可单测)
|
||||||
|
├── decorations.ts Spec → DecorationSet / 原子区间;图片 src 白名单
|
||||||
|
└── widgets.ts BulletWidget / QuoteBarWidget / HrWidget / ImageWidget /
|
||||||
|
FenceLangWidget / TaskWidget
|
||||||
|
```
|
||||||
|
|
||||||
|
> 实施时把方案里的 `markers.ts` / `inline.ts` / `blocks.ts` 合并为 `syntax.ts` + `decorations.ts`
|
||||||
|
> (三个文件职责都是「节点 → 装饰」,拆分只增加跳转成本),详见 §12.2 偏差 D2。
|
||||||
|
|
||||||
|
### 3.2 数据流(不变)
|
||||||
|
|
||||||
|
```
|
||||||
|
DB(notes.content = Markdown 原文)
|
||||||
|
├── 编辑态:CodeMirror 文档 = 原文;装饰器只改「视图」,不碰文档
|
||||||
|
└── 浏览态:MarkdownViewer = react-markdown 管线(原样保留)
|
||||||
|
```
|
||||||
|
|
||||||
|
**不变量:装饰层永不修改文档内容,只影响呈现。** `onChange` 拿到的一直是用户键入后的 Markdown 原文。
|
||||||
|
|
||||||
|
### 3.3 一致性契约
|
||||||
|
|
||||||
|
编辑态渲染结果必须与公开页一致,覆盖:字号/行高、标题层级字号与字体族(`--font-serif`)、引用左边框、代码块底色与等宽、列表间距、链接样式、图片圆角、表格边框。实现方式见 §4.6。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 详细设计
|
||||||
|
|
||||||
|
### 4.1 语法识别
|
||||||
|
|
||||||
|
**用 lezer 语法树,不自己写正则。** `syntaxTree(state)`(`@codemirror/language` 6.12.4)由 `@codemirror/lang-markdown` 6.5.2 增量维护;当前 `Editor.tsx:49` 配置为 `markdown({ base: markdownLanguage })`,`markdownLanguage` 为 GFM 语言,`Table` / `Task` / `Strikethrough` 节点可用(已核实)。
|
||||||
|
|
||||||
|
按节点取区间:
|
||||||
|
|
||||||
|
| 语法 | lezer 节点 | 处理 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 标题 | `ATXHeading1..6` + `HeaderMark` | 隐藏 `#`;行加 `.cm-md-h{1..6}` |
|
||||||
|
| 粗/斜/删除线 | `StrongEmphasis` / `Emphasis` / `Strikethrough` + `*Mark` | 隐藏定界符;内容加 mark |
|
||||||
|
| 行内代码 | `InlineCode` + `CodeMark` | 隐藏反引号;内容加 `.cm-md-code` |
|
||||||
|
| 链接 | `Link` + `LinkMark` | 隐藏 `[`、`](url)`;文本加 `.cm-md-link` |
|
||||||
|
| 图片 | `Image` | 整段替换为 `<img>` widget(§5.4) |
|
||||||
|
| 引用 | `Blockquote` + `QuoteMark` | `>` → 竖线 widget(§5.3) |
|
||||||
|
| 列表 | `BulletList` / `OrderedList` + `ListMark` | 圆点 widget / 数字保留(§5.1、§5.2) |
|
||||||
|
| 代码围栏 | `FencedCode` + `CodeMark` / `CodeInfo` | 起围栏 → 语言标签 widget;闭围栏隐藏;块加 `.cm-md-fenced` |
|
||||||
|
| 水平线 | `HorizontalRule` | 整行替换为 `<hr>` 块 widget |
|
||||||
|
| 任务项 | `Task` + `TaskMarker` | `[ ]`/`[x]` → 可点 checkbox widget(§5.4) |
|
||||||
|
| 表格 | `Table` 全族 | v1 仅行级等宽对齐 + 定界符淡化,**不渲染 HTML**(§10.1) |
|
||||||
|
|
||||||
|
### 4.2 隐藏与原子化
|
||||||
|
|
||||||
|
- 纯定界符区间用 `Decoration.replace({})` 隐藏,并纳入 `EditorView.atomicRanges`:方向键整段跳过、退格一次删净;
|
||||||
|
- **仅对「纯标记区间」原子化,绝不对含可编辑内容的区间原子化** —— 例如链接只把 `[` 与 `](url)` 两个子区间原子化,中间文本仍可正常进入编辑;
|
||||||
|
- 破坏行高/换行的块级装饰(围栏折叠、水平线、图片)**必须用 `StateField` 直接 `provide: EditorView.decorations`**;行内隐藏用 `ViewPlugin` + `view.visibleRanges`(CM6 硬性约束:间接装饰在视口计算后取,块级 widget 会错位)。
|
||||||
|
|
||||||
|
### 4.3 行内渲染
|
||||||
|
|
||||||
|
定界符隐藏后,内容区间用 `Decoration.mark({ class })` 上样式,类名对齐阅读态:`.cm-md-strong`(font-weight 600)、`.cm-md-em`(italic)、`.cm-md-del`(删除线 + `--muted-foreground`)、`.cm-md-code`(等宽 + `--muted` 底 + 圆角,对齐 `index.css:852-857`)、`.cm-md-link`(下划线 + `--border-strong`,对齐 `index.css:919-927`)。
|
||||||
|
|
||||||
|
### 4.4 块级渲染
|
||||||
|
|
||||||
|
- **标题**:隐藏 `#` 后该行加 `.cm-md-h1..h6`,字号/字体族/间距对齐 `index.css:817-838`;
|
||||||
|
- **引用**:整行加 `.cm-md-quote`(左内边距 + 左边框,对齐 `index.css:840-847`),`>` 由竖线 widget 承接(§5.3);
|
||||||
|
- **代码块**:首行围栏替换为语言标签 widget,块内各行加 `.cm-md-fenced`(`--muted` 底 + 圆角),代码文本保持 `--font-mono`;
|
||||||
|
- **表格**:整表各行加 `.cm-md-table`(等宽 + 13.5px,对齐 `index.css:903-917`),`|` 与分隔行 `---` 用 `.cm-md-table-mark` 淡化(**不隐藏** —— 隐藏竖线会破坏列对齐的可读性)。
|
||||||
|
|
||||||
|
### 4.5 光标处展开(reveal)
|
||||||
|
|
||||||
|
Typora 的核心手感,实现为**装饰集是 `state.selection` 的函数**:
|
||||||
|
|
||||||
|
```
|
||||||
|
activeLine = state.doc.lineAt(state.selection.main.head)
|
||||||
|
对每个候选隐藏区间:
|
||||||
|
若与 activeLine 相交 → 撤销该处 replace(显示源码),保留样式 mark
|
||||||
|
否则 → 隐藏
|
||||||
|
```
|
||||||
|
|
||||||
|
- **v1 用行粒度**(光标所在行的语法标记整体展开),实现简单、行为可预期、对 IME 最安全;
|
||||||
|
- **未聚焦时整篇按渲染态呈现**(决策 E11):编辑器失焦 = 只读观感,避免「首行因光标停在行首而永远显示 `#`」;
|
||||||
|
- 代码围栏处于活动状态时同样展开围栏,便于改语言标识;
|
||||||
|
- 细化到 Typora 的 span 级展开列入 §10.2,不在 v1 承诺。
|
||||||
|
|
||||||
|
### 4.6 排版一致性(共享 typography 层)
|
||||||
|
|
||||||
|
现有正文排版硬编码在 `.pn-note-body`(`index.css:805-933`),装饰态用的是 `.cm-line` / span,无法直接复用选择器。做法:
|
||||||
|
|
||||||
|
1. 把排版参数抽为 CSS 变量(`--md-body-size: 15px`、`--md-body-lh: 1.8`、`--md-h1-size: 26px` …);
|
||||||
|
2. `.pn-note-body` 的规则与装饰态类(`.cm-md-*`)**共同引用同一组变量**,杜绝两份样式漂移;
|
||||||
|
3. 编辑区正文族与字号对齐阅读态(`--font-sans` / 15px / 1.8),**仅 `.cm-md-code` 与 `.cm-md-fenced` 用 `--font-mono`**;当前 CM 全局等宽字号 14.5px(`index.css:1944-1948`)需调整;
|
||||||
|
4. 即时渲染模式下**关闭行号与活动行高亮**(Typora 无行号),`Editor.tsx:109` 的 `basicSetup` 相应调整。
|
||||||
|
|
||||||
|
### 4.7 源码模式(逃生舱)
|
||||||
|
|
||||||
|
- 用 `Compartment`(`@codemirror/state`)包裹 `livePreview` 扩展集,开关即整体挂载/卸载,O(1) 切换、无残留装饰;
|
||||||
|
- `AdminEdit.tsx` 现有「预览」按钮(`AdminEdit.tsx:204-209`)语义改为**「源码模式」开关**,`showPreview` 分屏块(`AdminEdit.tsx:282-286`)删除;
|
||||||
|
- 进入源码模式时恢复行号与等宽,与现状一致。
|
||||||
|
|
||||||
|
### 4.8 与既有功能的衔接
|
||||||
|
|
||||||
|
| 功能 | 变化 |
|
||||||
|
| --- | --- |
|
||||||
|
| 图片粘贴/拖拽上传 | **不变**。仍插入 `` 文本,装饰器随即把它渲染为图片 |
|
||||||
|
| 自动保存(防抖 2s) | **不变**。`onChange` 语义与频率不变 |
|
||||||
|
| 工具栏插入 | **不变**。插入的仍是 Markdown 片段,插入后立即渲染 |
|
||||||
|
| 主题(明/暗/跟随) | **不变**。装饰类全部走 CSS 变量,自动随主题 |
|
||||||
|
| 拖拽遮罩 | **不变**(`Editor.tsx:124-128`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Widget 规格
|
||||||
|
|
||||||
|
### 5.1 无序列表圆点(决策 E2)
|
||||||
|
|
||||||
|
| 项 | 规格 |
|
||||||
|
| --- | --- |
|
||||||
|
| 触发 | `ListMark` ∈ `BulletList`(源文本 `-` / `*` / `+`) |
|
||||||
|
| 装饰 | `Decoration.replace({ widget: BulletWidget })`,**原子** |
|
||||||
|
| 呈现 | `<span class="cm-md-bullet" aria-hidden="true">`,字符按嵌套深度取 `•`(深 1)/ `◦`(深 2)/ `▪`(深 3+) |
|
||||||
|
| 深度判定 | 遍历语法树祖先中 `BulletList` / `OrderedList` 的个数(可靠,不依赖缩进列数) |
|
||||||
|
| 对齐 | widget 宽度 = 原标记宽度,保证列表内容左边界不跳动 |
|
||||||
|
| 交互 | v1 不可点击(改标记类型请切源码模式) |
|
||||||
|
|
||||||
|
### 5.2 有序列表(决策 E3)
|
||||||
|
|
||||||
|
| 项 | 规格 |
|
||||||
|
| --- | --- |
|
||||||
|
| 触发 | `ListMark` ∈ `OrderedList`(源文本 `1.` / `2)` …) |
|
||||||
|
| 装饰 | **仅** `Decoration.mark({ class: 'cm-md-ol-mark' })`,**不替换、不原子** |
|
||||||
|
| 理由 | 数字携带序号语义(起始序号、`3.` 这类非常规起点),替换为 widget 会让用户无法编辑;CM 亦不自动重排列表号 |
|
||||||
|
| 呈现 | 数字加粗 + `--muted-foreground`,与圆点视觉重量接近 |
|
||||||
|
|
||||||
|
### 5.3 引用竖线(决策 E4)
|
||||||
|
|
||||||
|
| 项 | 规格 |
|
||||||
|
| --- | --- |
|
||||||
|
| 触发 | `QuoteMark`(源文本 `>`,嵌套时每层各一个) |
|
||||||
|
| 装饰 | `Decoration.replace({ widget: QuoteBarWidget })`,**原子** |
|
||||||
|
| 呈现 | `<span class="cm-md-quote-bar" aria-hidden="true">`,CSS 画 2px `var(--border-strong)` 竖线,**宽度 = 原标记宽度**,保证后续内容对齐 |
|
||||||
|
| 嵌套 | 每层 `QuoteMark` 各出一个 widget,自然叠加为多道竖线(接近 Typora) |
|
||||||
|
| 行样式 | 同行加 `Decoration.line({ class: 'cm-md-quote' })` 提供左内边距,视觉对齐 `index.css:840-847` |
|
||||||
|
|
||||||
|
### 5.4 其余 widget
|
||||||
|
|
||||||
|
| widget | 触发 | 呈现与行为 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 图片 | `Image` 全节点 | `Decoration.replace` 成 `<img>`(`max-width:100%`、圆角,对齐 `index.css:893-897`);`src` 白名单见 §6;原子 |
|
||||||
|
| 水平线 | `HorizontalRule` 整行 | `<hr>` 块 widget(**StateField 直接 provide**) |
|
||||||
|
| 任务框 | `TaskMarker` | `<input type="checkbox">`;点击 dispatch 改写源文本 `[ ]` ↔ `[x]`(真相源仍是文本);原子 |
|
||||||
|
| 围栏语言标签 | `CodeInfo` + 起围栏 | 语言名小标签;点击可切源码模式改语言 |
|
||||||
|
| 链接 URL | `LinkMark` 的 `](url)` 段 | 隐藏;v1 不做悬浮预览,光标进入该行即展开可见 |
|
||||||
|
|
||||||
|
### 5.5 Widget 通用约束
|
||||||
|
|
||||||
|
1. **构造 DOM 一律用 `document.createElement` / `textContent` / `setAttribute`,禁止 `innerHTML`**;
|
||||||
|
2. 所有 widget 视为装饰层私有产物,不接受用户 HTML;
|
||||||
|
3. `toDOM` 返回值带 `aria-hidden="true"`(视觉替代物,不参与无障碍朗读;复选框除外,需带 `aria-label`);
|
||||||
|
4. 块级 widget 必须走 `StateField` 直接 provide(§4.2);
|
||||||
|
5. widget 不得改变文档内容与选区语义。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 安全设计
|
||||||
|
|
||||||
|
装饰层不引入新的 XSS 面,但必须显式加固以下两条:
|
||||||
|
|
||||||
|
| 面 | 风险 | 措施 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 图片 `src` | 用户可写 `` / 外链 | 仅允许 `^/api/images/` 与 `^data:image/`;其余不发 `<img>`,降级为源码文本 |
|
||||||
|
| 链接 URL | 用户可写 `[x](javascript:…)` | v1 链接**只做文本样式,不渲染 `<a>`**,无点击行为 → 无协议执行面;日后若加「点击打开」,必须复用 `sanitize.ts` 的 http/https/mailto 白名单 |
|
||||||
|
| widget DOM | 注入执行 | 全程 `createElement`/`textContent`,零 `innerHTML`(§5.5) |
|
||||||
|
| CSP | 现有策略 | **不变**。`style-src 'unsafe-inline'` 的既有豁免仍由 CodeMirror `style-mod` 需要(见 `review-round1.md` 第 18 项);装饰类走静态 CSS,不增加 CSP 面 |
|
||||||
|
|
||||||
|
公开页渲染管线(`MarkdownViewer` + `rehype-sanitize`)与 `tests/markdown.test.tsx` 的断言**全部保持有效**,本次不改动该链路。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 风险与缓解
|
||||||
|
|
||||||
|
### 7.1 中文输入法(IME)—— 最高风险
|
||||||
|
|
||||||
|
替换装饰会在组合输入过程中改动 DOM,CodeMirror 官方 issue 中此类缺陷集中:
|
||||||
|
|
||||||
|
| issue | 现象 |
|
||||||
|
| --- | --- |
|
||||||
|
| [dev#1654](https://github.com/codemirror/dev/issues/1654) | 装饰与 IME 组合输入冲突 |
|
||||||
|
| [dev#1650](https://github.com/codemirror/dev/issues/1650) | 语法高亮边界处组字乱码 |
|
||||||
|
| [dev#1684](https://github.com/codemirror/dev/issues/1684) | Chrome 上中文 IME 删掉前文 |
|
||||||
|
| [dev#1688](https://github.com/codemirror/dev/issues/1688) | 括号内组字后文本视觉消失 |
|
||||||
|
|
||||||
|
**缓解**:
|
||||||
|
|
||||||
|
1. `view.composing === true` 期间**冻结装饰更新**(`view.composing` 为 CM6 公开 API,已核实),绝不替换组合范围内的 DOM;
|
||||||
|
—— 已实现(`livePreview.ts` 中组合期间只做 `decorations.map(changes)`,不重建);
|
||||||
|
2. reveal 用行粒度 —— 光标所在行本就是展开态,组字中途不会被替换;
|
||||||
|
3. 原子区间只作用于已隐藏的纯标记范围(不变量有单测守护,§12.5.3);
|
||||||
|
4. **上线前必须用拼音 / 搜狗 / 系统输入法真机回归,这是自动化测不出来的硬门槛** ——
|
||||||
|
已形式化为 §12.6 的**人工验收门槛(阻塞发布)**,含 8 项用例与签字要求;当前状态:未执行。
|
||||||
|
|
||||||
|
### 7.2 选区与光标
|
||||||
|
|
||||||
|
已知边角:多行 replace 破坏光标定位([#1658](https://github.com/codemirror/dev/issues/1658))、块 widget 致选区渲染异常([#1406](https://github.com/codemirror/dev/issues/1406))、相邻 widget 间移动异常([#979](https://github.com/codemirror/dev/issues/979))。缓解:避免多行 replace(逐行处理);对替换区间原子化。
|
||||||
|
|
||||||
|
**实测结论(P3,2026-09-11)**:读 CM6 源码可知 `EditorView.atomicRanges` 的消费点有两处 ——
|
||||||
|
`view` 包内 `skipAtomsForSelection`(鼠标指针选区,`view/dist:4317`)与 `@codemirror/commands` 的
|
||||||
|
`skipAtomic`(键盘移动/删除)。**因为 reveal 是行粒度,光标所在行永远是展开态**,所以原子区间在
|
||||||
|
键盘输入路径上几乎不起作用(退格只会命中「当前行之前」的换行符,而非本行标记);它的真实作用面是
|
||||||
|
**鼠标点击/拖拽**:锚点落在已隐藏的标记内部时被吸附到标记边界。这不是缺陷(行粒度下「本行永远可编辑」
|
||||||
|
本就是设计目标,决策 E6),而是需要如实记录的作用边界;若日后细化到 span 级 reveal(§10.2),
|
||||||
|
原子区间才会在键盘路径上变成关键机制。
|
||||||
|
|
||||||
|
边界回归见 §12.5:文首/文末构造、全选删除、跨标记选区替换、原子区间四项不变量(不跨行 / 有序不重叠 /
|
||||||
|
不含可编辑内容 / 隐藏标记必被原子化)。
|
||||||
|
|
||||||
|
### 7.3 块 widget 与布局
|
||||||
|
|
||||||
|
"[#575](https://github.com/codemirror/dev/issues/575) 视口外行不渲染"、"[#625](https://github.com/codemirror/dev/issues/625) 文末块 widget 消失":本实现不使用 block widget(决策 E10),水平线以整行
|
||||||
|
inline-block 承接、围栏闭标记只隐藏内容,因此这两类缺陷的结构性成因不存在。仍单独回归了「文首水平线/列表/围栏/任务」
|
||||||
|
「文末水平线/图片/围栏/任务」「整篇只有一条水平线」「文末追加内容」等边界(§12.5),全部通过。
|
||||||
|
|
||||||
|
### 7.4 性能
|
||||||
|
|
||||||
|
装饰计算只遍历 `view.visibleRanges`(外加选区端点行),不整篇扫描;语法树由 lezer 增量维护。
|
||||||
|
目标:3000 行文档单帧 < 16ms。**P3 已实测,结论见 §12.5**,摘要:
|
||||||
|
|
||||||
|
- 视口窗口(约 100 行)装饰采集中位数 **0.26ms**;全篇作为单一区间的极端情况中位数 **5.0ms**(< 16ms);
|
||||||
|
- 真实编辑器输入(3000 行文档、文末处连打 40 字)同步耗时中位数 **0.1ms**、最大 0.7ms;
|
||||||
|
- 冷启动从文档开头直跳文末:帧间隔最大 **7.1ms**、**零 long task**(无卡顿);
|
||||||
|
- 整篇 3000 行(15.6 万字符)全量解析 **15.4ms**,因此 §12.5 的「补解析预算」常态下不会真正被耗尽。
|
||||||
|
|
||||||
|
长文档的真实风险不是构建成本,而是 **lezer 的惰性解析**:`syntaxTree(state)` 首次只覆盖前约 3000 字符,
|
||||||
|
若不做处理,未解析区间会显示裸 Markdown(P3 修复,见 §12.5 与决策 E13)。
|
||||||
|
|
||||||
|
### 7.5 兼容与降级
|
||||||
|
|
||||||
|
风险兜底为源码模式(§4.7)。若某类文档装饰层出现无法及时修复的问题,可只对受影响语法节点关闭隐藏(配置项 + 单测),用户无感降级。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 测试方案
|
||||||
|
|
||||||
|
| 层 | 用例 | 落地 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 纯函数单测(vitest,无 DOM) | `syntax.ts` 区间识别(嵌套强调、代码块内转义、中文标点、表格分隔行);`reveal.ts` 行粒度展开与二分/线性等价 | `tests/editor-live-preview.test.ts` |
|
||||||
|
| 安全单测 | widget `toDOM` 输出无 `<script>`、无 `innerHTML`;图片 `src` 非白名单时降级为文本;链接不产出 `<a>` | 同上 |
|
||||||
|
| 组件冒烟(happy-dom 真实 EditorView) | 非活动行标记已隐藏、活动行标记可见、原子区间生效;文首/文末构造;全选删除;不变量 | `tests/editor-boundaries.test.ts` |
|
||||||
|
| 性能基准 | 3000 行视口/全篇装饰计算中位数、全量解析成本、最坏选区(Cmd+A)构建成本 | 同上「性能基准」组 |
|
||||||
|
| 真机回归(**人工门槛,阻塞发布**) | 拼音/搜狗/系统输入法连续输入中文 + 标点;复制粘贴;撤销重做;文首/文末 | §12.6 清单,未通过不得发布 |
|
||||||
|
| 既有回归 | `tests/markdown.test.tsx` 全绿(公开页管线不受影响);`make smoke` 全绿 | 已通过 |
|
||||||
|
|
||||||
|
实测中发现 happy-dom 能真实创建 `EditorView`(含 `view.focus()`、装饰 DOM、`atomicRanges` facet),
|
||||||
|
因此边界与原子化行为用真实 view 断言,不再只测纯函数。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 实施拆解
|
||||||
|
|
||||||
|
| 阶段 | 内容 | 风险 | 状态 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| **P0 骨架** | `syntax.ts` + `decorations.ts` + `reveal.ts` + `livePreview.ts`;覆盖标题/粗体/斜体/行内代码/链接;行粒度 reveal;关闭行号;接入 `Compartment` 源码开关 | 低,手感提升最明显 | ✅ 已完成 |
|
||||||
|
| **P1 块级 widget** | 图片、水平线、任务列表(可点)、围栏语言标签 | 中(块 widget 布局) | ✅ 已完成 |
|
||||||
|
| **P2 列表与引用 + 排版一致性** | 圆点(§5.1)、引用竖线(§5.3)、代码块底色;抽出共享 typography 变量层,编辑态对齐 `.pn-note-body` | 中 | ✅ 已完成 |
|
||||||
|
| **P3 加固** | IME 冻结、选区边界、性能基准、文首/文末边界;删除分屏预览 | 中高(IME 必须真机) | ✅ 代码与自动化部分完成(IME 冻结、分屏删除、选区/文首文末边界回归、3000 行基准实测);**真机 IME 回归是人工门槛,见 §12.6,未通过不得发布** |
|
||||||
|
|
||||||
|
每阶段交付:实现 + 对应单测 + 该阶段回归清单。P0 完成后即可替换现有分屏默认路径。
|
||||||
|
实际交付与验证证据见 §12。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 后续方向(明确记录,不在 v1 范围)
|
||||||
|
|
||||||
|
1. **表格(v1 只做源码对齐)**:后续可做 HTML 表格渲染 + 行列增删的可视化编辑。技术要点:块级 `Decoration.replace` 为 `<table>` widget(必须 StateField 直接 provide)+ 单元格编辑回写 Markdown 文本 + GFM 对齐语法(`:---:`)保真;这是复杂度黑洞,需单独立项与完整回归。
|
||||||
|
2. **span 级 reveal**:把 §4.5 从行粒度细化到 Typora 的「光标所在行内节点」粒度,需先解决 7.1/7.2 的 IME 与光标问题。
|
||||||
|
3. **代码块增强**:围栏内复用 `highlight.js`(公开页已用 `rehype-highlight`)做与阅读态一致的高亮;语言选择器。
|
||||||
|
4. **数学与图表**:KaTeX / Mermaid 需**编辑态与公开页同步引入**,否则「所见即所得」不成立;且要评估 bundle 与 CSP(KaTeX 需字体、Mermaid 需 `unsafe-eval` 类风险),单独评审。
|
||||||
|
5. **脚注 / 定义列表 / 目录**:lezer 支持后按 §4.1 表扩展。
|
||||||
|
6. **无障碍**:widget 的 `aria-label` 与键盘可操作性补全(任务框、语言标签)。
|
||||||
|
7. **移动端/触屏**:选中与原子区间的触屏行为回归。
|
||||||
|
8. **大文档**:3000 行以上基准与必要时的装饰节流。→ P3 已完成 3000 行基准(§12.5);更大规模(1 万行+)与装饰节流仍未做。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 本文档决策记录
|
||||||
|
|
||||||
|
| # | 决策 | 依据 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| E1 | 采用 CM6 装饰器路线(路线 A),不引入富文本模型 | §2.3;零新增依赖、真相源不变、回归面最小;符合 `design.md:118/684` |
|
||||||
|
| E2 | 无序列表标记替换为**圆点 widget**(原子,按嵌套深度 `•`/`◦`/`▪`) | 用户决策;更接近 Typora;宽度对齐避免内容跳动 |
|
||||||
|
| E3 | 有序列表标记**保留数字**,仅上样式,不替换不原子 | 数字携带序号语义且 CM 不自动重排,替换会导致无法编辑起始序号 |
|
||||||
|
| E4 | 引用 `>` 替换为**竖线 widget**(宽度=原标记宽度,原子),嵌套自然叠加多道竖线 | 用户决策;视觉对齐 `index.css:840` 的 `blockquote` |
|
||||||
|
| E5 | 表格 v1 只做源码等宽对齐 + 定界符淡化,不渲染 HTML | 按用户确认;表格可视化编辑复杂度高,列 §10.1 后续方向 |
|
||||||
|
| E6 | reveal 粒度 v1 取**行级**,span 级列 §10.2 | 行级对 IME 最安全、行为可预期;先保正确性再求细腻 |
|
||||||
|
| E7 | 保留源码模式开关(`Compartment` 整体挂载/卸载)作为逃生舱 | 装饰层出 bug 时用户可退回;对应 Typora Cmd/Ctrl+/ |
|
||||||
|
| E8 | 不修改公开页渲染管线与 sanitize 管线 | 安全防线不动;`tests/markdown.test.tsx` 持续守护 |
|
||||||
|
| E9 | 编辑区正文改用 `--font-sans` / 15px / 1.8,仅代码用等宽 | 与 `.pn-note-body` 视觉一致,否则「所见即所得」不成立 |
|
||||||
|
| E10 | **全部装饰由单一 ViewPlugin 产出,不引入 StateField** | 实现中不存在真正的块级装饰(水平线用整行 inline-block 承接、闭围栏只隐藏内容),因此无需 StateField 的全文档遍历,也规避块 widget 的布局/选区风险;代价见 §12.2 D1 |
|
||||||
|
| E11 | **reveal 以「编辑器聚焦」为前提**:未聚焦时整篇按渲染态呈现 | 否则载入即因光标在行首而显示 `#`,WYSIWYG 首屏观感受损;失焦=只读观感,与 Typora 一致 |
|
||||||
|
| E12 | **图片与任务复选框不参与 reveal**,恒为渲染/可点状态 | 图片:源码模式作为改 URL 的出口,避免段落内行内图片在编辑该行时永远显示源码;复选框:参与 reveal 会导致光标落到任务行后无法点击 |
|
||||||
|
| E13 | 装饰前用 `ensureSyntaxTree` 在 **20ms 预算**内补齐可见区间,并以「语法树对象换代」触发重建 | P3 实测 `syntaxTree(state)` 对 3000 行文档首次只覆盖 3061 字符(惰性解析),不补解析则未解析区间显示裸 Markdown;预算有界避免卡顿,补不动时自动等后台解析推进,见 §12.5 |
|
||||||
|
| E14 | 装饰采集范围 = 可见区间 + 选区**端点**行(**不是**整个选区跨越的行) | CM 只把选区端点块渲染到视口之外;全量取会让 Cmd+A 在长文档上退化为全文档装饰构建,见 §12.5 |
|
||||||
|
| E15 | reveal 判定改为「区间合并 + 二分」 | 整篇选区会产生数千个展开区间,原线性扫描 O(装饰数×区间数) 在长文档下失控;二分后最坏选区构建中位数 0.98ms,见 §12.5 |
|
||||||
|
| E16 | **编辑区默认吃满视口剩余高度**,长文在编辑器内部滚动(页面不再随内容增长) | 用户要求(2026-09-11)。此前 `.pn-ae-body-wrap` 只有 `min-height: 420px` 且高度随内容增长:短文编辑区偏小,3000 行长文把页面撑到 8.4 万像素、只能靠页面滚动。落地与实测见 §12.7 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 实施记录(2026-09-11)
|
||||||
|
|
||||||
|
### 12.1 交付物
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
| --- | --- |
|
||||||
|
| `web/src/editor/syntax.ts` | lezer 语法树 → 装饰描述(Spec)纯函数;覆盖标题 / 强调 / 行内代码 / 链接 / 图片 / 引用 / 列表 / 任务 / 围栏 / 水平线 / 表格 |
|
||||||
|
| `web/src/editor/reveal.ts` | 行粒度展开:`activeLineRanges` / `selectionEndpointLines` / `applyReveal`(合并区间 + 二分) |
|
||||||
|
| `web/src/editor/widgets.ts` | `BulletWidget` / `QuoteBarWidget` / `HrWidget` / `ImageWidget` / `FenceLangWidget` / `TaskWidget` |
|
||||||
|
| `web/src/editor/decorations.ts` | Spec → DecorationSet + 原子区间;图片 src 白名单;可见区间去重;`visibleSyntaxTree`(补解析) |
|
||||||
|
| `web/src/editor/livePreview.ts` | ViewPlugin(可见区间 + doc/selection/viewport/focus/语法树换代 驱动)、`EditorView.atomicRanges`、`livePreviewCompartment`、组合输入冻结 |
|
||||||
|
| `web/src/components/Editor.tsx` | 新增 `sourceMode` prop;`.pn-cm-live` 包裹类;compartment 热切换 |
|
||||||
|
| `web/src/pages/AdminEdit.tsx` | 删除分屏预览块与 `MarkdownViewer` import;「预览」按钮改为「源码模式 / 退出源码」 |
|
||||||
|
| `web/src/index.css` | `--md-*` 排版契约变量(阅读态与编辑态共用)+ `.cm-md-*` 装饰样式;移除 `.pn-ae-preview` |
|
||||||
|
| `web/tests/editor-live-preview.test.ts` | 29 条单测:区间识别 / reveal 策略(含二分等价) / 构建与安全 |
|
||||||
|
| `web/tests/editor-boundaries.test.ts` | 32 条回归:文首文末边界 / 跨原子区间选区 / 装饰层不变量 / 长文档惰性解析 / 性能基准 |
|
||||||
|
|
||||||
|
验证结果:前端 **71 条测试全绿**(新增 29 + 32,既有 10),`tsc --noEmit` 与 `npm run build` 通过,
|
||||||
|
`make test`(go vet + go test + vitest)与 `make smoke`(构建→起服务→SPA/meta/安全头/可见性抽查)全绿。
|
||||||
|
**运行时零新增 npm 依赖**;仅新增 `@codemirror/commands` 为 devDependency(它本就在依赖树中,用于边界回归里
|
||||||
|
真实执行退格等编辑命令,不进入产物)。
|
||||||
|
|
||||||
|
### 12.2 与方案的偏差(均经实机验证后确定)
|
||||||
|
|
||||||
|
| # | 偏差 | 原因与代价 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| D1 | **不引入 StateField**,全部装饰由一个 ViewPlugin 产出 | 方案 §4.2/§5.4 要求块级装饰走 StateField;但实现里不存在真正的块级装饰:水平线用「整行内容的 inline-block(`width:100%`)」承接,围栏闭标记只隐藏内容并用行类延续底色。收益是免去 StateField 的全文档遍历与块 widget 的布局/选区风险(§7.3)。**代价**:闭围栏行留下一行空白,成为代码块底部内边距 |
|
||||||
|
| D2 | 模块合并:`markers.ts` / `inline.ts` / `blocks.ts` → `syntax.ts`(识别)+ `decorations.ts`(构建) | 三个文件职责都是「节点 → 装饰」,拆分只增加跨文件跳转;单测边界改为「识别」与「构建/展开」两段,覆盖不减 |
|
||||||
|
| D3 | 图片 widget **不参与 reveal**,恒渲染 | 与 Typora 一致(点图片不展开为源码);改 URL/alt 走源码模式(§4.7)。同时避免「段落中的行内图片在编辑该行时永远显示源码」 |
|
||||||
|
| D4 | 任务复选框 **不参与 reveal**,恒可点 | 否则光标一落到任务行,复选框就变回 `[ ]` 文本,反而无法点击 |
|
||||||
|
| D5 | 行号 / 活动行高亮用 CSS 隐藏(`.pn-cm-live .cm-gutters{display:none}`),不改 `basicSetup` | 避免依赖 `@uiw/react-codemirror` 对 `basicSetup` 的运行时重配置行为 |
|
||||||
|
| D6 | 表格定界符产出单个 `tableMark` spec(带 `delimiter` 标记):普通竖线 55% 透明、分隔行 38% | 与 §4.4「淡化」一致,实现更简单;`TableDelimiter` 节点在 lezer 里对分隔行是整段而非单竖线 |
|
||||||
|
| D7 | 列表圆点覆盖「标记 + 其后空格」,行内样式类名定为 `cm-md-strong/em/del/code/link` | 圆点宽度固定 `1.15em`,与「宽度=原标记宽度」的方案意图一致但更可控(原标记在比例字体下宽度不定) |
|
||||||
|
|
||||||
|
### 12.3 实机验证(内置浏览器,`pn start --dev` + `vite dev`)
|
||||||
|
|
||||||
|
- **渲染**:标题 / 粗体 / 斜体 / 删除线 / 行内代码 / 链接 / 引用(嵌套两道竖线)/ 无序列表(`•`/`◦`/`▪` 三级)/
|
||||||
|
有序列表(保留 `1.` `2.` `3.`)/ 任务框 / 图片(480×220 原尺寸 + 12px 圆角 + 边框)/ 水平线 /
|
||||||
|
围栏(`js` 标签 + 底色圆角)/ 表格(等宽 + 竖线淡化)全部按预期呈现;
|
||||||
|
- **展开**:点击段落行 → 该行显示 `**粗体**` 等源码、其他行标记保持隐藏;聚焦标题行显示 `# `,失焦后恢复整篇渲染(E11);
|
||||||
|
- **交互**:点击复选框 → 源文本 `- [ ]` 改写为 `- [x]`,2s 防抖自动保存后服务端确实收到改写(读回 API 确认);
|
||||||
|
- **源码模式**:切换后行号恢复、装饰全部卸载(无 `cm-md-*` 残留),连续切换 4 次无异常、无 console 错误;
|
||||||
|
- **中文输入**:`insertText` 通路追加中文,以及合成 `compositionstart → 输入 → compositionend` 组合输入,
|
||||||
|
文本无重复/乱序、无 console 错误,其他行装饰保持;
|
||||||
|
- **零回归**:公开阅读页 `.pn-note-body` 计算样式未变(正文 15px / 行高 27px、标题衬线、段落下边距 24px);
|
||||||
|
`tests/markdown.test.tsx` 全绿。
|
||||||
|
|
||||||
|
### 12.4 未完成 / 待办
|
||||||
|
|
||||||
|
1. **真机输入法回归 —— 人工验收门槛,阻塞发布(§12.6)**:自动化只能合成组合事件;拼音 / 搜狗 /
|
||||||
|
系统输入法的连续中文 + 标点输入必须人工真机走查。
|
||||||
|
2. 表格 HTML 渲染与可视化编辑(§10.1)、span 级 reveal(§10.2)、代码块高亮(§10.3)仍按方案留作后续。
|
||||||
|
3. 图片 widget 目前按原尺寸显示;超大图依赖 `max-width:100%` 收敛,未做高度上限与点击放大(§10.7 范畴)。
|
||||||
|
4. 1 万行以上文档与装饰节流未做(§10.8);当前实测覆盖到 3000 行。
|
||||||
|
|
||||||
|
### 12.5 P3 加固记录(2026-09-11)
|
||||||
|
|
||||||
|
#### 12.5.1 修掉的两个真实缺陷
|
||||||
|
|
||||||
|
| # | 现象 | 根因 | 修复 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| P3-1 | 长文档中**未解析区间显示裸 Markdown**(3000 行文档实测:文档开头以外全部是 `**粗体**`、`## 标题` 原文) | lezer 是惰性增量解析:`EditorState` 建好后 `syntaxTree(state)` 只覆盖前 3061 字符(15.5 万字符文档),越界区间采集不到任何 spec;且后台解析推进时事务没有 doc/selection 变化,ViewPlugin 不会重算 | ① 新增 `visibleSyntaxTree()`:用 `ensureSyntaxTree(state, upto, 20ms)` 在有限预算内补齐可见区间;② ViewPlugin 增加「语法树对象换代」触发条件(`syntaxTree(startState) !== syntaxTree(state)` → 重建),后台解析推进后装饰自动补齐 |
|
||||||
|
| P3-2 | `Cmd+A` 全选在长文档下会构建**全文档**装饰;reveal 判定是 O(装饰数×区间数) 线性扫描 | ① 采集范围把「选区跨越的每一行」都算进去了;② `applyReveal` 对每个 spec 线性扫全部展开区间 | ① 采集范围改为「可见区间 + 选区**端点**行」(CM 也只渲染端点块);② `applyReveal` 先合并区间再二分(E14/E15) |
|
||||||
|
|
||||||
|
两项均补了回归:`visibleSyntaxTree` 补解析断言、越界不越界断言、光标行装饰断言、二分/线性等价断言、
|
||||||
|
最坏选区(Cmd+A)构建耗时基准。
|
||||||
|
|
||||||
|
#### 12.5.2 性能实测结果
|
||||||
|
|
||||||
|
**环境**:本机 macOS arm64 / Chromium(内置浏览器,~144Hz 刷新);vitest 组运行于 happy-dom(Node 22)。
|
||||||
|
**负载**:3000 行 / 3004 行、15.5–15.6 万字符的混合文档(每 7 行一个 h2、每 5 行一个含粗体/行内代码/链接的列表项、
|
||||||
|
每 11 行一个引用,其余为含强调/斜体/行内代码/链接的正文)。
|
||||||
|
|
||||||
|
| 指标 | 方法 | 结果 | 判定 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 视口窗口(100 行)装饰采集 | `collectSpecs` ×40 取中位数(vitest) | 中位数 **0.26ms**,p95 0.83ms | ✅ ≪ 16ms(§7.4 目标) |
|
||||||
|
| 全篇作为单一可见区间(最坏采集) | `collectSpecs` 全文档 ×10 取中位数 | 中位数 **5.0ms**,最大 12.1ms | ✅ < 16ms |
|
||||||
|
| 最坏选区(Cmd+A 整篇选中 + 聚焦) | `buildDecorations` ×10 取中位数 | 中位数 **0.98ms**,最大 1.73ms | ✅ < 16ms |
|
||||||
|
| 整篇全量解析(冷启动一次性) | `visibleSyntaxTree(tree=null → 全篇)` | **15.4ms**(覆盖 155327 字符) | 一次性成本,20ms 预算内 |
|
||||||
|
| 真实编辑器输入(文末普通段落,连打 60 字) | `execCommand` 同步返回耗时 ×60 取中位数 | 中位数 **1.5ms**,p95 1.8ms,最大 2.0ms | ✅ |
|
||||||
|
| 真实编辑器输入(中段列表项,装饰更密) | 同上 ×45 | 中位数 **2.2ms**,p95 2.5ms,最大 6.1ms | ✅ |
|
||||||
|
| 输入到下一帧(含浏览器排版/绘制/帧调度) | 同上一循环内 measure 到 rAF | 中位数 **13.9–18.3ms**、p95 22.8–28.5ms(≈1–3 帧 @60Hz 口径) | ✅ 无可感卡顿;该指标受帧调度与绘制影响,装饰层自身成本即上面的同步耗时 |
|
||||||
|
| 冷启动直跳文末(编辑器内部滚动,跳过未解析区) | rAF 帧间隔 + `longtask` 观察器 | 帧间隔最大 **8.6ms**(≈1 帧),**long task 数 0** | ✅ 无掉帧 |
|
||||||
|
| 跳转后文末渲染完整性 | 断言可见行装饰数与末行文本 | 34 行渲染 / 32 行带装饰,末行 `文末正文 粗 结束。` 无标记泄漏 | ✅ |
|
||||||
|
|
||||||
|
结论:**§7.4 的「3000 行单帧 < 16ms」目标达成**(装饰采集 0.26–5.0ms,真实输入同步耗时中位数 1.5–2.2ms)。
|
||||||
|
`ensureSyntaxTree` 的 20ms 预算在实测中甚至未被真正耗尽(整篇解析仅 15.4ms),因此不构成卡顿来源。
|
||||||
|
|
||||||
|
> 输入类指标为**编辑区吃满视口 + 内部滚动**落地后重测(见 §12.7);滚动类指标同样在内部滚动下重测。
|
||||||
|
|
||||||
|
#### 12.5.3 边界与选区回归(`tests/editor-boundaries.test.ts`,32 条)
|
||||||
|
|
||||||
|
| 组 | 覆盖 |
|
||||||
|
| --- | --- |
|
||||||
|
| 文首边界(5) | 标题 / 任务项 / 围栏 / 水平线开头的渲染与原子化;光标在文档起点退格为无操作且不损坏内容 |
|
||||||
|
| 文末边界(7) | 水平线 / 图片 / 围栏 / 任务结尾的 widget 渲染;整篇只有一条水平线;文末追加内容后装饰正确重建;全选清空不抛错 |
|
||||||
|
| 跨原子区间选区(5) | 全选替换、跨隐藏标记的选区替换、只选中可见内容时标记完整保留、连标记整体删除、跨行删除后装饰重建 |
|
||||||
|
| 装饰层不变量(6) | 原子区间不跨行(CM 硬性约束)/ 有序不重叠 / 隐藏标记必在原子区间内 / 强调·行内代码·链接的内容区间绝不被原子化 / reveal 契约(进入行→原子消失,离开→恢复)/ 失焦整篇渲染 |
|
||||||
|
| 长文档惰性解析(5) | `syntaxTree` 首覆盖仅 3061 字符的事实、`visibleSyntaxTree` 补齐、预算受限时越界保持源码态、装饰不越过已解析边界、光标行在视口外也被装饰 |
|
||||||
|
| 性能基准(6) | 上表前五项 + 最坏选区(Cmd+A)构建成本 |
|
||||||
|
|
||||||
|
交互面(真实浏览器)另验证:文首连续退格不损坏内容;`Cmd+A` + 退格清空 3000 行文档后 `Cmd+Z` 完整恢复;
|
||||||
|
远距离滚动后光标所在块仍以渲染态出现(P3-2 修复点)。
|
||||||
|
|
||||||
|
### 12.6 人工验收门槛:真机输入法回归(**未通过不得发布**)
|
||||||
|
|
||||||
|
自动化无法产生真实 IME 合成事件,冻结逻辑(`view.composing` 为真时仅映射、不重建装饰)虽已实现且
|
||||||
|
通过了合成 `compositionstart/…/compositionend` 冒烟,但**未经过真实输入法验证**,因此列为
|
||||||
|
**阻塞发布的人工验收项**,与 `docs/acceptance.md` 的 M5 门槛一致。
|
||||||
|
|
||||||
|
**执行要求**:在真实浏览器 + 真实输入法(至少覆盖「系统拼音」与「搜狗拼音」各一遍)中逐项走查:
|
||||||
|
|
||||||
|
| # | 用例 | 期望 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| I1 | 在空行用拼音连续输入中文(含候选词上下屏、翻页) | 文字正确上屏,无重复/丢字/乱序,装饰不闪断 |
|
||||||
|
| I2 | 在已有 `**粗体**` 行内、光标位于标记中间开始组字 | 该行保持展开态,组字过程中 DOM 不被替换,无「视觉消失」 |
|
||||||
|
| I3 | 在行首/行尾紧邻隐藏标记处组字 | 不吞前文、不把标记一起吃掉(对照 CM issue #1684/#1688) |
|
||||||
|
| I4 | 中文标点(,。、;:「」()——)连续输入 | 标点正确、不触发误解析 |
|
||||||
|
| I5 | 组字过程中移动光标 / 点击其他行 | 不出现错位、重复上屏或崩溃 |
|
||||||
|
| I6 | 列表项、引用、标题行内组字 | 行级 reveal 生效,标记不被误删 |
|
||||||
|
| I7 | 中文输入后撤销/重做(Cmd+Z / Shift+Cmd+Z) | 按「一次组字一次撤销」的直觉工作 |
|
||||||
|
| I8 | 3000 行文档中组字 | 无卡顿(输入同步耗时已在 §12.5 实测为亚毫秒级) |
|
||||||
|
|
||||||
|
**签字**:执行人 / 日期 / 浏览器与输入法版本 / 结果(通过 / 问题单链接)—— 未填写即视为未通过。
|
||||||
|
当前状态:**未执行**(2026-09-11,开发环境无法进行真实 IME 走查)。
|
||||||
|
|
||||||
|
### 12.7 编辑区高度(2026-09-11,用户要求)
|
||||||
|
|
||||||
|
**改动**:编辑卡默认吃满视口剩余高度,多余的滚动交给编辑器自身(`.cm-scroller`),
|
||||||
|
页面不再随正文长度增长。此前 `.pn-ae-body-wrap` 只有 `min-height: 420px`、高度随内容撑开,
|
||||||
|
导致两个极端:新建空文档时编辑区只有 420px;3000 行长文时页面高达 8.4 万像素、只能整页滚动。
|
||||||
|
|
||||||
|
| 位置 | 改动 |
|
||||||
|
| --- | --- |
|
||||||
|
| `index.css` `.pn-ae-grid` | `height: calc(100vh - 110px)`(topbar 62 + content padding 24×2)、`min-height: 520px`;≤1023px 用 `calc(100vh - 102px)`;≤900px(堆叠布局)回落 `height: auto` |
|
||||||
|
| `index.css` `.pn-ae-main` / `.ant-card-body` | 拉成纵向 flex,把剩余高度让给编辑区 |
|
||||||
|
| `index.css` `.pn-ae-body-wrap` | `flex: 1 1 auto; min-height: 280px; overflow: hidden` |
|
||||||
|
| `Editor.tsx` | CodeMirror 内联 `minHeight` 420 → 240(允许在矮视口下收缩,不再撑破容器) |
|
||||||
|
|
||||||
|
**实测**(1280×720 视口):
|
||||||
|
|
||||||
|
| 场景 | 结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| 新建空文档 | 编辑卡 **610px**(= 100vh−110),编辑区 **379px**;页面 scrollHeight **721** ≈ 一屏,页面不滚动 |
|
||||||
|
| 3000 行长文 | 编辑卡仍 610px,`.cm-scroller` clientHeight 379 / scrollHeight **85087**(`overflow: auto`)→ 编辑器内部滚动;页面 scrollHeight 保持 **721**,`window.scrollY` 恒为 0 |
|
||||||
|
| 内部滚动到中段 | 渲染行(约 1520–1532)全部按渲染态呈现;光标所在块(文档第 1 行)在滚动 4.2 万像素后仍带 `cm-md-h2` 装饰(P3-2 修复点在内部滚动布局下依然成立) |
|
||||||
|
| 窄屏 480×800(≤900px 断点) | 单列堆叠、`height: auto`,编辑区 280px(min-height 兜底),页面正常滚动 |
|
||||||
|
|
||||||
|
**未纳入本次改动**:编辑区最大宽度(当前随卡片宽度全宽)、粘性工具栏、全屏/专注模式。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录 A 依赖与 API 核实(2026-09-10,实测已装版本)
|
||||||
|
|
||||||
|
| 依赖 | 版本 | 用到的 API | 状态 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `@codemirror/view` | 6.43.11 | `Decoration.replace/mark/widget/line`、`EditorView.atomicRanges`、`view.composing`、`EditorView.decorations` | 已核实 |
|
||||||
|
| `@codemirror/state` | 6.7.4 | `StateField`、`Compartment` | 已核实 |
|
||||||
|
| `@codemirror/language` | 6.12.4 | `syntaxTree`、`ensureSyntaxTree`、`foldable` | 已核实 |
|
||||||
|
| `@codemirror/lang-markdown` | 6.5.2 | `markdownLanguage`(GFM 语言)、`GFM` | 已核实 |
|
||||||
|
| `@lezer/markdown` | — | `Table` / `Task` / `Strikethrough` 节点 | 已核实 |
|
||||||
|
|
||||||
|
**结论:本次改造零新增 npm 依赖。**
|
||||||
|
|
||||||
|
## 附录 B 参考资料
|
||||||
|
|
||||||
|
- Typora:[Markdown Reference](https://support.typora.io/Markdown-Reference/)("expand around cursor" 官方描述)、[DOM 证据 typora-issues#6629](https://github.com/typora/typora-issues/issues/6629)、性能 [#6542](https://github.com/typora/typora-issues/issues/6542) / [#6551](https://github.com/typora/typora-issues/issues/6551)、IME [#6554](https://github.com/typora/typora-issues/issues/6554)
|
||||||
|
- Obsidian:[Decorations API](https://docs.obsidian.md/Plugins/Editor/Decorations)、[Views and editing mode](https://help.obsidian.md/Editing+and+formatting/Views+and+editing+mode)
|
||||||
|
- CodeMirror 6:[Decoration example](https://codemirror.net/examples/decoration/)、[Reference](https://codemirror.net/docs/ref/)、相关 issue:#1654 / #1650 / #1684 / #1688 / #1658 / #1406 / #979 / #575 / #625
|
||||||
|
- 对照编辑器:[Vditor](https://github.com/Vanessa219/vditor)、[HyperMD](https://github.com/laobubu/HyperMD)、[Milkdown](https://milkdown.dev)、[Tiptap Markdown](https://tiptap.dev/docs/editor/markdown)、[Toast UI Editor](https://github.com/nhn/tui.editor)、[ByteMD](https://github.com/bytedance/byteMD)
|
||||||
|
- 项目内:`docs/design.md` §8.2 / §8.1、`docs/decisions.md` D5 / D26、`docs/review-round1.md` 第 18 项
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# 《Pure Note v1.1 实现》审评报告(第 2 轮)
|
||||||
|
|
||||||
|
> 审评对象:docs/design.md v1.1 的完整实现(Go 后端 + React 前端 + 构建/部署链),含二进制/子命令改名(pure-note → pn)后的全量代码
|
||||||
|
> 审评日期:2026-09-08
|
||||||
|
> 审评方法:三路并行代码评审(store/数据层、httpapi/中间件、前端),关键发现全部人工复核源码与文档(decisions.md D6 等)确认后成稿
|
||||||
|
> 基线:docs/review-round1.md(v1.0 设计评审)——其 P0/P1 修复要求已在 v1.1 设计中落地,本轮核对实现是否兑现
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、总体结论
|
||||||
|
|
||||||
|
**无 P0。** 默认配置下未发现远程可利用的越权或注入漏洞:SQL 全部参数化、可见性矩阵(列表/详情/标签/RSS/sitemap/图片/meta 注入)落实到位、CSRF 双保险(token + Origin/Referer 严格模式,覆盖 login/logout)、`__Host-` Cookie、PHC 口令哈希与会话摘要、meta 注入经 html/template 转义——round1 的 4 个 P0 均已在实现层封死。
|
||||||
|
|
||||||
|
但存在 **5 个 P1 功能缺陷**:其中 1 个导致前端功能完全不可用却被测试漏过(图片上传)、1 个邻接导航查询逻辑写反、1 个文档承诺(D6)与实现矛盾(升级/回滚场景备份不可用)。另有 10 项 P2 加固项。
|
||||||
|
|
||||||
|
| 级别 | 定义 | 数量 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| P0 | 远程可利用的越权/注入/私密内容泄露 | **0** |
|
||||||
|
| P1 | 功能缺陷/契约破坏/文档承诺与实现矛盾 | **5** |
|
||||||
|
| P2 | 加固与健壮性 | **10**(表内合并条目) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、发现汇总
|
||||||
|
|
||||||
|
| # | 级别 | 位置 | 问题一句话 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | P1 | internal/store/notes.go:188-210 | 上一篇/下一篇邻接查询 tie-break 写反且忽略 pinned,同秒更新漏链/错链,无测试覆盖 |
|
||||||
|
| 2 | P1 | web/src/lib/api.ts:39-41 | api() 对 FormData 强制 `Content-Type: application/json`,前端图片上传完全不可用 |
|
||||||
|
| 3 | P1 | internal/httpapi/server.go:102 | `/api/admin/*` 与 `/api/me` 缺 `Cache-Control: no-store`(中间件注释宣称已覆盖) |
|
||||||
|
| 4 | P1 | web/src/pages/AdminEdit.tsx:113 | 自动保存成功后 invalidate 连详情查询一起失效,refetch 回滚正在输入的内容并抑制下一轮自动保存 |
|
||||||
|
| 5 | P1 | cmd/pn/main.go:241/316/340 | 维护子命令总是迁移且带守卫、`--allow-newer` 被解析但忽略,与 D6 矛盾;回滚场景连备份都无法执行 |
|
||||||
|
| 6 | P2 | store/maint.go:36-98 | GC SELECT 与 DELETE 之间竞态:恢复/新引用的活数据会被物理删除 |
|
||||||
|
| 7 | P2 | store/maint.go:109-119 | 备份先以默认 umask 创建后 chmod 0600,私有内容短暂暴露窗口 |
|
||||||
|
| 8 | P2 | auth/auth.go:57-68 | VerifyPassword 不校验 PHC 参数上下界,畸形哈希可 panic/OOM |
|
||||||
|
| 9 | P2 | httpapi/public.go:161-174 | 图片数据行竞态缺失时 404 携带 public immutable 一年 |
|
||||||
|
| 10 | P2 | httpapi/feed.go:52-75 | 无公开笔记时 lastBuildDate 输出公元 1 年(非法 RSS 日期) |
|
||||||
|
| 11 | P2 | middleware/ratelimit.go:41 | sameOrigin 只比 host 不比 scheme |
|
||||||
|
| 12 | P2 | httpapi/admin.go:344-349 | image.Decode 无尺寸上限,<5MB 解压炸弹可 OOM 整站 |
|
||||||
|
| 13 | P2 | store/settings.go:64-67 | Sscanf("%d") 部分解析("10abc"→10),脏 page_size 被静默采用 |
|
||||||
|
| 14 | P2 | docs(design:567/590/628、acceptance:53、decisions D6/D28) | 改名后残留 5 处 `serve` 旧词;D6 内容与实现矛盾(见 P1-5) |
|
||||||
|
| 15 | P2 | web/src 多处 | Home 硬编码 page_size 使站点设置失效;auth.tsx 登录竞态覆盖 CSRF;401 无全局跳转;公共缓存失效缺失等 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、P1 详细说明
|
||||||
|
|
||||||
|
### P1-1 上一篇/下一篇邻接查询 tie-break 写反(store/notes.go:188-210)
|
||||||
|
|
||||||
|
**问题**:公开列表序为 `ORDER BY pinned DESC, updated_at DESC, id DESC`(notes.go:169)。邻接语义应在 (updated_at, id) 字典序上取紧邻项,但:
|
||||||
|
|
||||||
|
- prev(列表中更早一篇)写 `(updated_at > ? OR (updated_at = ? AND id < ?)) ORDER BY updated_at ASC, id DESC`——`id` 比较方向与列表序(id DESC)相反;同秒组内 `id DESC` 取到的是组内顶端而非紧邻项;
|
||||||
|
- next 同理(`id > ?` + `id ASC`)。
|
||||||
|
|
||||||
|
**后果**:同一秒有两篇以上笔记更新/创建(个人站点连续保存极常见,updated_at 为秒级粒度)时 prev/next 漏链或错链;且查询完全不参与 pinned 排序,置顶笔记附近跳错条目。调用方 public.go:103-104 用 `_` 吞掉错误,静默失效。该函数**无任何测试覆盖**(已确认)。
|
||||||
|
|
||||||
|
**修复**:prev 改为 `(updated_at > ? OR (updated_at = ? AND id > ?)) ORDER BY updated_at ASC, id ASC LIMIT 1`;next 改为 `(updated_at < ? OR (updated_at = ? AND id < ?)) ORDER BY updated_at DESC, id DESC LIMIT 1`;补表驱动测试(含同秒多篇、置顶参与排序的语义确认,必要时把 pinned 纳入邻接定义)。
|
||||||
|
|
||||||
|
### P1-2 前端图片上传完全不可用(web/src/lib/api.ts:39-41)
|
||||||
|
|
||||||
|
**问题**:`api()` 对所有带 body 的请求无条件补 `Content-Type: application/json`。Editor.tsx:22 的 `uploadImage` 用 FormData(注释明确「让浏览器自动设置 Content-Type(含 boundary)」),被此逻辑覆盖为 application/json → 后端 `ParseMultipartForm` 失败 → 400,粘贴/拖拽上传功能不可用。
|
||||||
|
|
||||||
|
**为什么测试全绿**:Go 集成测试直连 API 手工构造正确 multipart 头;验收浏览器走查未覆盖上传动作;前端无该路径的测试。
|
||||||
|
|
||||||
|
**修复**:`if (options.body && !(options.body instanceof FormData) && !headers.has('Content-Type'))`;补一个前端测试或至少把上传动作纳入浏览器走查清单。
|
||||||
|
|
||||||
|
### P1-3 /api/admin/* 与 /api/me 缺 no-store(server.go:102、public.go:21-28)
|
||||||
|
|
||||||
|
**问题**:middleware.go:129-136 注释宣称 NoStore「为 /api/admin/* 与 /api/auth/* 响应统一附加」,实际接线仅 `/api/auth/`(server.go:86)挂了 NoStore,`mux.Handle("/api/admin/", s.requireAdmin(adminMux))`(server.go:102)没有。`/api/me`(已认证时返回 csrf_token)同样无。
|
||||||
|
|
||||||
|
**后果**:登出后经 bfcache/历史回退(共用设备场景)可回看管理数据(笔记全文、回收站列表、settings、CSRF token);与设计 §9.2 目标直接冲突。
|
||||||
|
|
||||||
|
**修复**:`mux.Handle("/api/admin/", middleware.NoStore(s.requireAdmin(adminMux)))`;`/api/me` 已认证分支响应前补 `Cache-Control: no-store`(或对 /api/me 整路由统一 no-store)。
|
||||||
|
|
||||||
|
### P1-4 自动保存竞态回滚用户输入(AdminEdit.tsx:108-116、62-76)
|
||||||
|
|
||||||
|
**问题**:保存成功 `qc.invalidateQueries({ queryKey: ['admin'] })` 前缀匹配连当前编辑中的详情查询(key 形如 `['admin','note',id]`)一起失效 → refetch 返回服务端快照 → `useEffect([existing])` 执行 `setForm(...)` 把保存期间用户继续输入的内容回滚,并把 `dirtyRef.current = false`,抑制下一轮自动保存。每次自动保存都经历一次「输入被吞」窗口。
|
||||||
|
|
||||||
|
**修复**:onSuccess 仅 invalidate 列表键(如 `['admin','notes']`)与回收站键,不失效当前详情;或 refetch 落地时比较 `updated_at`/内容未变才允许重置 form。
|
||||||
|
|
||||||
|
### P1-5 维护子命令与 D6 矛盾:迁移与守卫始终执行、--allow-newer 被忽略(cmd/pn/main.go:241/316/340)
|
||||||
|
|
||||||
|
**问题**:decisions.md D6 承诺「backup/gc 不执行迁移、不做 user_version 守卫(纯数据操作)」,但实现中 passwd/backup/gc 一律 `store.Open(cfg.DBPath(), false)`,而 `store.Open` 无条件 `migrate()`(store.go:105)且 `false` 关闭 `--allow-newer`。`config.ParseMaint` 解析了 `--allow-newer` 却从未使用。
|
||||||
|
|
||||||
|
**后果**:
|
||||||
|
1. 用户升级到更高 schema 后回滚旧二进制,**`pn backup` 直接被守卫拒绝**——与「升级 SOP 第一步先备份」(review-round1 P1-13 修复)的语义冲突;
|
||||||
|
2. 错误 `--data-dir` 会静默创建并迁移出一个新库(维护命令不再是无副作用的纯数据操作);
|
||||||
|
3. 传入 `--allow-newer` 无任何效果且无提示。
|
||||||
|
|
||||||
|
**修复**:按 D6 拆分「打开 + 迁移(serve/init)」与「仅数据操作打开(passwd/backup/gc,跳过迁移与守卫)」,或至少让三个子命令真正透传 `cfg.AllowNewer`;同步修订 D6/代码注释,消除文档与实现二选一的漂移。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、P2 简述
|
||||||
|
|
||||||
|
| # | 位置 | 修复建议 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 6 | store/maint.go:36-98 | GC commit 阶段 DELETE 带原条件复查:笔记 `WHERE id=? AND deleted_at < ?`,图片 `DELETE ... WHERE id=? AND NOT EXISTS(SELECT 1 FROM image_refs WHERE image_id=?)`(或整体单事务)——防 SELECT 与 DELETE 之间恢复/新引用导致活数据被物理删除 |
|
||||||
|
| 7 | store/maint.go:109-119 | 备份文件以 0600 创建(先 `os.OpenFile(O_CREATE|O_EXCL, 0600)` 占位或临时目录生成后 rename),消除「先以 umask 创建后 chmod」的暴露窗口与存在性竞态 |
|
||||||
|
| 8 | auth/auth.go:57-68 | 解析 PHC 后校验 `1 ≤ t ≤ 10`、`1 ≤ p ≤ 8`、`m ≤ 1<<20`(KiB)、`len(want) == 32`,不合法直接返回 false——防 t/p=0 触发 argon2 panic、m 巨大 OOM(库被篡改场景) |
|
||||||
|
| 9 | httpapi/public.go:161-174 | 缓存头移到 `GetImageData` 成功之后设置;数据行缺失的 404 不带 public immutable(防恢复后仍被缓存 404 一年) |
|
||||||
|
| 10 | httpapi/feed.go:52-75 | 无公开笔记时 lastBuildDate 用当前时间或省略字段,不输出 `Mon, 01 Jan 0001` |
|
||||||
|
| 11 | middleware/ratelimit.go:41 | `sameOrigin` 同时比较 scheme(或至少把 http/https 视为不同源);与 HSTS 形成双层防护 |
|
||||||
|
| 12 | httpapi/admin.go:344-349 | 解码校验前先 `image.DecodeConfig` 校验宽高/像素总数上限(如 ≤8192×8192 或总像素 ≤2^26),防 <5MB 高压缩图解压炸弹 OOM |
|
||||||
|
| 13 | store/settings.go:64-67 | `Sscanf("%d")` 后校验「串已完整消费」(如 `%d%c` 探尾或 strconv.Atoi 全文),拒 "10abc" 类脏值 |
|
||||||
|
| 14 | docs 多处 | 改名收尾:design.md:567/590/628、acceptance.md:53 的 `serve` → `start`;decisions.md D6/D28 同步(D28 的 `serve` 改 `start`;D6 待 P1-5 修复后按新行为重写) |
|
||||||
|
| 15 | web/src 多处 | Home.tsx:10 改用 `/api/site` 的 page_size;auth.tsx:26-45 给 refresh 加请求序号/登录态变化判定,丢弃迟到匿名响应(StrictMode 双挂载放大窗口);api.ts 401 回调接入 AuthContext 触发跳转;写操作成功后同步 invalidate `['notes']`/`['tags']`/`['note']` 公共键;TagPage 补 error 分支;上传路径加前端测试 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、核实通过(维持不动)
|
||||||
|
|
||||||
|
1. **可见性单一可信点**:列表/标签/RSS/sitemap 统一走 `PublicNoteFilter`(store/notes.go:152),详情/图片/meta 出口 status + deleted_at 双层裁决,私有与不存在统一 404 防枚举,id 越界/负数均 404;
|
||||||
|
2. **SQL 注入面**:全部 `?` 参数化;仅 `VACUUM INTO` 路径经 `escapeSQLString`(引号加倍,SQLite 语义正确)与内部常量 PRAGMA 两处拼接,均无注入面;
|
||||||
|
3. **认证与 CSRF**:`__Host-` + Secure + HttpOnly + Lax、登录重建会话防固定、Origin/Referer 严格模式(缺头即拒)覆盖 login/logout、token 仅内存、改密校验旧口令且会话保持;
|
||||||
|
4. **口令与令牌**:Argon2id PHC 串(参数随哈希走)、会话库存 SHA-256 摘要、常量时间比较;
|
||||||
|
5. **上传防线**:声明类型白名单 → 魔数(SVG 拒绝)→ PNG/JPEG/GIF 再解码 → ≤5MB 哨兵 → sha256 去重;
|
||||||
|
6. **输出转义**:meta 注入经 html/template、JSON 默认 HTML 转义、RSS 经 encoding/xml + goldmark(非 unsafe) + bluemonday 双层清洗;
|
||||||
|
7. **迁移与并发**:user_version 上界守卫、仅追加式、DDL 与版本号同事务幂等;WAL + busy_timeout(5000) + synchronous(NORMAL) + foreign_keys(1) + `SetMaxOpenConns(1)`;
|
||||||
|
8. **构建/部署链**:Vite `base:'/'` + sync-assets + 指纹缓存 + 冒烟脚本(19 项断言)全链路打通;
|
||||||
|
9. **重命名**:pure-note → pn 的代码/脚本/deploy/README 残留已清理干净,本轮仅剩 docs 中 5 处 `serve` 旧词(P2-14);
|
||||||
|
10. **测试资产**:可见性矩阵、CSRF、限流、上传、回收站/gc、slug、设置白名单、meta 转义、迁移守卫均有自动化用例;`go vet`/`go test`/vitest/`make smoke` 全绿。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、结论
|
||||||
|
|
||||||
|
v1.1 实现的骨架与安全设计经受住了第二轮对抗性评审,round1 的全部 P0 修复要求已在实现层兑现,无新增 P0。需优先处理的是 5 个 P1:其中 P1-1(邻接导航)与 P1-2(图片上传)是用户可直接感知的功能性缺陷,P1-3(no-store)与 P1-5(维护命令契约)是安全/运维承诺的缺口,P1-4(自动保存竞态)影响核心编辑体验。P1 修复后建议随本轮把 P2 清单中低成本项(8/9/10/13/14)一并落地,并补上 P1-1 的邻接查询测试。
|
||||||
+14
-2
@@ -40,7 +40,15 @@ func HashPassword(password string) (string, error) {
|
|||||||
), nil
|
), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// VerifyPassword 按 PHC 串内参数重派生并常量时间比较。任何解析失败均返回 false。
|
// PHC 参数上下界(防畸形哈希触发 argon2 panic 或 OOM,评审 round2 P2-8):
|
||||||
|
// t/p 过小使 argon2.IDKey panic,m 过大直接耗尽内存。
|
||||||
|
const (
|
||||||
|
maxArgonTime = 10
|
||||||
|
maxArgonThreads = 8
|
||||||
|
maxArgonMemory = 1 << 20 // KiB(1 GiB),远大于现行 19456
|
||||||
|
)
|
||||||
|
|
||||||
|
// VerifyPassword 按 PHC 串内参数重派生并常量时间比较。任何解析失败或参数越界均返回 false。
|
||||||
func VerifyPassword(encoded, password string) bool {
|
func VerifyPassword(encoded, password string) bool {
|
||||||
parts := strings.Split(encoded, "$")
|
parts := strings.Split(encoded, "$")
|
||||||
// ["", "argon2id", "v=19", "m=..,t=..,p=..", salt, hash]
|
// ["", "argon2id", "v=19", "m=..,t=..,p=..", salt, hash]
|
||||||
@@ -57,12 +65,16 @@ func VerifyPassword(encoded, password string) bool {
|
|||||||
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil {
|
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if t < 1 || t > maxArgonTime || p < 1 || p > maxArgonThreads ||
|
||||||
|
m < 8 || m > maxArgonMemory {
|
||||||
|
return false
|
||||||
|
}
|
||||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
want, err := base64.RawStdEncoding.DecodeString(parts[5])
|
want, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||||
if err != nil {
|
if err != nil || len(want) != KeyLen {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
got := argon2.IDKey([]byte(password), salt, t, m, p, uint32(len(want)))
|
got := argon2.IDKey([]byte(password), salt, t, m, p, uint32(len(want)))
|
||||||
|
|||||||
@@ -31,6 +31,26 @@ func TestPasswordHashRoundtrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestVerifyPasswordMalformedParams 畸形 PHC 参数安全返回 false:
|
||||||
|
// t/p=0 会使 argon2.IDKey panic、m 巨大会 OOM(评审 round2 P2-8)。
|
||||||
|
func TestVerifyPasswordMalformedParams(t *testing.T) {
|
||||||
|
salt := "AAAAAAAAAAAAAAAAAAAAAA" // 16 字节 base64(raw std)
|
||||||
|
hash := "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" // 32 字节
|
||||||
|
bad := []string{
|
||||||
|
"$argon2id$v=19$m=19456,t=0,p=1$" + salt + "$" + hash,
|
||||||
|
"$argon2id$v=19$m=19456,t=2,p=0$" + salt + "$" + hash,
|
||||||
|
"$argon2id$v=19$m=999999999,t=2,p=1$" + salt + "$" + hash,
|
||||||
|
"$argon2id$v=19$m=19456,t=999,p=1$" + salt + "$" + hash,
|
||||||
|
"$argon2id$v=19$m=19456,t=2,p=99$" + salt + "$" + hash,
|
||||||
|
"$argon2id$v=19$m=19456,t=2,p=1$" + salt + "$AAAA", // 哈希长度异常
|
||||||
|
}
|
||||||
|
for _, h := range bad {
|
||||||
|
if VerifyPassword(h, "x") {
|
||||||
|
t.Errorf("畸形参数不应通过: %s", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTokenAndStrength(t *testing.T) {
|
func TestTokenAndStrength(t *testing.T) {
|
||||||
tok, err := NewToken()
|
tok, err := NewToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ func (c *Config) DBPath() string { return c.DataDir + "/pn.db" }
|
|||||||
|
|
||||||
func addCommonFlags(fs *flag.FlagSet, c *Config) {
|
func addCommonFlags(fs *flag.FlagSet, c *Config) {
|
||||||
fs.StringVar(&c.DataDir, "data-dir", "./data", "数据目录(SQLite 数据库所在)")
|
fs.StringVar(&c.DataDir, "data-dir", "./data", "数据目录(SQLite 数据库所在)")
|
||||||
fs.BoolVar(&c.AllowNewer, "allow-newer", false, "允许在更新的数据库 schema 版本上运行(跳过版本上界守卫)")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func addServeFlags(fs *flag.FlagSet, c *Config) {
|
func addServeFlags(fs *flag.FlagSet, c *Config) {
|
||||||
@@ -34,6 +33,7 @@ func addServeFlags(fs *flag.FlagSet, c *Config) {
|
|||||||
fs.StringVar(&c.LogFormat, "log-format", "text", "日志格式(text|json)")
|
fs.StringVar(&c.LogFormat, "log-format", "text", "日志格式(text|json)")
|
||||||
fs.BoolVar(&c.BehindProxy, "behind-proxy", false, "位于可信反向代理之后(取 X-Forwarded-For 最右条目作为客户端 IP)")
|
fs.BoolVar(&c.BehindProxy, "behind-proxy", false, "位于可信反向代理之后(取 X-Forwarded-For 最右条目作为客户端 IP)")
|
||||||
fs.BoolVar(&c.Dev, "dev", false, "开发模式:允许非 Secure Cookie,仅允许监听 loopback 地址")
|
fs.BoolVar(&c.Dev, "dev", false, "开发模式:允许非 Secure Cookie,仅允许监听 loopback 地址")
|
||||||
|
fs.BoolVar(&c.AllowNewer, "allow-newer", false, "允许在更新的数据库 schema 版本上运行(跳过版本上界守卫)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseStart 解析 `pn start` 参数。
|
// ParseStart 解析 `pn start` 参数。
|
||||||
@@ -55,13 +55,15 @@ func ParseInit(args []string) (*Config, error) {
|
|||||||
c := &Config{}
|
c := &Config{}
|
||||||
fs := flag.NewFlagSet("init", flag.ContinueOnError)
|
fs := flag.NewFlagSet("init", flag.ContinueOnError)
|
||||||
addCommonFlags(fs, c)
|
addCommonFlags(fs, c)
|
||||||
|
fs.BoolVar(&c.AllowNewer, "allow-newer", false, "允许在更新的数据库 schema 版本上运行(跳过版本上界守卫)")
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseMaint 解析 `pn backup` / `pn gc` / `pn passwd` 参数(均只需数据目录)。
|
// ParseMaint 解析 `pn backup` / `pn gc` / `pn passwd` 参数(仅数据目录;
|
||||||
|
// 维护命令经 store.OpenData 打开,不迁移、无版本守卫,故无 --allow-newer,D6)。
|
||||||
// 返回 flag 解析后的剩余位置参数(如 backup 的输出路径)。
|
// 返回 flag 解析后的剩余位置参数(如 backup 的输出路径)。
|
||||||
func ParseMaint(cmd string, args []string) (*Config, []string, error) {
|
func ParseMaint(cmd string, args []string) (*Config, []string, error) {
|
||||||
c := &Config{}
|
c := &Config{}
|
||||||
|
|||||||
+84
-23
@@ -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)
|
||||||
@@ -274,6 +295,10 @@ func (s *Server) handleAdminTrashRestore(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
// ---- 图片上传(§7.4)----
|
// ---- 图片上传(§7.4)----
|
||||||
|
|
||||||
|
// maxImagePixels 解码像素总数上限(1<<25 ≈ 8K 分辨率 7680×4320 ≈ 3.3×10⁷),
|
||||||
|
// 防高压缩比小体积图片解码后内存放大(解压炸弹)。
|
||||||
|
const maxImagePixels = 1 << 25
|
||||||
|
|
||||||
var allowedUploadTypes = map[string]string{
|
var allowedUploadTypes = map[string]string{
|
||||||
"image/png": ".png",
|
"image/png": ".png",
|
||||||
"image/jpeg": ".jpg",
|
"image/jpeg": ".jpg",
|
||||||
@@ -340,8 +365,19 @@ func (s *Server) handleAdminImageUpload(w http.ResponseWriter, r *http.Request)
|
|||||||
writeError(w, http.StatusUnsupportedMediaType, "unsupported_media", "文件内容不是受支持的图片(魔数校验失败,SVG 一律拒绝)")
|
writeError(w, http.StatusUnsupportedMediaType, "unsupported_media", "文件内容不是受支持的图片(魔数校验失败,SVG 一律拒绝)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 解码校验(PNG/JPEG/GIF;WebP 由魔数保证)——拦截截断/伪造的图片流
|
// 解码校验(PNG/JPEG/GIF;WebP 由魔数保证)——拦截截断/伪造的图片流。
|
||||||
|
// 先 DecodeConfig 限制像素总数:防 ≤5MB 高压缩比图片解码后撑爆内存
|
||||||
|
// (解压炸弹 OOM,评审 round2 P2-12)。
|
||||||
if magicMime != "image/webp" {
|
if magicMime != "image/webp" {
|
||||||
|
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusUnsupportedMediaType, "unsupported_media", "图片解码失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cfg.Width <= 0 || cfg.Height <= 0 || int64(cfg.Width)*int64(cfg.Height) > maxImagePixels {
|
||||||
|
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "图片像素总数超过上限")
|
||||||
|
return
|
||||||
|
}
|
||||||
if _, _, err := image.Decode(bytes.NewReader(data)); err != nil {
|
if _, _, err := image.Decode(bytes.NewReader(data)); err != nil {
|
||||||
writeError(w, http.StatusUnsupportedMediaType, "unsupported_media", "图片解码失败")
|
writeError(w, http.StatusUnsupportedMediaType, "unsupported_media", "图片解码失败")
|
||||||
return
|
return
|
||||||
@@ -381,6 +417,8 @@ type settingsDTO struct {
|
|||||||
SiteTitle *string `json:"site_title"`
|
SiteTitle *string `json:"site_title"`
|
||||||
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"`
|
||||||
|
SiteLogo *string `json:"site_logo"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleAdminSettingsGet GET /api/admin/settings:白名单三键;永不序列化 admin_password_hash。
|
// handleAdminSettingsGet GET /api/admin/settings:白名单三键;永不序列化 admin_password_hash。
|
||||||
@@ -399,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)")
|
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 {
|
||||||
@@ -424,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 {
|
||||||
@@ -433,6 +471,29 @@ func (s *Server) handleAdminSettingsPut(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if dto.BeianNo != nil {
|
||||||
|
v := strings.TrimSpace(*dto.BeianNo)
|
||||||
|
if len([]rune(v)) > 200 {
|
||||||
|
writeError(w, http.StatusBadRequest, "bad_request", "备案号 ≤200 字符")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.st.SetSetting(store.KeyBeianNo, v); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
|
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()
|
||||||
|
|||||||
@@ -284,20 +284,43 @@ 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}`), 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)
|
||||||
}
|
}
|
||||||
|
// beian_no 经 /api/site 可读且生效
|
||||||
|
_, body = e.get(e.client(), "/api/site")
|
||||||
|
if !strings.Contains(string(body), `"beian_no":"京公网安备12345678901号"`) {
|
||||||
|
t.Errorf("beian_no 设置应生效: %s", body)
|
||||||
|
}
|
||||||
|
// beian_no 可清空(空串 = 不展示)
|
||||||
|
resp, _ = e.do(admin, http.MethodPut, "/api/admin/settings",
|
||||||
|
[]byte(`{"beian_no":""}`), e.adminHeaders())
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
t.Errorf("beian_no 清空应 200: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
_, body = e.get(e.client(), "/api/site")
|
||||||
|
if !strings.Contains(string(body), `"beian_no":""`) {
|
||||||
|
t.Errorf("beian_no 应可清空: %s", body)
|
||||||
|
}
|
||||||
|
// beian_no 过长拒绝
|
||||||
|
resp, _ = e.do(admin, http.MethodPut, "/api/admin/settings",
|
||||||
|
[]byte(`{"beian_no":"`+strings.Repeat("备", 201)+`"}`), e.adminHeaders())
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("beian_no 超长应 400: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
// 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 自动转义)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +65,11 @@ func (s *Server) handleRSS(w http.ResponseWriter, r *http.Request) {
|
|||||||
Description: html,
|
Description: html,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// 无公开笔记时省略 lastBuildDate(零值 Format 会产出公元 1 年的非法日期)
|
||||||
|
lastBuildStr := ""
|
||||||
|
if !lastBuild.IsZero() {
|
||||||
|
lastBuildStr = lastBuild.Format(time.RFC1123Z)
|
||||||
|
}
|
||||||
feed := rssFeed{
|
feed := rssFeed{
|
||||||
Version: "2.0",
|
Version: "2.0",
|
||||||
Channel: rssChannel{
|
Channel: rssChannel{
|
||||||
@@ -72,7 +77,7 @@ func (s *Server) handleRSS(w http.ResponseWriter, r *http.Request) {
|
|||||||
Link: base + "/",
|
Link: base + "/",
|
||||||
Description: ss.SiteDesc,
|
Description: ss.SiteDesc,
|
||||||
Language: "zh-CN",
|
Language: "zh-CN",
|
||||||
LastBuild: lastBuild.Format(time.RFC1123Z),
|
LastBuild: lastBuildStr,
|
||||||
Items: items,
|
Items: items,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -87,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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestNoStoreHeaders 认证与管理端点响应禁缓存(§9.2,评审 round2 P1-3):
|
||||||
|
// 防登出后 bfcache/历史回退回看管理数据与 CSRF token。
|
||||||
|
func TestNoStoreHeaders(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
|
||||||
|
// 匿名 /api/me(响应随会话态变化)
|
||||||
|
resp, _ := e.get(e.client(), "/api/me")
|
||||||
|
if cc := resp.Header.Get("Cache-Control"); cc != "no-store" {
|
||||||
|
t.Errorf("匿名 /api/me 期望 Cache-Control: no-store,实际 %q", cc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录后 /api/me(含 csrf_token)与 /api/admin/notes
|
||||||
|
c := e.loginAdmin()
|
||||||
|
resp, _ = e.get(c, "/api/me")
|
||||||
|
if cc := resp.Header.Get("Cache-Control"); cc != "no-store" {
|
||||||
|
t.Errorf("已认证 /api/me 期望 Cache-Control: no-store,实际 %q", cc)
|
||||||
|
}
|
||||||
|
resp, _ = e.get(c, "/api/admin/notes")
|
||||||
|
if cc := resp.Header.Get("Cache-Control"); cc != "no-store" {
|
||||||
|
t.Errorf("/api/admin/notes 期望 Cache-Control: no-store,实际 %q", cc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// /api/auth/* 维持 no-store
|
||||||
|
resp, _ = e.do(c, "POST", "/api/auth/logout", nil, nil)
|
||||||
|
if cc := resp.Header.Get("Cache-Control"); cc != "no-store" {
|
||||||
|
t.Errorf("/api/auth/logout 期望 Cache-Control: no-store,实际 %q", cc)
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
-21
@@ -18,7 +18,9 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleMe GET /api/me:匿名 {authenticated:false};已认证 {authenticated:true, csrf_token}。
|
// handleMe GET /api/me:匿名 {authenticated:false};已认证 {authenticated:true, csrf_token}。
|
||||||
|
// 响应随会话态变化且含 CSRF token → no-store。
|
||||||
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
if sess, ok := s.sessionFrom(r); ok {
|
if sess, ok := s.sessionFrom(r); ok {
|
||||||
s.maybeRotate(w, sess)
|
s.maybeRotate(w, sess)
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"authenticated": true, "csrf_token": sess.CSRFToken})
|
writeJSON(w, http.StatusOK, map[string]any{"authenticated": true, "csrf_token": sess.CSRFToken})
|
||||||
@@ -38,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)。
|
||||||
@@ -68,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{
|
||||||
@@ -100,15 +105,26 @@ func (s *Server) handlePublicNote(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
prevSlug, prevTitle, _ := s.st.AdjacentPublicNote(note, "prev")
|
prevSlug, prevTitle, err := s.st.AdjacentPublicNote(note, "prev")
|
||||||
nextSlug, nextTitle, _ := s.st.AdjacentPublicNote(note, "next")
|
if err != nil {
|
||||||
|
s.log.Error("查询上一篇失败", "err", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nextSlug, nextTitle, err := s.st.AdjacentPublicNote(note, "next")
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("查询下一篇失败", "err", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"id": note.ID, "slug": note.Slug, "title": note.Title,
|
"id": note.ID, "slug": note.Slug, "title": note.Title,
|
||||||
"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),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +149,8 @@ func (s *Server) handleTags(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleImage GET /api/images/{id}:并集可见性;未授权与不存在统一 404;
|
// handleImage GET /api/images/{id}:并集可见性;未授权与不存在统一 404;
|
||||||
// 缓存头按可见性分流(§7.4)。
|
// 缓存头按可见性分流(§7.4)。缓存头在数据读取成功后才设置,
|
||||||
|
// 错误路径不携带公开缓存指令(防 404 被 CDN 缓存一年)。
|
||||||
func (s *Server) handleImage(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleImage(w http.ResponseWriter, r *http.Request) {
|
||||||
idStr := r.PathValue("id")
|
idStr := r.PathValue("id")
|
||||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||||
@@ -156,10 +173,18 @@ func (s *Server) handleImage(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusNotFound, "not_found", "图片不存在")
|
writeError(w, http.StatusNotFound, "not_found", "图片不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// 先取数据:数据行竞态缺失时返回的 404 不携带任何缓存指令
|
||||||
|
data, err := s.st.GetImageData(id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "图片不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if public {
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
|
} else {
|
||||||
// 非公开图:每次请求重新判定,禁止缓存
|
// 非公开图:每次请求重新判定,禁止缓存
|
||||||
w.Header().Set("Cache-Control", "private, no-store")
|
w.Header().Set("Cache-Control", "private, no-store")
|
||||||
} else {
|
|
||||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", img.MIME)
|
w.Header().Set("Content-Type", img.MIME)
|
||||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
@@ -168,11 +193,6 @@ func (s *Server) handleImage(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNotModified)
|
w.WriteHeader(http.StatusNotModified)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data, err := s.st.GetImageData(id)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusNotFound, "not_found", "图片不存在")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||||
_, _ = w.Write(data)
|
_, _ = w.Write(data)
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-13
@@ -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,13 +93,15 @@ 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)
|
||||||
adminMux.HandleFunc("GET /api/admin/settings", s.handleAdminSettingsGet)
|
adminMux.HandleFunc("GET /api/admin/settings", s.handleAdminSettingsGet)
|
||||||
adminMux.Handle("PUT /api/admin/settings", middleware.MaxBytes(maxAuthBody)(http.HandlerFunc(s.handleAdminSettingsPut)))
|
adminMux.Handle("PUT /api/admin/settings", middleware.MaxBytes(maxAuthBody)(http.HandlerFunc(s.handleAdminSettingsPut)))
|
||||||
adminMux.Handle("POST /api/admin/password", middleware.MaxBytes(maxAuthBody)(http.HandlerFunc(s.handleAdminPassword)))
|
adminMux.Handle("POST /api/admin/password", middleware.MaxBytes(maxAuthBody)(http.HandlerFunc(s.handleAdminPassword)))
|
||||||
mux.Handle("/api/admin/", s.requireAdmin(adminMux))
|
// NoStore:管理数据(笔记全文/回收站/settings)禁缓存,防登出后 bfcache 回看(§9.2)
|
||||||
|
mux.Handle("/api/admin/", middleware.NoStore(s.requireAdmin(adminMux)))
|
||||||
|
|
||||||
// ---- SPA(兜底,须最后注册)----
|
// ---- SPA(兜底,须最后注册)----
|
||||||
if ui != nil {
|
if ui != nil {
|
||||||
@@ -290,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 == "" {
|
||||||
@@ -316,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 == "" {
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"hash/crc32"
|
||||||
|
"image"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pngWithDims 构造仅含文件签名 + IHDR 的 PNG 头(DecodeConfig 只解析头部即可
|
||||||
|
// 得到宽高,无需真实像素数据;CRC 按 PNG 规范计算)。
|
||||||
|
func pngWithDims(w, h uint32) []byte {
|
||||||
|
ihdr := make([]byte, 13)
|
||||||
|
binary.BigEndian.PutUint32(ihdr[0:4], w)
|
||||||
|
binary.BigEndian.PutUint32(ihdr[4:8], h)
|
||||||
|
// 8bit / truecolor / deflate / adaptive / no interlace
|
||||||
|
ihdr[8], ihdr[9], ihdr[10], ihdr[11], ihdr[12] = 8, 2, 0, 0, 0
|
||||||
|
|
||||||
|
chunk := bytes.NewBuffer(nil)
|
||||||
|
_ = binary.Write(chunk, binary.BigEndian, uint32(len(ihdr)))
|
||||||
|
chunk.WriteString("IHDR")
|
||||||
|
chunk.Write(ihdr)
|
||||||
|
_ = binary.Write(chunk, binary.BigEndian, crc32.ChecksumIEEE(chunk.Bytes()[4:]))
|
||||||
|
|
||||||
|
out := bytes.NewBuffer(nil)
|
||||||
|
out.Write([]byte{0x89, 'P', 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A})
|
||||||
|
out.Write(chunk.Bytes())
|
||||||
|
return out.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUploadPixelBomb 解压炸弹防御(评审 round2 P2-12):小体积超大尺寸图片
|
||||||
|
// 在 DecodeConfig 阶段被 413 拒绝,不进入全量 Decode。
|
||||||
|
func TestUploadPixelBomb(t *testing.T) {
|
||||||
|
// 头部自证合法:DecodeConfig 可解析出宽高
|
||||||
|
bomb := pngWithDims(20000, 20000) // 4 亿像素 > 1<<25
|
||||||
|
if _, _, err := image.DecodeConfig(bytes.NewReader(bomb)); err != nil {
|
||||||
|
t.Fatalf("夹具应可解析出尺寸: %v", err)
|
||||||
|
}
|
||||||
|
// 正常小图不受影响
|
||||||
|
if _, _, err := image.DecodeConfig(bytes.NewReader(png1x1)); err != nil {
|
||||||
|
t.Fatalf("1x1 夹具应合法: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
e := newEnv(t)
|
||||||
|
c := e.loginAdmin()
|
||||||
|
// 手工 multipart(uploadPNG 辅助对非 201 会 Fatal)
|
||||||
|
var body bytes.Buffer
|
||||||
|
boundary := "bombboundary456"
|
||||||
|
body.WriteString("--" + boundary + "\r\n")
|
||||||
|
body.WriteString(`Content-Disposition: form-data; name="file"; filename="bomb.png"` + "\r\n")
|
||||||
|
body.WriteString("Content-Type: image/png\r\n\r\n")
|
||||||
|
body.Write(bomb)
|
||||||
|
body.WriteString("\r\n--" + boundary + "--\r\n")
|
||||||
|
req, err := http.NewRequest(http.MethodPost, e.ts.URL+"/api/admin/images", &body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "multipart/form-data; boundary="+boundary)
|
||||||
|
req.Header.Set("Origin", e.ts.URL)
|
||||||
|
req.Header.Set("X-CSRF-Token", e.csrf)
|
||||||
|
resp, err := c.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusRequestEntityTooLarge {
|
||||||
|
t.Fatalf("超大像素图片应 413,实际 %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"},
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSameOriginSchemeSameOrigin 校验 host 与 scheme 双比对(评审 round2 P2-11):
|
||||||
|
// 仅 host 相同、scheme 不同的 Origin(如对 https 站点的 http://host)必须拒绝。
|
||||||
|
func TestSameOriginScheme(t *testing.T) {
|
||||||
|
mk := func(origin string, tlsConn bool, behindProxy bool, xfp string) *http.Request {
|
||||||
|
r := httptest.NewRequest(http.MethodPost, "http://example.com/api/auth/login", nil)
|
||||||
|
if origin != "" {
|
||||||
|
r.Header.Set("Origin", origin)
|
||||||
|
}
|
||||||
|
if tlsConn {
|
||||||
|
r.TLS = &tls.ConnectionState{}
|
||||||
|
}
|
||||||
|
if behindProxy {
|
||||||
|
r = WithBehindProxy(r, true)
|
||||||
|
}
|
||||||
|
if xfp != "" {
|
||||||
|
r.Header.Set("X-Forwarded-Proto", xfp)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
r *http.Request
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"http 直连 + http Origin", mk("http://example.com", false, false, ""), true},
|
||||||
|
{"http 直连 + https Origin(scheme 不匹配)", mk("https://example.com", false, false, ""), false},
|
||||||
|
{"https 直连 + https Origin", mk("https://example.com", true, false, ""), true},
|
||||||
|
{"https 直连 + http Origin(scheme 不匹配)", mk("http://example.com", true, false, ""), false},
|
||||||
|
{"反代 XFP=https + https Origin", mk("https://example.com", false, true, "https"), true},
|
||||||
|
{"反代 XFP=https + http Origin(scheme 不匹配)", mk("http://example.com", false, true, "https"), false},
|
||||||
|
{"反代未带 XFP 视为 http + http Origin", mk("http://example.com", false, true, ""), true},
|
||||||
|
{"host 不同", mk("http://evil.com", false, false, ""), false},
|
||||||
|
{"缺 Origin/Referer", mk("", false, false, ""), false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := sameOrigin(tc.r); got != tc.want {
|
||||||
|
t.Errorf("%s: 期望 %v,实际 %v", tc.name, tc.want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,8 @@ func OriginCheck(next http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// sameOrigin 校验 Origin(或 Referer)的 host 与请求 Host 一致。
|
// sameOrigin 校验 Origin(或 Referer)与请求同源:host 一致且 scheme 与请求
|
||||||
|
// 实际 scheme 一致(TLS 直连为 https;可信反代后取 X-Forwarded-Proto,评审 round2 P2-11)。
|
||||||
func sameOrigin(r *http.Request) bool {
|
func sameOrigin(r *http.Request) bool {
|
||||||
raw := r.Header.Get("Origin")
|
raw := r.Header.Get("Origin")
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
@@ -35,10 +36,16 @@ func sameOrigin(r *http.Request) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
u, err := url.Parse(raw)
|
u, err := url.Parse(raw)
|
||||||
if err != nil || u.Host == "" {
|
if err != nil || u.Host == "" || u.Scheme == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return strings.EqualFold(u.Host, r.Host)
|
scheme := "http"
|
||||||
|
if r.TLS != nil {
|
||||||
|
scheme = "https"
|
||||||
|
} else if trueBehindProxy(r) && strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
return strings.EqualFold(u.Scheme, scheme) && strings.EqualFold(u.Host, r.Host)
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeErr(w http.ResponseWriter, status int, code, msg string) {
|
func writeErr(w http.ResponseWriter, status int, code, msg string) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-6
@@ -3,6 +3,7 @@ package store
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -53,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
|
||||||
}
|
}
|
||||||
@@ -86,15 +89,30 @@ func (s *Store) GC(now time.Time, dryRun bool) (*GCReport, error) {
|
|||||||
return rep, nil
|
return rep, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 条件复查删除(防 TOCTOU:SELECT 与 DELETE 之间管理员恢复笔记/图片被
|
||||||
|
// 重新引用时,不删活数据;评审 round2 P2-6)
|
||||||
for _, en := range rep.ExpiredNotes {
|
for _, en := range rep.ExpiredNotes {
|
||||||
if err := s.DeleteNoteForever(en.ID); err != nil {
|
res, err := s.db.Exec(
|
||||||
|
`DELETE FROM notes WHERE id=? AND deleted_at IS NOT NULL AND deleted_at < ?`,
|
||||||
|
en.ID, noteCutoff)
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("物理删除笔记 %d: %w", en.ID, err)
|
return nil, fmt.Errorf("物理删除笔记 %d: %w", en.ID, err)
|
||||||
}
|
}
|
||||||
|
if rows, _ := res.RowsAffected(); rows == 0 {
|
||||||
|
continue // 期间已被恢复,跳过
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, img := range rep.OrphanImages {
|
for _, img := range rep.OrphanImages {
|
||||||
if err := s.DeleteImage(img.ID); err != nil {
|
// 条件复查含 Logo 保护:SELECT 与 DELETE 之间 Logo 可能指向该图
|
||||||
|
res, err := s.db.Exec(
|
||||||
|
`DELETE FROM images WHERE id=? AND NOT EXISTS (SELECT 1 FROM image_refs WHERE image_id=?) AND id<>?`,
|
||||||
|
img.ID, img.ID, logoID)
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("删除孤儿图片 %d: %w", img.ID, err)
|
return nil, fmt.Errorf("删除孤儿图片 %d: %w", img.ID, err)
|
||||||
}
|
}
|
||||||
|
if rows, _ := res.RowsAffected(); rows == 0 {
|
||||||
|
continue // 期间已被引用,跳过
|
||||||
|
}
|
||||||
}
|
}
|
||||||
n, err := s.DeleteExpiredSessions(now.Unix())
|
n, err := s.DeleteExpiredSessions(now.Unix())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -109,11 +127,17 @@ func (s *Store) Backup(destPath string) error {
|
|||||||
if _, err := os.Stat(destPath); err == nil {
|
if _, err := os.Stat(destPath); err == nil {
|
||||||
return fmt.Errorf("目标文件已存在: %s", destPath)
|
return fmt.Errorf("目标文件已存在: %s", destPath)
|
||||||
}
|
}
|
||||||
|
// 备份含全部私密内容:临时收紧 umask,确保 VACUUM INTO 创建即 0600
|
||||||
|
// (否则以默认 umask(常 0644)创建、事后 chmod 存在暴露窗口,评审 round2 P2-7)。
|
||||||
|
// backup 为 CLI 单线程进程,进程级 umask 无并发副作用。
|
||||||
|
old := syscall.Umask(0o077)
|
||||||
// VACUUM INTO 不接受参数绑定,路径经单引号转义(无参数化通道时的最小注入面)。
|
// VACUUM INTO 不接受参数绑定,路径经单引号转义(无参数化通道时的最小注入面)。
|
||||||
if _, err := s.db.Exec("VACUUM INTO " + escapeSQLString(destPath)); err != nil {
|
_, err := s.db.Exec("VACUUM INTO " + escapeSQLString(destPath))
|
||||||
|
syscall.Umask(old)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// 备份含全部私钥内容:强制 0600(§10.3)
|
// 双保险:显式收紧(幂等)
|
||||||
if err := os.Chmod(destPath, 0o600); err != nil {
|
if err := os.Chmod(destPath, 0o600); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-30
@@ -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)...)
|
||||||
@@ -183,23 +187,26 @@ func (s *Store) ListPublicNotes(page, pageSize int, tag string) ([]Note, int, er
|
|||||||
return out, total, rows.Err()
|
return out, total, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdjacentPublicNote 返回公开序列中与 n 相邻的上一篇/下一篇
|
// AdjacentPublicNote 返回公开列表中与 n 相邻的上一篇/下一篇。
|
||||||
// (按列表序 updated_at DESC, id DESC)。仅返回 slug 与 title。
|
// 列表序为 pinned DESC, updated_at DESC, id DESC(notes.go ListPublicNotes),
|
||||||
|
// prev = 列表中紧邻 n 之前的一篇(元组 (pinned, updated_at, id) 字典序更大侧的最小项),
|
||||||
|
// next = 紧邻之后的一篇(字典序更小侧的最大项)。仅返回 slug 与 title。
|
||||||
func (s *Store) AdjacentPublicNote(n *Note, dir string) (slug, title string, err error) {
|
func (s *Store) AdjacentPublicNote(n *Note, dir string) (slug, title string, err error) {
|
||||||
|
p := boolToInt(n.Pinned)
|
||||||
var q string
|
var q string
|
||||||
switch dir {
|
switch dir {
|
||||||
case "prev": // 列表中更早的一篇
|
case "prev": // 列表中位于 n 之前的一篇
|
||||||
q = `SELECT slug, title FROM notes WHERE ` + PublicNoteFilter + `
|
q = `SELECT slug, title FROM notes WHERE ` + PublicNoteFilter + `
|
||||||
AND (updated_at > ? OR (updated_at = ? AND id < ?))
|
AND (pinned > ? OR (pinned = ? AND (updated_at > ? OR (updated_at = ? AND id > ?))))
|
||||||
ORDER BY updated_at ASC, id DESC LIMIT 1`
|
ORDER BY pinned ASC, updated_at ASC, id ASC LIMIT 1`
|
||||||
case "next": // 列表中更新的一篇
|
case "next": // 列表中位于 n 之后的一篇
|
||||||
q = `SELECT slug, title FROM notes WHERE ` + PublicNoteFilter + `
|
q = `SELECT slug, title FROM notes WHERE ` + PublicNoteFilter + `
|
||||||
AND (updated_at < ? OR (updated_at = ? AND id > ?))
|
AND (pinned < ? OR (pinned = ? AND (updated_at < ? OR (updated_at = ? AND id < ?))))
|
||||||
ORDER BY updated_at DESC, id ASC LIMIT 1`
|
ORDER BY pinned DESC, updated_at DESC, id DESC LIMIT 1`
|
||||||
default:
|
default:
|
||||||
return "", "", fmt.Errorf("dir 必须为 prev|next")
|
return "", "", fmt.Errorf("dir 必须为 prev|next")
|
||||||
}
|
}
|
||||||
row := s.db.QueryRow(q, n.UpdatedAt, n.UpdatedAt, n.ID)
|
row := s.db.QueryRow(q, p, p, n.UpdatedAt, n.UpdatedAt, n.ID)
|
||||||
if err := row.Scan(&slug, &title); err != nil {
|
if err := row.Scan(&slug, &title); err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return "", "", nil
|
return "", "", nil
|
||||||
@@ -231,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)
|
||||||
@@ -239,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)
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ package store
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"strconv"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Settings 键白名单(§7.1 SettingsDTO 同源;admin_password_hash 永不进入 API 响应)。
|
// Settings 键白名单(§7.1 SettingsDTO 同源;admin_password_hash 永不进入 API 响应)。
|
||||||
@@ -12,6 +13,8 @@ const (
|
|||||||
KeySiteTitle = "site_title"
|
KeySiteTitle = "site_title"
|
||||||
KeySiteDesc = "site_desc"
|
KeySiteDesc = "site_desc"
|
||||||
KeyPageSize = "page_size"
|
KeyPageSize = "page_size"
|
||||||
|
KeyBeianNo = "beian_no"
|
||||||
|
KeySiteLogo = "site_logo"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetSetting 读取单个设置。
|
// GetSetting 读取单个设置。
|
||||||
@@ -36,6 +39,8 @@ type SiteSettings struct {
|
|||||||
SiteTitle string `json:"site_title"`
|
SiteTitle string `json:"site_title"`
|
||||||
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"`
|
||||||
|
SiteLogo string `json:"site_logo"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Defaults,未初始化时兜底。
|
// Defaults,未初始化时兜底。
|
||||||
@@ -43,11 +48,21 @@ const (
|
|||||||
DefaultSiteTitle = "Pure Note"
|
DefaultSiteTitle = "Pure Note"
|
||||||
DefaultSiteDesc = ""
|
DefaultSiteDesc = ""
|
||||||
DefaultPageSize = 10
|
DefaultPageSize = 10
|
||||||
|
DefaultBeianNo = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetSiteSettings 读取站点设置(白名单三键,带默认值)。
|
// ValidPageSize 每页条数可选项(前端选择器同源:10/20/30/50)。
|
||||||
|
func ValidPageSize(n int) bool {
|
||||||
|
switch n {
|
||||||
|
case 10, 20, 30, 50:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSiteSettings 读取站点设置(白名单键,带默认值)。
|
||||||
func (s *Store) GetSiteSettings() (*SiteSettings, error) {
|
func (s *Store) GetSiteSettings() (*SiteSettings, error) {
|
||||||
ss := &SiteSettings{SiteTitle: DefaultSiteTitle, SiteDesc: DefaultSiteDesc, PageSize: DefaultPageSize}
|
ss := &SiteSettings{SiteTitle: DefaultSiteTitle, SiteDesc: DefaultSiteDesc, PageSize: DefaultPageSize, BeianNo: DefaultBeianNo}
|
||||||
if v, ok, err := s.GetSetting(KeySiteTitle); err != nil {
|
if v, ok, err := s.GetSetting(KeySiteTitle); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
} else if ok && v != "" {
|
} else if ok && v != "" {
|
||||||
@@ -61,10 +76,36 @@ 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 {
|
||||||
var n int
|
// strconv.Atoi 全文解析:拒绝 "10abc" 类部分解析的脏值(评审 round2 P2-13);
|
||||||
if _, err := fmt.Sscanf(v, "%d", &n); 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if v, ok, err := s.GetSetting(KeyBeianNo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if ok {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
+34
-10
@@ -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;
|
||||||
`,
|
`,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,8 +85,32 @@ type Store struct {
|
|||||||
db *sql.DB
|
db *sql.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open 打开数据库并执行迁移。dbPath 为文件绝对/相对路径。
|
// Open 打开数据库并执行迁移(start/init 用)。dbPath 为文件绝对/相对路径。
|
||||||
|
// allowNewer 跳过 user_version 上界守卫(§10.4)。
|
||||||
func Open(dbPath string, allowNewer bool) (*Store, error) {
|
func Open(dbPath string, allowNewer bool) (*Store, error) {
|
||||||
|
db, err := openDB(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := migrateDB(db, allowNewer); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Store{db: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenData 打开数据库仅做数据操作:**不迁移、不做版本守卫**
|
||||||
|
// (passwd/backup/gc 维护子命令,D6:在更新 schema 的库上备份/清理
|
||||||
|
// 不应被守卫阻断,也不应产生迁移副作用)。
|
||||||
|
func OpenData(dbPath string) (*Store, error) {
|
||||||
|
db, err := openDB(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Store{db: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func openDB(dbPath string) (*sql.DB, error) {
|
||||||
// SQLite URI 不接受相对路径
|
// SQLite URI 不接受相对路径
|
||||||
abs, err := filepath.Abs(dbPath)
|
abs, err := filepath.Abs(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -101,24 +130,19 @@ func Open(dbPath string, allowNewer bool) (*Store, error) {
|
|||||||
}
|
}
|
||||||
// 单写者串行化(§6.2)
|
// 单写者串行化(§6.2)
|
||||||
db.SetMaxOpenConns(1)
|
db.SetMaxOpenConns(1)
|
||||||
s := &Store{db: db}
|
return db, nil
|
||||||
if err := s.migrate(allowNewer); err != nil {
|
|
||||||
db.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return s, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) migrate(allowNewer bool) error {
|
func migrateDB(db *sql.DB, allowNewer bool) error {
|
||||||
var v int
|
var v int
|
||||||
if err := s.db.QueryRow("PRAGMA user_version").Scan(&v); err != nil {
|
if err := db.QueryRow("PRAGMA user_version").Scan(&v); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if v > MaxSchemaVersion && !allowNewer {
|
if v > MaxSchemaVersion && !allowNewer {
|
||||||
return fmt.Errorf("%w: 库版本 %d > 支持上限 %d", ErrSchemaNewer, v, MaxSchemaVersion)
|
return fmt.Errorf("%w: 库版本 %d > 支持上限 %d", ErrSchemaNewer, v, MaxSchemaVersion)
|
||||||
}
|
}
|
||||||
for i := v; i < MaxSchemaVersion; i++ {
|
for i := v; i < MaxSchemaVersion; i++ {
|
||||||
tx, err := s.db.Begin()
|
tx, err := db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,98 @@ func TestForeignKeysCascade(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestOpenDataNoMigrate 维护命令打开方式:不迁移、不做版本守卫(D6,评审 round2 P1-5)。
|
||||||
|
func TestOpenDataNoMigrate(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
dbPath := filepath.Join(dir, "pn.db")
|
||||||
|
|
||||||
|
// 全新路径:OpenData 不创建 schema
|
||||||
|
s, err := OpenData(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
v, err := s.SchemaVersion()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if v != 0 {
|
||||||
|
t.Fatalf("OpenData 不应执行迁移,user_version=%d", v)
|
||||||
|
}
|
||||||
|
var one int
|
||||||
|
if err := s.db.QueryRow("SELECT 1 FROM notes LIMIT 1").Scan(&one); err == nil {
|
||||||
|
t.Fatal("OpenData 不应创建表")
|
||||||
|
}
|
||||||
|
s.Close()
|
||||||
|
|
||||||
|
// 更高 schema 版本:OpenData 不被守卫拒绝(升级/回滚场景备份仍可用)
|
||||||
|
s2, err := Open(dbPath, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := s2.DB().Exec("PRAGMA user_version = 99"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s2.Close()
|
||||||
|
if _, err := Open(dbPath, false); !errors.Is(err, ErrSchemaNewer) {
|
||||||
|
t.Fatalf("Open 仍应被守卫拒绝,实际 %v", err)
|
||||||
|
}
|
||||||
|
s3, err := OpenData(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OpenData 不做版本守卫,应放行: %v", err)
|
||||||
|
}
|
||||||
|
s3.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAdjacentPublicNote 邻接查询:列表序 pinned DESC, updated_at DESC, id DESC
|
||||||
|
// 的紧邻语义(评审 round2 P1-1:同秒 tie-break 与 pinned 参与排序;仅统计公开)。
|
||||||
|
func TestAdjacentPublicNote(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
mk := func(slug string, at int64, pinned bool, status string) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := s.CreateNote(&Note{Slug: slug, Title: slug, Status: status,
|
||||||
|
Tags: []string{}, Pinned: pinned, CreatedAt: at, UpdatedAt: at}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mk("a", 100, false, "public") // id=1
|
||||||
|
mk("b", 100, false, "public") // id=2(与 a 同秒,列表中排在 a 之前)
|
||||||
|
mk("c", 80, false, "public") // id=3
|
||||||
|
mk("p", 50, true, "public") // id=4 置顶 → 列表首
|
||||||
|
mk("priv", 200, false, "private") // 更新的私有笔记:不得出现在邻接中
|
||||||
|
|
||||||
|
get := func(slug string) *Note {
|
||||||
|
n, err := s.GetNoteBySlug(slug)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
// 公开列表序:p > b > a > c(priv 被过滤)
|
||||||
|
cases := []struct{ slug, prev, next string }{
|
||||||
|
{"p", "", "b"}, // 置顶首篇
|
||||||
|
{"b", "p", "a"},
|
||||||
|
{"a", "b", "c"}, // a 与 b 同秒:prev 应取同秒中紧邻的 b(id 更大),而非跳过
|
||||||
|
{"c", "a", ""},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
prevSlug, _, err := s.AdjacentPublicNote(get(tc.slug), "prev")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s prev: %v", tc.slug, err)
|
||||||
|
}
|
||||||
|
nextSlug, _, err := s.AdjacentPublicNote(get(tc.slug), "next")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s next: %v", tc.slug, err)
|
||||||
|
}
|
||||||
|
if prevSlug != tc.prev || nextSlug != tc.next {
|
||||||
|
t.Errorf("%s: 期望 prev=%q next=%q,实际 prev=%q next=%q",
|
||||||
|
tc.slug, tc.prev, tc.next, prevSlug, nextSlug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, _, err := s.AdjacentPublicNote(get("a"), "bad"); err == nil {
|
||||||
|
t.Error("非法 dir 应返回错误")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func openTestStore(t *testing.T) *Store {
|
func openTestStore(t *testing.T) *Store {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
s, err := Open(filepath.Join(t.TempDir(), "test.db"), false)
|
s, err := Open(filepath.Join(t.TempDir(), "test.db"), false)
|
||||||
|
|||||||
@@ -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 静态资源服务。
|
||||||
|
|||||||
Executable
+52
@@ -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
@@ -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)。
|
||||||
|
|||||||
Generated
+1441
-32
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
@@ -24,6 +27,7 @@
|
|||||||
"remark-gfm": "^4.0.1"
|
"remark-gfm": "^4.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@codemirror/commands": "^6.11.0",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@testing-library/dom": "^10.4.0",
|
"@testing-library/dom": "^10.4.0",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
+49
-21
@@ -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="flex min-h-screen items-center justify-center text-zinc-500">加载中…</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,23 +55,26 @@ function RequireAuth({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<div className="flex min-h-screen items-center justify-center text-zinc-500">加载中…</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/:tag" element={<TagPage />} />
|
<Route path="/notes/:slug" element={<Note />} />
|
||||||
</Route>
|
<Route path="/tags" element={<TagPage />} />
|
||||||
<Route path="/admin/login" element={<Login />} />
|
<Route path="/tags/:tag" element={<TagPage />} />
|
||||||
<Route element={<RequireAuth><AdminLayout /></RequireAuth>}>
|
</Route>
|
||||||
<Route path="/admin" element={<AdminList />} />
|
<Route path="/admin/login" element={<Login />} />
|
||||||
<Route path="/admin/trash" element={<AdminTrash />} />
|
<Route element={<RequireAuth><AdminLayout /></RequireAuth>}>
|
||||||
<Route path="/admin/settings" element={<AdminSettings />} />
|
<Route path="/admin" element={<AdminList />} />
|
||||||
<Route path="/admin/notes/new" element={<AdminEdit />} />
|
<Route path="/admin/trash" element={<AdminTrash />} />
|
||||||
<Route path="/admin/notes/:id/edit" element={<AdminEdit />} />
|
<Route path="/admin/settings" element={<AdminSettings />} />
|
||||||
</Route>
|
<Route path="/admin/notes/new" element={<AdminEdit />} />
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="/admin/notes/:id/edit" element={<AdminEdit />} />
|
||||||
</Routes>
|
</Route>
|
||||||
</Suspense>
|
<Route path="*" element={<NotFound />} />
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
|
</AntdProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import CodeMirror from '@uiw/react-codemirror'
|
import CodeMirror from '@uiw/react-codemirror'
|
||||||
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
|
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
|
||||||
import { EditorView } from '@codemirror/view'
|
import { EditorView } from '@codemirror/view'
|
||||||
import { api, ApiError } from '../lib/api'
|
import { api, ApiError } from '../lib/api'
|
||||||
|
import { useTheme } from '../lib/theme'
|
||||||
|
import { livePreview, livePreviewCompartment } from '../editor/livePreview'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Editor(编辑态,§8.2):CodeMirror 6 源码编辑 + 图片粘贴/拖拽上传。
|
* Editor(编辑态,§8.2):CodeMirror 6 即时渲染(Typora 式)+ 图片粘贴/拖拽上传。
|
||||||
* 粘贴/拖拽 → POST /api/admin/images(≤5MB,魔数校验)→ 光标处插入
|
* 粘贴/拖拽 → POST /api/admin/images(≤5MB,魔数校验)→ 光标处插入
|
||||||
* ``。
|
* ``,插入后由装饰层立刻渲染为图片。
|
||||||
|
*
|
||||||
|
* sourceMode=true 时卸载全部装饰,退回纯源码编辑(逃生舱,方案 §4.7)。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface UploadResult {
|
interface UploadResult {
|
||||||
@@ -22,28 +26,51 @@ async function uploadImage(file: File): Promise<UploadResult> {
|
|||||||
return api<UploadResult>('/api/admin/images', { method: 'POST', body: form })
|
return api<UploadResult>('/api/admin/images', { method: 'POST', body: form })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDarkMode(mode: 'light' | 'dark' | 'system'): boolean {
|
||||||
|
if (mode === 'dark') return true
|
||||||
|
if (mode === 'light') return false
|
||||||
|
return typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
}
|
||||||
|
|
||||||
export default function Editor({
|
export default function Editor({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
onUploadStart,
|
onUploadStart,
|
||||||
onUploadEnd,
|
onUploadEnd,
|
||||||
|
sourceMode = false,
|
||||||
}: {
|
}: {
|
||||||
value: string
|
value: string
|
||||||
onChange: (v: string) => void
|
onChange: (v: string) => void
|
||||||
onUploadStart?: (name: string) => void
|
onUploadStart?: (name: string) => void
|
||||||
onUploadEnd?: (err: string | null) => void
|
onUploadEnd?: (err: string | null) => void
|
||||||
|
sourceMode?: boolean
|
||||||
}) {
|
}) {
|
||||||
const viewRef = useRef<{ view?: EditorView } | null>(null)
|
const viewRef = useRef<{ view?: EditorView } | null>(null)
|
||||||
const [dragOver, setDragOver] = useState(false)
|
const [dragOver, setDragOver] = useState(false)
|
||||||
|
const { mode } = useTheme()
|
||||||
|
const appliedSourceMode = useRef<boolean | null>(null)
|
||||||
|
|
||||||
const extensions = useMemo(
|
const extensions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
markdown({ base: markdownLanguage }),
|
markdown({ base: markdownLanguage }),
|
||||||
EditorView.lineWrapping,
|
EditorView.lineWrapping,
|
||||||
|
// 初始值即决定首次挂载是否带装饰;后续切换走 useEffect 的 reconfigure
|
||||||
|
livePreviewCompartment.of(sourceMode ? [] : livePreview()),
|
||||||
],
|
],
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 源码模式 O(1) 切换:整体挂载/卸载装饰扩展(方案 §4.7)
|
||||||
|
useEffect(() => {
|
||||||
|
const view = viewRef.current?.view
|
||||||
|
if (!view || appliedSourceMode.current === null || appliedSourceMode.current === sourceMode) return
|
||||||
|
appliedSourceMode.current = sourceMode
|
||||||
|
view.dispatch({
|
||||||
|
effects: livePreviewCompartment.reconfigure(sourceMode ? [] : livePreview()),
|
||||||
|
})
|
||||||
|
}, [sourceMode])
|
||||||
|
|
||||||
const insertAtCursor = useCallback(
|
const insertAtCursor = useCallback(
|
||||||
(text: string) => {
|
(text: string) => {
|
||||||
const wrapper = viewRef.current
|
const wrapper = viewRef.current
|
||||||
@@ -81,9 +108,8 @@ export default function Editor({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`relative h-full overflow-hidden rounded-lg border ${
|
className={[sourceMode ? '' : 'pn-cm-live', dragOver ? 'pn-cm-drag' : ''].filter(Boolean).join(' ') || undefined}
|
||||||
dragOver ? 'border-blue-500 bg-blue-50 dark:bg-blue-950' : 'border-zinc-200 dark:border-zinc-800'
|
style={{ position: 'relative', height: '100%', overflow: 'hidden', borderRadius: 'var(--radius-sm)' }}
|
||||||
}`}
|
|
||||||
onDragOver={(e) => {
|
onDragOver={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setDragOver(true)
|
setDragOver(true)
|
||||||
@@ -100,11 +126,12 @@ export default function Editor({
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
extensions={extensions}
|
extensions={extensions}
|
||||||
basicSetup={{ lineNumbers: true, foldGutter: false, highlightActiveLine: true }}
|
basicSetup={{ lineNumbers: true, foldGutter: false, highlightActiveLine: true }}
|
||||||
theme="light"
|
theme={isDarkMode(mode) ? 'dark' : 'light'}
|
||||||
height="100%"
|
height="100%"
|
||||||
style={{ height: '100%' }}
|
style={{ height: '100%', minHeight: 240 }}
|
||||||
onCreateEditor={(view) => {
|
onCreateEditor={(view) => {
|
||||||
viewRef.current = { view }
|
viewRef.current = { view }
|
||||||
|
appliedSourceMode.current = sourceMode
|
||||||
}}
|
}}
|
||||||
onPaste={(event: React.ClipboardEvent<HTMLDivElement>) => {
|
onPaste={(event: React.ClipboardEvent<HTMLDivElement>) => {
|
||||||
const files = event.clipboardData?.files
|
const files = event.clipboardData?.files
|
||||||
@@ -115,7 +142,7 @@ export default function Editor({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{dragOver && (
|
{dragOver && (
|
||||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-blue-500/10 text-sm font-medium text-blue-600">
|
<div className="pn-cm-drag-overlay">
|
||||||
松开以上传图片(≤5MB)
|
松开以上传图片(≤5MB)
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+303
-83
@@ -1,101 +1,321 @@
|
|||||||
import { Link, NavLink, Outlet } from 'react-router'
|
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router'
|
||||||
import { Moon, Sun, Monitor } from 'lucide-react'
|
import {
|
||||||
|
Moon, Sun, Monitor, ExternalLink, House, PenLine, MoreHorizontal, Tags, Trash2, Settings, LogOut,
|
||||||
|
} 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'
|
||||||
|
|
||||||
export function SiteHeader() {
|
/* ── 溢出菜单:antd Dropdown(外点关闭/Esc/焦点管理由 antd 承接) ── */
|
||||||
const { data } = useSiteTitle()
|
|
||||||
const { mode, setMode } = useTheme()
|
function MoreMenu({ triggerClassName, triggerLabel, items }: {
|
||||||
|
triggerClassName: string
|
||||||
|
triggerLabel: string
|
||||||
|
items: MenuProps['items']
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<header className="border-b border-zinc-200 dark:border-zinc-800">
|
// wrapper:与设计稿一致占一个 flex 槽位(display:none 触发器不产生 gap,
|
||||||
<div className="mx-auto flex max-w-3xl items-center justify-between px-4 py-4">
|
// 曾致桌面导航整体横移 4px)
|
||||||
<Link to="/" className="text-xl font-bold tracking-tight hover:opacity-80">
|
<div className="pn-moremenu">
|
||||||
{data?.site_title ?? 'Pure Note'}
|
<Dropdown trigger={['click']} placement="bottomRight" menu={{ items }}>
|
||||||
</Link>
|
<Button type="text" className={triggerClassName} aria-label={triggerLabel} icon={<MoreHorizontal size={15} />} />
|
||||||
<div className="flex items-center gap-1">
|
</Dropdown>
|
||||||
<ThemeButton mode={mode} setMode={setMode} />
|
</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 布局(博客系列设计稿) ─────────── */
|
||||||
|
|
||||||
|
function ThemeButton({ className = 'pn-site-navlink' }: { className?: string }) {
|
||||||
|
const { mode, setMode } = useTheme()
|
||||||
|
const next = mode === 'light' ? 'dark' : mode === 'dark' ? 'system' : 'light'
|
||||||
|
const title = `主题:${mode === 'light' ? '浅色' : mode === 'dark' ? '深色' : '跟随系统'}(点击切换)`
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
className={className}
|
||||||
|
title={title}
|
||||||
|
aria-label={title}
|
||||||
|
icon={mode === 'light' ? <Sun size={14} /> : mode === 'dark' ? <Moon size={14} /> : <Monitor size={14} />}
|
||||||
|
onClick={() => setMode(next)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BlogLayout() {
|
||||||
|
const { data } = useSiteTitle()
|
||||||
|
const title = data?.site_title ?? 'Pure Note'
|
||||||
|
const logo = data?.site_logo ?? ''
|
||||||
|
const location = useLocation()
|
||||||
|
return (
|
||||||
|
<div className="pn-site-page">
|
||||||
|
<header className="pn-site-header">
|
||||||
|
<div className="pn-site-header-inner">
|
||||||
|
<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="站点导航">
|
||||||
|
<NavLink to="/" end className="pn-site-navlink hide-sm">
|
||||||
|
<House size={14} />首页
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/tags" className="pn-site-navlink hide-sm">
|
||||||
|
<Tags size={14} />标签
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/admin" className="pn-site-navlink">
|
||||||
|
<ExternalLink size={14} />管理
|
||||||
|
</NavLink>
|
||||||
|
<ThemeButton />
|
||||||
|
{/* <640px 首页/标签收进溢出菜单 */}
|
||||||
|
<MoreMenu
|
||||||
|
triggerClassName="pn-site-navlink pn-site-menu-btn"
|
||||||
|
triggerLabel="更多导航"
|
||||||
|
items={[
|
||||||
|
moreNavItem('/', true, <House size={15} />, '首页', location.pathname === '/'),
|
||||||
|
moreNavItem('/tags', false, <Tags size={15} />, '标签', location.pathname.startsWith('/tags')),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="pn-site-main">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
<footer className="pn-site-footer">
|
||||||
|
<div className="pn-site-footer-inner">
|
||||||
|
<p className="pn-site-footer-line">
|
||||||
|
© {new Date().getFullYear()} {title} · Powered by{' '}
|
||||||
|
<a href="https://git.airnan.cn/wangairnan/pure-note" target="_blank" rel="noreferrer">Pure Note</a>
|
||||||
|
{' '}· <a href="/feed.xml">RSS</a>
|
||||||
|
</p>
|
||||||
|
{data?.beian_no && (
|
||||||
|
<a
|
||||||
|
className="pn-site-beian"
|
||||||
|
href="https://beian.mps.gov.cn/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<img src="/national_emblem.png" alt="" aria-hidden />
|
||||||
|
{data.beian_no}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 管理端: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 }) {
|
||||||
|
const { logout } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { mode, setMode } = useTheme()
|
||||||
|
const next = mode === 'light' ? 'dark' : mode === 'dark' ? 'system' : 'light'
|
||||||
|
const modeLabel = mode === 'light' ? '浅色' : mode === 'dark' ? '深色' : '跟随系统'
|
||||||
|
return (
|
||||||
|
<header className="pn-admin-topbar">
|
||||||
|
<h1>{title}</h1>
|
||||||
|
<div className="pn-admin-actions">
|
||||||
|
{children}
|
||||||
|
<AdminThemeButton />
|
||||||
|
{/* <768px 侧栏隐藏、topbar 空间有限:主题/返回博客/退出登录收进溢出菜单 */}
|
||||||
|
<MoreMenu
|
||||||
|
triggerClassName="pn-admin-iconbtn pn-admin-mobile-act"
|
||||||
|
triggerLabel="更多操作"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'theme',
|
||||||
|
onClick: () => setMode(next),
|
||||||
|
label: (
|
||||||
|
<>
|
||||||
|
{mode === 'light' ? <Sun size={15} /> : mode === 'dark' ? <Moon size={15} /> : <Monitor size={15} />}
|
||||||
|
主题:{modeLabel}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ThemeButton({
|
const adminNav = [
|
||||||
mode,
|
{ to: '/admin/notes/new', label: '新建笔记', icon: PenLine, end: false, match: 'prefix' as const },
|
||||||
setMode,
|
{ to: '/admin', label: '笔记管理', icon: House, end: true, match: 'exact' as const },
|
||||||
}: {
|
{ to: '/admin/trash', label: '回收站', icon: Trash2, end: true, match: 'exact' as const },
|
||||||
mode: 'light' | 'dark' | 'system'
|
{ to: '/admin/settings', label: '设置', icon: Settings, end: true, match: 'exact' as const },
|
||||||
setMode: (m: 'light' | 'dark' | 'system') => void
|
|
||||||
}) {
|
|
||||||
const next = mode === 'light' ? 'dark' : mode === 'dark' ? 'system' : 'light'
|
|
||||||
const title = `主题:${mode === 'light' ? '浅色' : mode === 'dark' ? '深色' : '跟随系统'}(点击切换)`
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
title={title}
|
|
||||||
onClick={() => setMode(next)}
|
|
||||||
className="rounded p-2 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900 dark:hover:bg-zinc-800 dark:hover:text-zinc-100"
|
|
||||||
>
|
|
||||||
{mode === 'light' ? <Sun size={16} /> : mode === 'dark' ? <Moon size={16} /> : <Monitor size={16} />}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SiteFooter() {
|
|
||||||
return (
|
|
||||||
<footer className="mt-16 border-t border-zinc-200 py-8 text-center text-xs text-zinc-400 dark:border-zinc-800">
|
|
||||||
<a href="/feed.xml" className="hover:text-zinc-600">RSS</a>
|
|
||||||
<span className="mx-2">·</span>
|
|
||||||
<a href="/sitemap.xml" className="hover:text-zinc-600">Sitemap</a>
|
|
||||||
</footer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BlogLayout() {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen">
|
|
||||||
<SiteHeader />
|
|
||||||
<main className="mx-auto max-w-3xl px-4 py-8">
|
|
||||||
<Outlet />
|
|
||||||
</main>
|
|
||||||
<SiteFooter />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const adminLinks = [
|
|
||||||
{ to: '/admin', label: '笔记', end: true },
|
|
||||||
{ to: '/admin/trash', label: '回收站', end: true },
|
|
||||||
{ to: '/admin/settings', label: '设置', end: true },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const adminMenuPathByKey: Record<string, string> = {
|
||||||
|
notes: '/admin',
|
||||||
|
new: '/admin/notes/new',
|
||||||
|
trash: '/admin/trash',
|
||||||
|
settings: '/admin/settings',
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminSidebar() {
|
||||||
|
const { logout } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
// 新建/编辑共用「新建笔记」高亮(设计稿:编辑页高亮新建入口)
|
||||||
|
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 (
|
||||||
|
<aside className="pn-admin-sidebar">
|
||||||
|
<Link to="/admin" className="pn-admin-brand">
|
||||||
|
<span className="pn-admin-brand-mark">P</span>
|
||||||
|
<span className="pn-admin-brand-copy">
|
||||||
|
<span className="pn-admin-eyebrow">PURE NOTE</span>
|
||||||
|
<strong>管理后台</strong>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Menu
|
||||||
|
mode="inline"
|
||||||
|
className="pn-admin-menu"
|
||||||
|
selectedKeys={selectedKey ? [selectedKey] : []}
|
||||||
|
onClick={({ key }) => navigate(adminMenuPathByKey[key])}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'g-content',
|
||||||
|
type: 'group',
|
||||||
|
label: '内容',
|
||||||
|
children: [
|
||||||
|
{ key: 'new', icon: <PenLine size={15} />, label: '新建笔记' },
|
||||||
|
{ key: 'notes', icon: <House size={15} />, label: '笔记管理' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'g-system',
|
||||||
|
type: 'group',
|
||||||
|
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-user">
|
||||||
|
<span className="pn-admin-avatar">管</span>
|
||||||
|
<span className="pn-admin-user-meta">
|
||||||
|
<span className="pn-admin-user-name">管理员</span>
|
||||||
|
</span>
|
||||||
|
<span className="pn-admin-user-actions">
|
||||||
|
<Tooltip title="返回博客" placement="bottom">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
className="pn-admin-iconbtn"
|
||||||
|
href="/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
aria-label="返回博客(新窗口打开)"
|
||||||
|
icon={<ExternalLink size={15} />}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="退出登录" placement="bottom">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
className="pn-admin-iconbtn"
|
||||||
|
aria-label="退出登录"
|
||||||
|
icon={<LogOut size={15} />}
|
||||||
|
onClick={() => void logout().then(() => navigate('/'))}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminTabbar() {
|
||||||
|
const location = useLocation()
|
||||||
|
const editing = location.pathname.startsWith('/admin/notes')
|
||||||
|
return (
|
||||||
|
<nav className="pn-admin-tabbar" aria-label="后台导航">
|
||||||
|
{adminNav.map(({ to, label, icon: Icon, match }) => {
|
||||||
|
const active = match === 'exact' ? location.pathname === to : editing
|
||||||
|
return (
|
||||||
|
<Link key={to} to={to} className={active ? 'is-active' : undefined} aria-current={active ? 'page' : undefined}>
|
||||||
|
<Icon size={18} /><span>{label === '笔记管理' ? '笔记' : label === '新建笔记' ? '新建' : label}</span>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function AdminLayout() {
|
export function AdminLayout() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="pn-admin-shell">
|
||||||
<SiteHeader />
|
<AdminSidebar />
|
||||||
<div className="mx-auto max-w-5xl px-4">
|
<div className="pn-admin-main">
|
||||||
<nav className="flex gap-1 border-b border-zinc-200 py-2 dark:border-zinc-800">
|
<Outlet />
|
||||||
{adminLinks.map((l) => (
|
|
||||||
<NavLink
|
|
||||||
key={l.to}
|
|
||||||
to={l.to}
|
|
||||||
end={l.end}
|
|
||||||
className={({ isActive }) =>
|
|
||||||
`rounded px-3 py-1.5 text-sm ${
|
|
||||||
isActive
|
|
||||||
? 'bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900'
|
|
||||||
: 'text-zinc-600 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-800'
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{l.label}
|
|
||||||
</NavLink>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
<main className="py-6">
|
|
||||||
<Outlet />
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
|
<AdminTabbar />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
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'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MarkdownViewer:浏览态渲染管线(§8.2),组件边界独立、可整体替换。
|
* MarkdownViewer:浏览态渲染管线(§8.2),组件边界独立、可整体替换。
|
||||||
|
* 排版样式由 index.css 的 .pn-note-body 提供(设计稿正文排版契约)。
|
||||||
*
|
*
|
||||||
* Markdown 原文
|
* Markdown 原文
|
||||||
* → remark-gfm(表格/任务列表/删除线/自动链接)
|
* → remark-gfm(表格/任务列表/删除线/自动链接)
|
||||||
@@ -16,7 +18,7 @@ import { sanitizePlugin } from '../lib/sanitize'
|
|||||||
*/
|
*/
|
||||||
function CodeBlock(props: React.HTMLAttributes<HTMLPreElement>) {
|
function CodeBlock(props: React.HTMLAttributes<HTMLPreElement>) {
|
||||||
return (
|
return (
|
||||||
<div className="group relative">
|
<div>
|
||||||
<pre {...props} />
|
<pre {...props} />
|
||||||
<CopyButton />
|
<CopyButton />
|
||||||
</div>
|
</div>
|
||||||
@@ -24,24 +26,35 @@ 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="absolute right-2 top-2 rounded border border-zinc-300 bg-white px-2 py-0.5 text-xs opacity-0 transition group-hover:opacity-100 dark:border-zinc-700 dark:bg-zinc-900"
|
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const MarkdownViewer = memo(function MarkdownViewer({ source }: { source: string }) {
|
const MarkdownViewer = memo(function MarkdownViewer({ source }: { source: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="prose prose-zinc max-w-none dark:prose-invert prose-pre:bg-zinc-100 dark:prose-pre:bg-zinc-900">
|
<div className="pn-note-body">
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
rehypePlugins={[sanitizePlugin, rehypeHighlight]}
|
rehypePlugins={[sanitizePlugin, rehypeHighlight]}
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
// Spec → CodeMirror 装饰集(方案 §4.2 / §4.3 / §4.4)。
|
||||||
|
//
|
||||||
|
// 与 livePreview.ts 分离:本模块只做「纯转换」,输入是 EditorState + 可见区间,
|
||||||
|
// 输出 DecorationSet / 原子区间集,便于单测(不需要真实 view)。
|
||||||
|
|
||||||
|
import { RangeSet } from '@codemirror/state'
|
||||||
|
import type { EditorState, Range } from '@codemirror/state'
|
||||||
|
import { ensureSyntaxTree, syntaxTree } from '@codemirror/language'
|
||||||
|
import type { Tree } from '@lezer/common'
|
||||||
|
import { Decoration } from '@codemirror/view'
|
||||||
|
import type { DecorationSet } from '@codemirror/view'
|
||||||
|
import { collectSpecs, type Spec } from './syntax'
|
||||||
|
import { activeLineRanges, applyReveal, selectionEndpointLines } from './reveal'
|
||||||
|
import {
|
||||||
|
BulletWidget,
|
||||||
|
FenceLangWidget,
|
||||||
|
HrWidget,
|
||||||
|
ImageWidget,
|
||||||
|
QuoteBarWidget,
|
||||||
|
TaskWidget,
|
||||||
|
} from './widgets'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片 src 白名单(方案 §6):仅允许站内图片接口与内联 data:image。
|
||||||
|
* 不匹配则整条 image spec 被丢弃 → 退回显示源码文本,不产出 <img>。
|
||||||
|
*/
|
||||||
|
export const IMAGE_SRC_ALLOW = /^(?:\/api\/images\/|data:image\/(?:png|jpe?g|gif|webp|avif);base64,)/i
|
||||||
|
|
||||||
|
export interface LiveDecorations {
|
||||||
|
decorations: DecorationSet
|
||||||
|
atomic: RangeSet<Decoration>
|
||||||
|
}
|
||||||
|
|
||||||
|
function specKey(s: Spec): string {
|
||||||
|
return [s.kind, s.from, s.to, 'line' in s ? s.line : '', 'cls' in s ? s.cls : ''].join('|')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 强制补解析可见区间所需时间的上限(毫秒)。 */
|
||||||
|
export const PARSE_BUDGET_MS = 20
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取一棵至少覆盖 `ranges` 末端的语法树。
|
||||||
|
*
|
||||||
|
* 关键点:lezer 是**惰性增量**解析 —— `syntaxTree(state)` 只给出已完成的部分,
|
||||||
|
* 长文档(实测 3000 行 ≈ 15 万字符时首次仅解析约 3000 字符)未覆盖的区间若直接
|
||||||
|
* 返回空装饰,用户会看到裸 Markdown。这里用 `ensureSyntaxTree` 在有限预算内
|
||||||
|
* 把可见区间补齐;预算内没补完就退回已有树,其余部分保持源码态并等解析推进后
|
||||||
|
* 由 `livePreview` 的「树对象变化」触发自动补装饰(不会永久缺失)。
|
||||||
|
*/
|
||||||
|
export function visibleSyntaxTree(
|
||||||
|
state: EditorState,
|
||||||
|
ranges: readonly { from: number; to: number }[],
|
||||||
|
timeoutMs = PARSE_BUDGET_MS,
|
||||||
|
): Tree {
|
||||||
|
let upto = 0
|
||||||
|
for (const r of ranges) if (r.to > upto) upto = r.to
|
||||||
|
if (upto <= 0) return syntaxTree(state)
|
||||||
|
return ensureSyntaxTree(state, upto, timeoutMs) ?? syntaxTree(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 采集可见区间内的语法 → 应用 reveal → 生成装饰集。
|
||||||
|
* 只遍历 visibleRanges(外加光标所在行),不整篇扫描(方案 §7.4)。
|
||||||
|
*
|
||||||
|
* 为什么额外加选区端点行:CodeMirror 会把选区所在的块单独渲染出来(哪怕它
|
||||||
|
* 在视口之外,用 `cm-gap` 占位)。若只装饰 visibleRanges,光标行在远距离滚动
|
||||||
|
* 后会以裸源码出现。补上这几行成本可忽略,却让「凡渲染出来的行都有装饰」成立。
|
||||||
|
* 注意只取**端点**行 —— 选区跨越多行时 CM 也只渲染端点块,全量取会让 Cmd+A
|
||||||
|
* 在长文档上退化成全文档装饰构建。
|
||||||
|
*
|
||||||
|
* @param reveal 编辑器是否处于聚焦态;未聚焦时整篇按渲染态呈现(决策 E11)
|
||||||
|
*/
|
||||||
|
export function buildDecorations(
|
||||||
|
state: EditorState,
|
||||||
|
visibleRanges: readonly { from: number; to: number }[],
|
||||||
|
reveal: boolean,
|
||||||
|
tree?: Tree,
|
||||||
|
): LiveDecorations {
|
||||||
|
const selectionLines = activeLineRanges(state)
|
||||||
|
const collectRanges: { from: number; to: number }[] = [...visibleRanges]
|
||||||
|
for (const line of selectionEndpointLines(state)) {
|
||||||
|
if (!collectRanges.some((r) => r.from <= line.from && r.to >= line.to)) collectRanges.push(line)
|
||||||
|
}
|
||||||
|
const syntax = tree ?? visibleSyntaxTree(state, collectRanges)
|
||||||
|
|
||||||
|
const raw: Spec[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (const range of collectRanges) {
|
||||||
|
// 语法树可能只覆盖文档前段:越界部分不采集(保持源码态,等解析推进后补齐)
|
||||||
|
const to = Math.min(range.to, syntax.length)
|
||||||
|
if (to <= range.from) continue
|
||||||
|
for (const spec of collectSpecs(syntax, state.doc, range.from, to)) {
|
||||||
|
const key = specKey(spec)
|
||||||
|
if (seen.has(key)) continue
|
||||||
|
seen.add(key)
|
||||||
|
raw.push(spec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const specs = reveal ? applyReveal(raw, selectionLines) : raw
|
||||||
|
|
||||||
|
const ranges: Range<Decoration>[] = []
|
||||||
|
const atomic: Range<Decoration>[] = []
|
||||||
|
|
||||||
|
for (const spec of specs) {
|
||||||
|
switch (spec.kind) {
|
||||||
|
case 'line':
|
||||||
|
ranges.push(Decoration.line({ class: spec.cls }).range(spec.from))
|
||||||
|
break
|
||||||
|
case 'mark':
|
||||||
|
ranges.push(Decoration.mark({ class: spec.cls }).range(spec.from, spec.to))
|
||||||
|
break
|
||||||
|
case 'hide': {
|
||||||
|
const deco = Decoration.replace({})
|
||||||
|
const r = deco.range(spec.from, spec.to)
|
||||||
|
ranges.push(r)
|
||||||
|
atomic.push(r)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'bullet': {
|
||||||
|
const deco = Decoration.replace({ widget: new BulletWidget(spec.depth) })
|
||||||
|
const r = deco.range(spec.from, spec.to)
|
||||||
|
ranges.push(r)
|
||||||
|
atomic.push(r)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'quote': {
|
||||||
|
const deco = Decoration.replace({ widget: new QuoteBarWidget(spec.depth) })
|
||||||
|
const r = deco.range(spec.from, spec.to)
|
||||||
|
ranges.push(r)
|
||||||
|
atomic.push(r)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'hr': {
|
||||||
|
const deco = Decoration.replace({ widget: new HrWidget() })
|
||||||
|
const r = deco.range(spec.from, spec.to)
|
||||||
|
ranges.push(r)
|
||||||
|
atomic.push(r)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'image': {
|
||||||
|
if (!IMAGE_SRC_ALLOW.test(spec.src)) break // 非白名单 → 退回源码文本
|
||||||
|
const deco = Decoration.replace({ widget: new ImageWidget(spec.src, spec.alt) })
|
||||||
|
const r = deco.range(spec.from, spec.to)
|
||||||
|
ranges.push(r)
|
||||||
|
atomic.push(r)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'task': {
|
||||||
|
const deco = Decoration.replace({ widget: new TaskWidget(spec.from, spec.to, spec.checked) })
|
||||||
|
const r = deco.range(spec.from, spec.to)
|
||||||
|
ranges.push(r)
|
||||||
|
atomic.push(r)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'fenceLang': {
|
||||||
|
const deco = Decoration.replace({ widget: new FenceLangWidget(spec.lang) })
|
||||||
|
const r = deco.range(spec.from, spec.to)
|
||||||
|
ranges.push(r)
|
||||||
|
atomic.push(r)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'tableMark': {
|
||||||
|
ranges.push(Decoration.mark({ class: 'cm-md-table-mark' }).range(spec.from, spec.to))
|
||||||
|
if (spec.delimiter) ranges.push(Decoration.mark({ class: 'cm-md-table-delim' }).range(spec.from, spec.to))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
decorations: Decoration.set(ranges, true),
|
||||||
|
atomic: RangeSet.of(atomic, true),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// 即时渲染扩展入口(方案 §3.1 / §4.7)。
|
||||||
|
//
|
||||||
|
// - ViewPlugin:装饰计算只跑可见区间;selection 变化即重算(行粒度 reveal);
|
||||||
|
// - EditorView.atomicRanges:隐藏的纯标记区间原子化,方向键整段跳过;
|
||||||
|
// - livePreviewCompartment:源码模式开关整体挂载/卸载,O(1) 且无残留装饰。
|
||||||
|
//
|
||||||
|
// IME(方案 §7.1):视图处于组合输入(view.composing)且文档变化时,
|
||||||
|
// 不做重算,只把旧装饰按变更映射 —— 组合范围内绝不替换 DOM。
|
||||||
|
|
||||||
|
import { Compartment, RangeSet } from '@codemirror/state'
|
||||||
|
import type { Extension } from '@codemirror/state'
|
||||||
|
import { syntaxTree } from '@codemirror/language'
|
||||||
|
import { Decoration, EditorView, ViewPlugin } from '@codemirror/view'
|
||||||
|
import type { DecorationSet, PluginValue, ViewUpdate } from '@codemirror/view'
|
||||||
|
import { buildDecorations } from './decorations'
|
||||||
|
|
||||||
|
class LivePreviewPlugin implements PluginValue {
|
||||||
|
decorations: DecorationSet = Decoration.none
|
||||||
|
atomic: RangeSet<Decoration> = RangeSet.empty
|
||||||
|
|
||||||
|
constructor(view: EditorView) {
|
||||||
|
this.build(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update: ViewUpdate): void {
|
||||||
|
if (update.docChanged && update.view.composing) {
|
||||||
|
// 组合输入期间冻结:只映射位置,不重建装饰(§7.1)
|
||||||
|
this.decorations = this.decorations.map(update.changes)
|
||||||
|
this.atomic = this.atomic.map(update.changes)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 语法树对象换代 = 后台解析推进:必须重建,否则未解析区间的装饰会一直缺失
|
||||||
|
const treeAdvanced = syntaxTree(update.startState) !== syntaxTree(update.state)
|
||||||
|
if (
|
||||||
|
update.docChanged ||
|
||||||
|
update.selectionSet ||
|
||||||
|
update.viewportChanged ||
|
||||||
|
update.focusChanged ||
|
||||||
|
treeAdvanced
|
||||||
|
) {
|
||||||
|
this.build(update.view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private build(view: EditorView): void {
|
||||||
|
// 未聚焦时整篇按渲染态呈现(决策 E11):编辑器失焦 = 只读观感,
|
||||||
|
// 避免「首行因光标停在行首而永远显示 #」的观感缺陷。
|
||||||
|
const { decorations, atomic } = buildDecorations(
|
||||||
|
view.state,
|
||||||
|
view.visibleRanges,
|
||||||
|
view.hasFocus,
|
||||||
|
)
|
||||||
|
this.decorations = decorations
|
||||||
|
this.atomic = atomic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const livePreviewPlugin = ViewPlugin.fromClass(LivePreviewPlugin, {
|
||||||
|
decorations: (value) => value.decorations,
|
||||||
|
provide: (plugin) =>
|
||||||
|
EditorView.atomicRanges.of((view) => view.plugin(plugin)?.atomic ?? RangeSet.empty),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 源码模式开关的 compartment;`empty` 表示回到纯源码编辑。 */
|
||||||
|
export const livePreviewCompartment = new Compartment()
|
||||||
|
|
||||||
|
/** 组装即时渲染扩展集。 */
|
||||||
|
export function livePreview(): Extension {
|
||||||
|
return [livePreviewPlugin]
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// 光标处展开(方案 §4.5)。
|
||||||
|
//
|
||||||
|
// 实现为纯函数:装饰集 = f(语法树, selection)。v1 取行粒度(决策 E6):
|
||||||
|
// 光标/选区触及的行,其语法标记整体展开为源码。行粒度对 IME 最安全 ——
|
||||||
|
// 组字所在行永远是展开态,组合过程中不会被 replace 掉 DOM。
|
||||||
|
//
|
||||||
|
// span 级展开(Typora 的「光标进入行内节点才展开」)列入方案 §10.2。
|
||||||
|
|
||||||
|
import type { EditorState } from '@codemirror/state'
|
||||||
|
import type { Spec } from './syntax'
|
||||||
|
|
||||||
|
export interface Range {
|
||||||
|
from: number
|
||||||
|
to: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 选区(全部 range,非仅 main)所触及的整行区间集合(升序、互不相交)。 */
|
||||||
|
export function activeLineRanges(state: EditorState): Range[] {
|
||||||
|
const out: Range[] = []
|
||||||
|
const seen = new Set<number>()
|
||||||
|
for (const r of state.selection.ranges) {
|
||||||
|
const start = state.doc.lineAt(r.from)
|
||||||
|
const end = state.doc.lineAt(r.to)
|
||||||
|
for (let n = start.number; n <= end.number; n++) {
|
||||||
|
if (seen.has(n)) continue
|
||||||
|
seen.add(n)
|
||||||
|
const line = state.doc.line(n)
|
||||||
|
out.push({ from: line.from, to: line.to })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选区**端点**所在的整行(CM 只把选区端点所在的块渲染在视口之外,
|
||||||
|
* 不会渲染整个选区跨越的每一行)。用于决定「除可见区间外还要装饰哪些行」,
|
||||||
|
* 避免 Cmd+A 这种整篇选区触发全文档装饰构建。
|
||||||
|
*/
|
||||||
|
export function selectionEndpointLines(state: EditorState): Range[] {
|
||||||
|
const out: Range[] = []
|
||||||
|
const seen = new Set<number>()
|
||||||
|
for (const r of state.selection.ranges) {
|
||||||
|
for (const pos of [r.from, r.to]) {
|
||||||
|
const line = state.doc.lineAt(pos)
|
||||||
|
if (seen.has(line.number)) continue
|
||||||
|
seen.add(line.number)
|
||||||
|
out.push({ from: line.from, to: line.to })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [from, to) 是否与任一展开区间相交。零长区间不与任何行相交。 */
|
||||||
|
export function intersectsAny(ranges: readonly Range[], from: number, to: number): boolean {
|
||||||
|
if (to <= from) return false
|
||||||
|
for (const r of ranges) {
|
||||||
|
if (from <= r.to && r.from <= to) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把区间排序并合并,得到互不相交的升序区间,供二分查询使用。 */
|
||||||
|
function mergeRanges(ranges: readonly Range[]): Range[] {
|
||||||
|
if (ranges.length < 2) return ranges.map((r) => ({ from: r.from, to: r.to }))
|
||||||
|
const sorted = ranges
|
||||||
|
.map((r) => ({ from: r.from, to: r.to }))
|
||||||
|
.sort((a, b) => a.from - b.from || a.to - b.to)
|
||||||
|
const out: Range[] = [sorted[0]]
|
||||||
|
for (let i = 1; i < sorted.length; i++) {
|
||||||
|
const last = out[out.length - 1]
|
||||||
|
if (sorted[i].from <= last.to) last.to = Math.max(last.to, sorted[i].to)
|
||||||
|
else out.push(sorted[i])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消除 O(装饰数 × 展开区间数):展开区间先合并再二分。
|
||||||
|
* 整篇选区(Cmd+A)在长文档下会产生数千个展开区间,线性扫描会让单帧成本失控。
|
||||||
|
*/
|
||||||
|
export function intersectsMerged(sortedMerged: readonly Range[], from: number, to: number): boolean {
|
||||||
|
if (to <= from) return false
|
||||||
|
let lo = 0
|
||||||
|
let hi = sortedMerged.length - 1
|
||||||
|
let idx = -1
|
||||||
|
while (lo <= hi) {
|
||||||
|
const mid = (lo + hi) >> 1
|
||||||
|
if (sortedMerged[mid].from <= to) {
|
||||||
|
idx = mid
|
||||||
|
lo = mid + 1
|
||||||
|
} else {
|
||||||
|
hi = mid - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return idx >= 0 && sortedMerged[idx].to >= from
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 丢掉落在展开区间内的「标记替代物」,保留样式类装饰。
|
||||||
|
* 结果仍是文档顺序(collectSpecs 输出即有序)。
|
||||||
|
*/
|
||||||
|
export function applyReveal(specs: readonly Spec[], ranges: readonly Range[]): Spec[] {
|
||||||
|
if (ranges.length === 0) return specs.slice()
|
||||||
|
const merged = mergeRanges(ranges)
|
||||||
|
return specs.filter((s) => !(s.reveal && intersectsMerged(merged, s.from, s.to)))
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
// 即时渲染(Typora 式)语法采集层(方案 §4.1)。
|
||||||
|
//
|
||||||
|
// 职责:把 lezer Markdown 语法树翻译成「与 CodeMirror 解耦」的装饰描述(Spec)。
|
||||||
|
// 纯函数、零 DOM、零 CM view 依赖,便于 vitest 直接断言区间。
|
||||||
|
//
|
||||||
|
// 设计约束:
|
||||||
|
// - 只认语法树节点,不写正则(方案 §4.1);
|
||||||
|
// - 采集结果不做 reveal 过滤(reveal 是 selection 的函数,见 reveal.ts),
|
||||||
|
// 由 decorations.ts 在构建阶段调用 applyReveal —— 这样「识别」与「展开」
|
||||||
|
// 可以分别单测;
|
||||||
|
// - 任何 from/to 都不跨越行边界(CM6 对 replace 跨行的硬性约束,方案 §4.2)。
|
||||||
|
|
||||||
|
import type { SyntaxNode, Tree } from '@lezer/common'
|
||||||
|
import type { Text } from '@codemirror/state'
|
||||||
|
|
||||||
|
/** 装饰描述类型(kind 决定 decorations.ts 里用哪种 Decoration)。 */
|
||||||
|
export type SpecKind =
|
||||||
|
| 'hide' // 纯定界符,replace 为空 + 原子
|
||||||
|
| 'mark' // 行内样式,Decoration.mark
|
||||||
|
| 'line' // 整行样式,Decoration.line
|
||||||
|
| 'bullet' // 无序列表圆点 widget(§5.1)
|
||||||
|
| 'quote' // 引用竖线 widget(§5.3)
|
||||||
|
| 'hr' // 水平线 widget(§5.4)
|
||||||
|
| 'image' // 图片 widget(§5.4)
|
||||||
|
| 'task' // 任务复选框 widget(§5.4)
|
||||||
|
| 'fenceLang' // 代码围栏语言标签 widget(§5.4)
|
||||||
|
| 'tableMark' // 表格定界符淡化(§4.4)
|
||||||
|
|
||||||
|
interface BaseSpec {
|
||||||
|
from: number
|
||||||
|
to: number
|
||||||
|
/**
|
||||||
|
* 是否属于「语法标记替代物」:为 true 时,光标所在行会撤销该装饰、展开源码
|
||||||
|
* (方案 §4.5 行粒度 reveal)。样式类(mark/line)恒为 false。
|
||||||
|
*/
|
||||||
|
reveal: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HideSpec extends BaseSpec {
|
||||||
|
kind: 'hide'
|
||||||
|
}
|
||||||
|
export interface MarkSpec extends BaseSpec {
|
||||||
|
kind: 'mark'
|
||||||
|
cls: string
|
||||||
|
}
|
||||||
|
export interface LineSpec extends BaseSpec {
|
||||||
|
kind: 'line'
|
||||||
|
line: number
|
||||||
|
cls: string
|
||||||
|
}
|
||||||
|
export interface BulletSpec extends BaseSpec {
|
||||||
|
kind: 'bullet'
|
||||||
|
depth: number
|
||||||
|
}
|
||||||
|
export interface QuoteSpec extends BaseSpec {
|
||||||
|
kind: 'quote'
|
||||||
|
depth: number
|
||||||
|
}
|
||||||
|
export interface HrSpec extends BaseSpec {
|
||||||
|
kind: 'hr'
|
||||||
|
}
|
||||||
|
export interface ImageSpec extends BaseSpec {
|
||||||
|
kind: 'image'
|
||||||
|
alt: string
|
||||||
|
src: string
|
||||||
|
}
|
||||||
|
export interface TaskSpec extends BaseSpec {
|
||||||
|
kind: 'task'
|
||||||
|
checked: boolean
|
||||||
|
}
|
||||||
|
export interface FenceLangSpec extends BaseSpec {
|
||||||
|
kind: 'fenceLang'
|
||||||
|
lang: string
|
||||||
|
}
|
||||||
|
export interface TableMarkSpec extends BaseSpec {
|
||||||
|
kind: 'tableMark'
|
||||||
|
/** 是否为分隔行(`| --- |`),用于「淡化」与「更淡」两级 */
|
||||||
|
delimiter: boolean
|
||||||
|
}
|
||||||
|
export type Spec =
|
||||||
|
| HideSpec
|
||||||
|
| MarkSpec
|
||||||
|
| LineSpec
|
||||||
|
| BulletSpec
|
||||||
|
| QuoteSpec
|
||||||
|
| HrSpec
|
||||||
|
| ImageSpec
|
||||||
|
| TaskSpec
|
||||||
|
| FenceLangSpec
|
||||||
|
| TableMarkSpec
|
||||||
|
|
||||||
|
const HEADING_RE = /^ATXHeading([1-6])$/
|
||||||
|
|
||||||
|
/** 行内样式类名(对齐 .pn-note-body 的阅读态,方案 §4.3)。 */
|
||||||
|
export const CLS_STRONG = 'cm-md-strong'
|
||||||
|
export const CLS_EM = 'cm-md-em'
|
||||||
|
export const CLS_DEL = 'cm-md-del'
|
||||||
|
export const CLS_CODE = 'cm-md-code'
|
||||||
|
export const CLS_LINK = 'cm-md-link'
|
||||||
|
export const CLS_TABLE = 'cm-md-table'
|
||||||
|
export const CLS_FENCED = 'cm-md-fenced'
|
||||||
|
export const CLS_QUOTE_LINE = 'cm-md-quote'
|
||||||
|
export const CLS_OL_MARK = 'cm-md-ol-mark'
|
||||||
|
|
||||||
|
/** 折行到下一个非空白位置(用于把 `#` / `-` 后的空格一并吃掉)。 */
|
||||||
|
function afterSpaces(doc: Text, pos: number, limit: number): number {
|
||||||
|
let p = pos
|
||||||
|
while (p < limit && doc.sliceString(p, p + 1) === ' ') p++
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 向上找最近的同名祖先。 */
|
||||||
|
function nearestAncestor(node: SyntaxNode, name: string): SyntaxNode | null {
|
||||||
|
for (let p = node.parent; p; p = p.parent) if (p.name === name) return p
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表嵌套深度 = 祖先链中 BulletList / OrderedList 的个数(方案 §5.1)。 */
|
||||||
|
function listDepth(node: SyntaxNode): number {
|
||||||
|
let d = 0
|
||||||
|
for (let p = node.parent; p; p = p.parent) {
|
||||||
|
if (p.name === 'BulletList' || p.name === 'OrderedList') d++
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 引用嵌套深度 = 祖先链中 Blockquote 的个数。 */
|
||||||
|
function quoteDepth(node: SyntaxNode): number {
|
||||||
|
let d = 0
|
||||||
|
for (let p = node.parent; p; p = p.parent) if (p.name === 'Blockquote') d++
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 该列表项是否为任务项(`- [ ] x`)—— 任务项不画圆点,只画复选框。 */
|
||||||
|
function isTaskItem(node: SyntaxNode): boolean {
|
||||||
|
const li = nearestAncestor(node, 'ListItem')
|
||||||
|
return !!li?.getChild('Task')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 采集 [from, to) 区间内所有装饰描述。区间通常取 `view.visibleRanges`,
|
||||||
|
* 保证只做视口内的工作(方案 §7.4)。
|
||||||
|
*/
|
||||||
|
export function collectSpecs(tree: Tree, doc: Text, from: number, to: number): Spec[] {
|
||||||
|
const specs: Spec[] = []
|
||||||
|
const push = (s: Spec) => {
|
||||||
|
if (s.kind === 'line' || s.to > s.from) specs.push(s)
|
||||||
|
}
|
||||||
|
const hide = (f: number, t: number) => push({ kind: 'hide', from: f, to: t, reveal: true })
|
||||||
|
const mark = (f: number, t: number, cls: string) => push({ kind: 'mark', from: f, to: t, cls, reveal: false })
|
||||||
|
|
||||||
|
tree.iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter: (node) => {
|
||||||
|
const name = node.name
|
||||||
|
const nodeFrom = node.from
|
||||||
|
const nodeTo = node.to
|
||||||
|
|
||||||
|
const heading = HEADING_RE.exec(name)
|
||||||
|
if (heading) {
|
||||||
|
const line = doc.lineAt(nodeFrom)
|
||||||
|
push({ kind: 'line', from: line.from, to: line.to, line: line.number, cls: `cm-md-h${heading[1]}`, reveal: false })
|
||||||
|
const hm = node.node.getChild('HeaderMark')
|
||||||
|
if (hm) hide(hm.from, afterSpaces(doc, hm.to, line.to))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (name) {
|
||||||
|
case 'StrongEmphasis':
|
||||||
|
case 'Emphasis':
|
||||||
|
case 'Strikethrough': {
|
||||||
|
const markName = name === 'Strikethrough' ? 'StrikethroughMark' : 'EmphasisMark'
|
||||||
|
const marks = node.node.getChildren(markName)
|
||||||
|
if (marks.length >= 2) {
|
||||||
|
const open = marks[0]
|
||||||
|
const close = marks[marks.length - 1]
|
||||||
|
const cls = name === 'StrongEmphasis' ? CLS_STRONG : name === 'Emphasis' ? CLS_EM : CLS_DEL
|
||||||
|
hide(open.from, open.to)
|
||||||
|
hide(close.from, close.to)
|
||||||
|
mark(open.to, close.from, cls)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'InlineCode': {
|
||||||
|
// 行内代码内容为字面量,不再向下解析
|
||||||
|
const marks = node.node.getChildren('CodeMark')
|
||||||
|
if (marks.length >= 2) {
|
||||||
|
hide(marks[0].from, marks[0].to)
|
||||||
|
hide(marks[marks.length - 1].from, marks[marks.length - 1].to)
|
||||||
|
mark(marks[0].to, marks[marks.length - 1].from, CLS_CODE)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'Link': {
|
||||||
|
const marks = node.node.getChildren('LinkMark')
|
||||||
|
if (marks.length >= 2) {
|
||||||
|
hide(marks[0].from, marks[0].to)
|
||||||
|
mark(marks[0].to, marks[1].from, CLS_LINK)
|
||||||
|
// `](url)` 整段隐藏(URL 不产出 <a>,v1 只做文本样式,方案 §6)
|
||||||
|
hide(marks[1].from, nodeTo)
|
||||||
|
}
|
||||||
|
// 继续下钻:链接内可嵌图片
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'Image': {
|
||||||
|
const url = node.node.getChild('URL')
|
||||||
|
const imgMarks = node.node.getChildren('LinkMark')
|
||||||
|
const alt = imgMarks.length >= 2 ? doc.sliceString(imgMarks[0].to, imgMarks[1].from) : ''
|
||||||
|
push({
|
||||||
|
kind: 'image',
|
||||||
|
from: nodeFrom,
|
||||||
|
to: nodeTo,
|
||||||
|
reveal: false,
|
||||||
|
alt,
|
||||||
|
src: url ? doc.sliceString(url.from, url.to) : '',
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'HorizontalRule':
|
||||||
|
push({ kind: 'hr', from: nodeFrom, to: nodeTo, reveal: true })
|
||||||
|
return false
|
||||||
|
|
||||||
|
case 'QuoteMark': {
|
||||||
|
const line = doc.lineAt(nodeFrom)
|
||||||
|
// 行级样式与竖线 widget 分工:widget 承接标记,行类把正文调成阅读态的弱化色
|
||||||
|
push({ kind: 'line', from: line.from, to: line.to, line: line.number, cls: CLS_QUOTE_LINE, reveal: false })
|
||||||
|
push({ kind: 'quote', from: nodeFrom, to: nodeTo, reveal: true, depth: quoteDepth(node.node) })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ListMark': {
|
||||||
|
const list = nearestAncestor(node.node, 'BulletList') ?? nearestAncestor(node.node, 'OrderedList')
|
||||||
|
const line = doc.lineAt(nodeFrom)
|
||||||
|
const end = afterSpaces(doc, nodeTo, line.to)
|
||||||
|
if (list?.name === 'BulletList') {
|
||||||
|
if (isTaskItem(node.node)) {
|
||||||
|
hide(nodeFrom, end) // 任务项:标记让位给复选框
|
||||||
|
} else {
|
||||||
|
push({ kind: 'bullet', from: nodeFrom, to: end, reveal: true, depth: listDepth(node.node) })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 有序列表:保留数字(携带序号语义,方案 §5.2 / 决策 E3),仅上样式
|
||||||
|
mark(nodeFrom, nodeTo, CLS_OL_MARK)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'TaskMarker': {
|
||||||
|
// 复选框始终渲染(不参与 reveal),点击即改写源文本(方案 §5.4)
|
||||||
|
const checked = doc.sliceString(nodeFrom, nodeTo).toLowerCase().includes('x')
|
||||||
|
push({ kind: 'task', from: nodeFrom, to: nodeTo, reveal: false, checked })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'FencedCode': {
|
||||||
|
const marks = node.node.getChildren('CodeMark')
|
||||||
|
const info = node.node.getChild('CodeInfo')
|
||||||
|
const startLine = doc.lineAt(nodeFrom)
|
||||||
|
const endLine = doc.lineAt(nodeTo)
|
||||||
|
if (marks.length >= 1) {
|
||||||
|
const open = marks[0]
|
||||||
|
const end = info ? info.to : open.to
|
||||||
|
if (info) {
|
||||||
|
push({ kind: 'fenceLang', from: open.from, to: end, reveal: true, lang: doc.sliceString(info.from, info.to) })
|
||||||
|
} else {
|
||||||
|
hide(open.from, end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (marks.length >= 2) hide(marks[marks.length - 1].from, marks[marks.length - 1].to)
|
||||||
|
for (let n = startLine.number; n <= endLine.number; n++) {
|
||||||
|
const line = doc.line(n)
|
||||||
|
const cls = [
|
||||||
|
CLS_FENCED,
|
||||||
|
n === startLine.number ? 'cm-md-fenced-first' : '',
|
||||||
|
n === endLine.number ? 'cm-md-fenced-last' : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
push({ kind: 'line', from: line.from, to: line.to, line: n, cls, reveal: false })
|
||||||
|
}
|
||||||
|
return false // 代码块内不解析行内语法
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'Table': {
|
||||||
|
const startLine = doc.lineAt(nodeFrom)
|
||||||
|
const endLine = doc.lineAt(nodeTo)
|
||||||
|
for (let n = startLine.number; n <= endLine.number; n++) {
|
||||||
|
const line = doc.line(n)
|
||||||
|
push({ kind: 'line', from: line.from, to: line.to, line: n, cls: CLS_TABLE, reveal: false })
|
||||||
|
}
|
||||||
|
return // 下钻以标记 TableDelimiter
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'TableDelimiter': {
|
||||||
|
// 分隔行(`| --- |`)比普通竖线更淡,用于区分表头分隔
|
||||||
|
const text = doc.sliceString(nodeFrom, nodeTo)
|
||||||
|
push({ kind: 'tableMark', from: nodeFrom, to: nodeTo, reveal: false, delimiter: text.includes('-') })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'TableCell':
|
||||||
|
// 表格为「源码对齐」呈现(决策 E5),单元格内不隐藏语法
|
||||||
|
return false
|
||||||
|
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return specs
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
// 即时渲染的 widget 集合(方案 §5)。
|
||||||
|
//
|
||||||
|
// 硬性约束(§5.5):
|
||||||
|
// - 一律 document.createElement / textContent / setAttribute,禁止 innerHTML;
|
||||||
|
// - widget DOM 不接受任何用户 HTML,只消费语法树里解析出的「文本/白名单 URL」;
|
||||||
|
// - 除任务复选框外均 aria-hidden="true"(它们是语法标记的视觉替代物);
|
||||||
|
// - widget 不改变文档内容与选区语义(任务框例外:它显式改写源文本)。
|
||||||
|
|
||||||
|
import { EditorView, WidgetType } from '@codemirror/view'
|
||||||
|
|
||||||
|
const BULLET_CHARS = ['•', '◦', '▪']
|
||||||
|
|
||||||
|
/** 无序列表圆点(§5.1):按嵌套深度取 • / ◦ / ▪。 */
|
||||||
|
export class BulletWidget extends WidgetType {
|
||||||
|
constructor(readonly depth: number) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(other: BulletWidget): boolean {
|
||||||
|
return other.depth === this.depth
|
||||||
|
}
|
||||||
|
|
||||||
|
toDOM(): HTMLElement {
|
||||||
|
const span = document.createElement('span')
|
||||||
|
span.className = 'cm-md-bullet'
|
||||||
|
span.setAttribute('aria-hidden', 'true')
|
||||||
|
span.textContent = BULLET_CHARS[Math.min(this.depth - 1, BULLET_CHARS.length - 1)] ?? BULLET_CHARS[0]
|
||||||
|
return span
|
||||||
|
}
|
||||||
|
|
||||||
|
ignoreEvent(): boolean {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 引用竖线(§5.3):CSS 画 2px 竖线,宽度 = 原标记宽度,嵌套自然叠加。 */
|
||||||
|
export class QuoteBarWidget extends WidgetType {
|
||||||
|
constructor(readonly depth: number) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(other: QuoteBarWidget): boolean {
|
||||||
|
return other.depth === this.depth
|
||||||
|
}
|
||||||
|
|
||||||
|
toDOM(): HTMLElement {
|
||||||
|
const span = document.createElement('span')
|
||||||
|
span.className = 'cm-md-quote-bar'
|
||||||
|
span.setAttribute('aria-hidden', 'true')
|
||||||
|
span.dataset.depth = String(this.depth)
|
||||||
|
return span
|
||||||
|
}
|
||||||
|
|
||||||
|
ignoreEvent(): boolean {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 水平线(§5.4):整行内容替换为一条贯穿的分隔线。 */
|
||||||
|
export class HrWidget extends WidgetType {
|
||||||
|
eq(): boolean {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
toDOM(): HTMLElement {
|
||||||
|
const span = document.createElement('span')
|
||||||
|
span.className = 'cm-md-hr'
|
||||||
|
span.setAttribute('aria-hidden', 'true')
|
||||||
|
return span
|
||||||
|
}
|
||||||
|
|
||||||
|
ignoreEvent(): boolean {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 图片(§5.4):src 白名单在 decorations.ts 里已校验,此处只负责 DOM。 */
|
||||||
|
export class ImageWidget extends WidgetType {
|
||||||
|
constructor(
|
||||||
|
readonly src: string,
|
||||||
|
readonly alt: string,
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(other: ImageWidget): boolean {
|
||||||
|
return other.src === this.src && other.alt === this.alt
|
||||||
|
}
|
||||||
|
|
||||||
|
toDOM(): HTMLElement {
|
||||||
|
const img = document.createElement('img')
|
||||||
|
img.className = 'cm-md-image'
|
||||||
|
img.src = this.src
|
||||||
|
img.alt = this.alt
|
||||||
|
img.loading = 'lazy'
|
||||||
|
// 图片加载失败(如已删除)时保持占位,不破坏行高
|
||||||
|
img.addEventListener('error', () => {
|
||||||
|
img.classList.add('cm-md-image-error')
|
||||||
|
})
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|
||||||
|
ignoreEvent(): boolean {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 围栏语言标签(§5.4):` ```js ` → 语言名小标签。 */
|
||||||
|
export class FenceLangWidget extends WidgetType {
|
||||||
|
constructor(readonly lang: string) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(other: FenceLangWidget): boolean {
|
||||||
|
return other.lang === this.lang
|
||||||
|
}
|
||||||
|
|
||||||
|
toDOM(): HTMLElement {
|
||||||
|
const span = document.createElement('span')
|
||||||
|
span.className = 'cm-md-fence-lang'
|
||||||
|
span.setAttribute('aria-hidden', 'true')
|
||||||
|
span.textContent = this.lang
|
||||||
|
return span
|
||||||
|
}
|
||||||
|
|
||||||
|
ignoreEvent(): boolean {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务复选框(§5.4):点击直接改写源文本 `[ ]` ↔ `[x]`,真相源仍是 Markdown。
|
||||||
|
* 该 widget 不参与 reveal(复选框始终可见可点,与 Typora 一致)。
|
||||||
|
*/
|
||||||
|
export class TaskWidget extends WidgetType {
|
||||||
|
constructor(
|
||||||
|
readonly from: number,
|
||||||
|
readonly to: number,
|
||||||
|
readonly checked: boolean,
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(other: TaskWidget): boolean {
|
||||||
|
return other.from === this.from && other.to === this.to && other.checked === this.checked
|
||||||
|
}
|
||||||
|
|
||||||
|
toDOM(view: EditorView): HTMLElement {
|
||||||
|
const input = document.createElement('input')
|
||||||
|
input.type = 'checkbox'
|
||||||
|
input.className = 'cm-md-task'
|
||||||
|
input.checked = this.checked
|
||||||
|
input.setAttribute('aria-label', this.checked ? '标记为未完成' : '标记为已完成')
|
||||||
|
|
||||||
|
const onMouseDown = (event: MouseEvent) => {
|
||||||
|
// 阻止 CM 把焦点/光标移走,自己处理取反
|
||||||
|
event.preventDefault()
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: this.from, to: this.to, insert: this.checked ? '[ ]' : '[x]' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
input.addEventListener('mousedown', onMouseDown)
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 复选框要自己处理事件(默认 true 即「编辑器忽略」),无需覆写。 */
|
||||||
|
ignoreEvent(): boolean {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,35 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { api } from '../lib/api'
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置浏览器标签页标题。
|
||||||
|
* - 生产环境首帧由服务端注入首页标题;SPA 路由切换后由此 hook 接管。
|
||||||
|
* - dev 环境 index.html 占位符 `{{.Title}}` 也依赖此 hook 替换。
|
||||||
|
* pageTitle 缺省时仅显示站点名。
|
||||||
|
*/
|
||||||
|
export function useDocumentTitle(pageTitle?: string) {
|
||||||
|
const { data } = useSiteTitle()
|
||||||
|
const siteName = data?.site_title ?? 'Pure Note'
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = pageTitle ? `${pageTitle} · ${siteName}` : siteName
|
||||||
|
}, [pageTitle, siteName])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
+2172
-7
File diff suppressed because it is too large
Load Diff
+17
-2
@@ -25,6 +25,13 @@ export function getCsrfToken(): string | null {
|
|||||||
return csrfToken
|
return csrfToken
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 401 统一回调:由 AuthProvider 注册(置未登录态 → RequireAuth 跳 /admin/login)
|
||||||
|
let unauthorizedHandler: (() => void) | null = null
|
||||||
|
|
||||||
|
export function setUnauthorizedHandler(fn: (() => void) | null) {
|
||||||
|
unauthorizedHandler = fn
|
||||||
|
}
|
||||||
|
|
||||||
interface Envelope<T> {
|
interface Envelope<T> {
|
||||||
data?: T
|
data?: T
|
||||||
error?: { code: string; message: string; field?: string }
|
error?: { code: string; message: string; field?: string }
|
||||||
@@ -36,7 +43,9 @@ export async function api<T>(
|
|||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const method = (options.method ?? 'GET').toUpperCase()
|
const method = (options.method ?? 'GET').toUpperCase()
|
||||||
const headers = new Headers(options.headers)
|
const headers = new Headers(options.headers)
|
||||||
if (options.body && !headers.has('Content-Type')) {
|
// FormData 由浏览器自动生成含 boundary 的 multipart Content-Type,不可覆写
|
||||||
|
// (曾因此破坏图片上传,评审 round2 P1-2)
|
||||||
|
if (options.body && !(options.body instanceof FormData) && !headers.has('Content-Type')) {
|
||||||
headers.set('Content-Type', 'application/json')
|
headers.set('Content-Type', 'application/json')
|
||||||
}
|
}
|
||||||
if (method !== 'GET' && method !== 'HEAD' && csrfToken) {
|
if (method !== 'GET' && method !== 'HEAD' && csrfToken) {
|
||||||
@@ -54,8 +63,9 @@ export async function api<T>(
|
|||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = body?.error
|
const err = body?.error
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
// 登录态失效:清内存态,统一由 AuthContext 跳登录页
|
// 登录态失效:清内存态并通知全局回调(AuthProvider 跳登录页)
|
||||||
setCsrfToken(null)
|
setCsrfToken(null)
|
||||||
|
unauthorizedHandler?.()
|
||||||
}
|
}
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
res.status,
|
res.status,
|
||||||
@@ -77,6 +87,8 @@ export interface SiteSettings {
|
|||||||
site_title: string
|
site_title: string
|
||||||
site_desc: string
|
site_desc: string
|
||||||
page_size: number
|
page_size: number
|
||||||
|
beian_no: string
|
||||||
|
site_logo: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NoteItem {
|
export interface NoteItem {
|
||||||
@@ -87,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 {
|
||||||
|
|||||||
+20
-3
@@ -1,6 +1,6 @@
|
|||||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react'
|
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { api, setCsrfToken } from './api'
|
import { api, setCsrfToken, setUnauthorizedHandler } from './api'
|
||||||
|
|
||||||
interface Me {
|
interface Me {
|
||||||
authenticated: boolean
|
authenticated: boolean
|
||||||
@@ -22,10 +22,15 @@ const AuthContext = createContext<AuthState | null>(null)
|
|||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [authenticated, setAuthenticated] = useState(false)
|
const [authenticated, setAuthenticated] = useState(false)
|
||||||
|
// 请求序号:login/logout 递增,使竞态中的迟到 /api/me 响应被丢弃
|
||||||
|
// (防登录成功后被先前的匿名响应覆盖,评审 round2 P2-15)
|
||||||
|
const seqRef = useRef(0)
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
|
const seq = ++seqRef.current
|
||||||
try {
|
try {
|
||||||
const me = await api<Me>('/api/me')
|
const me = await api<Me>('/api/me')
|
||||||
|
if (seqRef.current !== seq) return // 状态已被 login/logout 改变,丢弃迟到响应
|
||||||
setAuthenticated(me.authenticated)
|
setAuthenticated(me.authenticated)
|
||||||
if (me.authenticated && me.csrf_token) {
|
if (me.authenticated && me.csrf_token) {
|
||||||
setCsrfToken(me.csrf_token)
|
setCsrfToken(me.csrf_token)
|
||||||
@@ -33,10 +38,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setCsrfToken(null)
|
setCsrfToken(null)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
if (seqRef.current !== seq) return
|
||||||
setAuthenticated(false)
|
setAuthenticated(false)
|
||||||
setCsrfToken(null)
|
setCsrfToken(null)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
if (seqRef.current === seq) setLoading(false)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -44,12 +50,22 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
void refresh()
|
void refresh()
|
||||||
}, [refresh])
|
}, [refresh])
|
||||||
|
|
||||||
|
// api 层 401 统一回调:置未登录态,RequireAuth 随即跳转 /admin/login
|
||||||
|
useEffect(() => {
|
||||||
|
setUnauthorizedHandler(() => {
|
||||||
|
setAuthenticated(false)
|
||||||
|
setCsrfToken(null)
|
||||||
|
})
|
||||||
|
return () => setUnauthorizedHandler(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const login = useCallback(
|
const login = useCallback(
|
||||||
async (password: string) => {
|
async (password: string) => {
|
||||||
const res = await api<{ csrf_token: string }>('/api/auth/login', {
|
const res = await api<{ csrf_token: string }>('/api/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ password }),
|
body: JSON.stringify({ password }),
|
||||||
})
|
})
|
||||||
|
seqRef.current++ // 使在途的匿名 /api/me 响应失效
|
||||||
setCsrfToken(res.csrf_token)
|
setCsrfToken(res.csrf_token)
|
||||||
setAuthenticated(true)
|
setAuthenticated(true)
|
||||||
},
|
},
|
||||||
@@ -58,6 +74,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
await api('/api/auth/logout', { method: 'POST' })
|
await api('/api/auth/logout', { method: 'POST' })
|
||||||
|
seqRef.current++
|
||||||
setCsrfToken(null)
|
setCsrfToken(null)
|
||||||
setAuthenticated(false)
|
setAuthenticated(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { QueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写操作成功后失效公共数据缓存(列表/标签页/详情/标签云)。
|
||||||
|
* 公共查询 staleTime=30s 且不随窗口聚焦刷新,管理端改动若不失效,
|
||||||
|
* 返回公共页最长 30s 展示旧内容(评审 round2 P2-15)。
|
||||||
|
* 前缀匹配:['notes'] → ['notes',page,tag],['note'] → ['note',slug]。
|
||||||
|
*/
|
||||||
|
export function invalidatePublic(qc: QueryClient) {
|
||||||
|
void qc.invalidateQueries({ queryKey: ['notes'] })
|
||||||
|
void qc.invalidateQueries({ queryKey: ['tag'] })
|
||||||
|
void qc.invalidateQueries({ queryKey: ['note'] })
|
||||||
|
void qc.invalidateQueries({ queryKey: ['tags'] })
|
||||||
|
}
|
||||||
+18
-4
@@ -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',
|
||||||
@@ -20,6 +16,24 @@ export function formatDateTime(unix: number): string {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 设计稿统一使用 YYYY-MM-DD 等宽日期
|
||||||
|
export function formatDateISO(unix: number): string {
|
||||||
|
const d = new Date(unix * 1000)
|
||||||
|
const p = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 阅读时长估算:中文约 400 字/分钟
|
||||||
|
export function readingMinutes(content: string): number {
|
||||||
|
return Math.max(1, Math.round(content.length / 400))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回收站剩余保留天数(30 天保留期)
|
||||||
|
export function daysRemaining(deletedAt: number): number {
|
||||||
|
const elapsedDays = Math.floor((Date.now() / 1000 - deletedAt) / 86400)
|
||||||
|
return Math.max(0, 30 - elapsedDays)
|
||||||
|
}
|
||||||
|
|
||||||
// 前端 slug 预览:与服务端 slugify 同规则(ASCII 部分),纯非 ASCII 时提示将自动生成
|
// 前端 slug 预览:与服务端 slugify 同规则(ASCII 部分),纯非 ASCII 时提示将自动生成
|
||||||
export function slugPreview(title: string, date: Date): string {
|
export function slugPreview(title: string, date: Date): string {
|
||||||
const t = title.trim().toLowerCase().replace(/_/g, '-').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').replace(/^-+|-+$/g, '')
|
const t = title.trim().toLowerCase().replace(/_/g, '-').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').replace(/^-+|-+$/g, '')
|
||||||
|
|||||||
+233
-160
@@ -1,10 +1,18 @@
|
|||||||
import { lazy, Suspense, useCallback, 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 { Bold, Italic, Link2, Code, Table, Eye, EyeOff } from 'lucide-react'
|
import dayjs from 'dayjs'
|
||||||
|
import {
|
||||||
|
Bold, Code, Eye, FileCode2, Italic, Link2, List, Quote,
|
||||||
|
Save, Table, Trash2,
|
||||||
|
} 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 MarkdownViewer from '../components/MarkdownViewer'
|
import { invalidatePublic } from '../lib/invalidate'
|
||||||
|
import { useDocumentTitle } from '../features/site'
|
||||||
|
import { AdminTopbar } from '../components/Layout'
|
||||||
|
import { useToast } from '@/hooks/use-toast'
|
||||||
import { slugPreview } from '../lib/utils'
|
import { slugPreview } from '../lib/utils'
|
||||||
|
|
||||||
// CodeMirror 体积大,单独拆 chunk,编辑器在首帧后异步加载
|
// CodeMirror 体积大,单独拆 chunk,编辑器在首帧后异步加载
|
||||||
@@ -22,26 +30,30 @@ type Form = {
|
|||||||
summary: string
|
summary: string
|
||||||
content: string
|
content: string
|
||||||
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: '',
|
||||||
content: '',
|
content: '',
|
||||||
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()
|
||||||
const noteId = id ? Number(id) : null
|
const noteId = id ? Number(id) : null
|
||||||
const isNew = noteId === null
|
const isNew = noteId === null
|
||||||
|
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],
|
||||||
@@ -49,16 +61,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(true)
|
const [sourceMode, setSourceMode] = useState(false)
|
||||||
const [uploadMsg, setUploadMsg] = useState<string | null>(null)
|
const [uploadMsg, setUploadMsg] = useState<{ kind: 'busy' | 'error'; text: string } | 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({
|
||||||
@@ -67,8 +91,9 @@ export default function AdminEdit() {
|
|||||||
summary: existing.summary,
|
summary: existing.summary,
|
||||||
content: existing.content,
|
content: existing.content,
|
||||||
status: existing.status === 'public' ? 'public' : 'private',
|
status: existing.status === 'public' ? 'public' : 'private',
|
||||||
tags: existing.tags.join(', '),
|
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
|
||||||
@@ -83,15 +108,17 @@ export default function AdminEdit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const save = useMutation({
|
const save = useMutation({
|
||||||
mutationFn: async (f: Form) => {
|
mutationFn: async (vars: { f: Form; auto: boolean }) => {
|
||||||
|
const { f } = vars
|
||||||
const payload = {
|
const payload = {
|
||||||
title: f.title,
|
title: f.title,
|
||||||
slug: f.slug,
|
slug: f.slug,
|
||||||
summary: f.summary,
|
summary: f.summary,
|
||||||
content: f.content,
|
content: f.content,
|
||||||
status: f.status,
|
status: f.status,
|
||||||
tags: f.tags.split(/[,,]/).map((t) => t.trim()).filter(Boolean),
|
tags: f.tags,
|
||||||
pinned: f.pinned,
|
pinned: f.pinned,
|
||||||
|
published_at: f.publishedAt,
|
||||||
}
|
}
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
// 新建:slug 由服务端从标题自动生成(首次保存即定稿,§8.2)
|
// 新建:slug 由服务端从标题自动生成(首次保存即定稿,§8.2)
|
||||||
@@ -105,17 +132,21 @@ export default function AdminEdit() {
|
|||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onSuccess: (saved) => {
|
onSuccess: (saved, vars) => {
|
||||||
setSavedAt(Date.now())
|
|
||||||
setSlugFieldError(null)
|
setSlugFieldError(null)
|
||||||
setErrorMsg(null)
|
setErrorMsg(null)
|
||||||
dirtyRef.current = false
|
dirtyRef.current = false
|
||||||
void qc.invalidateQueries({ queryKey: ['admin'] })
|
toast({ description: vars.auto ? '已自动保存' : '已保存' })
|
||||||
|
// 仅失效列表与公共缓存;不失效当前编辑详情(['admin','note',id]),
|
||||||
|
// 防 refetch 回滚保存期间继续输入的内容并抑制下一轮自动保存(评审 round2 P1-4)
|
||||||
|
void qc.invalidateQueries({ queryKey: ['admin', 'notes'] })
|
||||||
|
invalidatePublic(qc)
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
navigate(`/admin/notes/${saved.id}/edit`, { replace: true })
|
navigate(`/admin/notes/${saved.id}/edit`, { replace: true })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (e) => {
|
onError: (e) => {
|
||||||
|
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 {
|
||||||
@@ -124,6 +155,16 @@ export default function AdminEdit() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const trashMutation = useMutation({
|
||||||
|
mutationFn: () => api(`/api/admin/notes/${noteId}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: ['admin'] })
|
||||||
|
invalidatePublic(qc)
|
||||||
|
navigate('/admin')
|
||||||
|
},
|
||||||
|
onError: () => toast({ description: '操作失败,请重试', variant: 'error' }),
|
||||||
|
})
|
||||||
|
|
||||||
const canSave = form.title.trim().length > 0
|
const canSave = form.title.trim().length > 0
|
||||||
const autosaveReady = !isNew || form.content.trim().length > 0
|
const autosaveReady = !isNew || form.content.trim().length > 0
|
||||||
|
|
||||||
@@ -132,7 +173,9 @@ export default function AdminEdit() {
|
|||||||
if (!canSave || !autosaveReady || !dirtyRef.current) return
|
if (!canSave || !autosaveReady || !dirtyRef.current) return
|
||||||
if (timerRef.current) clearTimeout(timerRef.current)
|
if (timerRef.current) clearTimeout(timerRef.current)
|
||||||
timerRef.current = setTimeout(() => {
|
timerRef.current = setTimeout(() => {
|
||||||
if (dirtyRef.current) save.mutate(form)
|
if (dirtyRef.current) {
|
||||||
|
save.mutate({ f: form, auto: true })
|
||||||
|
}
|
||||||
}, 2000)
|
}, 2000)
|
||||||
return () => {
|
return () => {
|
||||||
if (timerRef.current) clearTimeout(timerRef.current)
|
if (timerRef.current) clearTimeout(timerRef.current)
|
||||||
@@ -140,162 +183,192 @@ export default function AdminEdit() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [form, canSave, autosaveReady])
|
}, [form, canSave, autosaveReady])
|
||||||
|
|
||||||
const wrapSelection = useCallback(
|
const insert = (snippet: string) => set('content', form.content + snippet)
|
||||||
(before: string, after = before, placeholder = '文本') => {
|
|
||||||
const el = document.querySelector<HTMLElement>('.cm-content')
|
|
||||||
// 简化实现:在内容末尾/光标处理由 CodeMirror 自行接管,这里直接改源码
|
|
||||||
void el
|
|
||||||
set('content', form.content + `${before}${placeholder}${after}`)
|
|
||||||
},
|
|
||||||
[form.content],
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!isNew && isLoading) {
|
if (!isNew && isLoading) {
|
||||||
return <p className="py-12 text-center text-zinc-400">加载中…</p>
|
return (
|
||||||
|
<>
|
||||||
|
<AdminTopbar title="编辑笔记" />
|
||||||
|
<div className="pn-admin-content"><div className="pn-admin-loading"><Spin /></div></div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const slugDisplay = form.slug || (form.title ? slugPreview(form.title, new Date()) : '保存后自动生成')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-8rem)] flex-col">
|
<>
|
||||||
<div className="mb-3 flex items-center gap-3">
|
<AdminTopbar title={isNew ? '新建笔记' : '编辑笔记'}>
|
||||||
<input
|
<Button
|
||||||
value={form.title}
|
type="text"
|
||||||
onChange={(e) => set('title', e.target.value)}
|
icon={sourceMode ? <Eye size={15} /> : <FileCode2 size={15} />}
|
||||||
placeholder="标题(必填)"
|
onClick={() => setSourceMode((v) => !v)}
|
||||||
className="min-w-0 flex-1 rounded-lg border border-transparent bg-transparent px-2 py-1.5 text-xl font-semibold outline-none hover:border-zinc-200 focus:border-blue-500 dark:hover:border-zinc-800"
|
title={sourceMode ? '退出源码模式,回到即时渲染' : '查看/编辑 Markdown 源码(逃生舱)'}
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPreview((v) => !v)}
|
|
||||||
className="inline-flex items-center gap-1 rounded-lg border border-zinc-300 px-2.5 py-1.5 text-xs text-zinc-600 dark:border-zinc-700 dark:text-zinc-400"
|
|
||||||
title="切换预览"
|
|
||||||
>
|
>
|
||||||
{showPreview ? <EyeOff size={13} /> : <Eye size={13} />}
|
{sourceMode ? '退出源码' : '源码模式'}
|
||||||
{showPreview ? '隐藏预览' : '显示预览'}
|
</Button>
|
||||||
</button>
|
<Button
|
||||||
<button
|
type="primary"
|
||||||
type="button"
|
icon={<Save size={15} />}
|
||||||
onClick={() => save.mutate(form)}
|
|
||||||
disabled={!canSave || save.isPending}
|
disabled={!canSave || save.isPending}
|
||||||
className="rounded-lg bg-zinc-900 px-4 py-1.5 text-sm font-medium text-white disabled:opacity-50 dark:bg-zinc-100 dark:text-zinc-900"
|
onClick={() => save.mutate({ f: form, auto: false })}
|
||||||
>
|
>
|
||||||
{save.isPending ? '保存中…' : '保存'}
|
{save.isPending ? '保存中…' : isNew ? '保存草稿' : '保存'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</AdminTopbar>
|
||||||
|
|
||||||
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-zinc-400">
|
<div className="pn-admin-content">
|
||||||
<button type="button" onClick={() => wrapSelection('**')} className="rounded p-1 hover:bg-zinc-100 dark:hover:bg-zinc-800"><Bold size={14} /></button>
|
<div className="pn-ae-grid">
|
||||||
<button type="button" onClick={() => wrapSelection('*')} className="rounded p-1 hover:bg-zinc-100 dark:hover:bg-zinc-800"><Italic size={14} /></button>
|
<Card className="pn-ae-main min-w-0">
|
||||||
<button type="button" onClick={() => set('content', form.content + '[标题](https://)')} className="rounded p-1 hover:bg-zinc-100 dark:hover:bg-zinc-800"><Link2 size={14} /></button>
|
<Input
|
||||||
<button type="button" onClick={() => set('content', form.content + '\n```\n代码\n```\n')} className="rounded p-1 hover:bg-zinc-100 dark:hover:bg-zinc-800"><Code size={14} /></button>
|
variant="filled"
|
||||||
<button type="button" onClick={() => set('content', form.content + '\n| 列1 | 列2 |\n| --- | --- |\n| a | b |\n')} className="rounded p-1 hover:bg-zinc-100 dark:hover:bg-zinc-800"><Table size={14} /></button>
|
className="pn-ae-title-input"
|
||||||
<span className="mx-1 text-zinc-300 dark:text-zinc-700">|</span>
|
type="text"
|
||||||
<span>粘贴 / 拖拽图片即可上传</span>
|
value={form.title}
|
||||||
<span className="ml-auto">
|
onChange={(e) => set('title', e.target.value)}
|
||||||
{save.isPending
|
placeholder="输入标题…"
|
||||||
? '保存中…'
|
aria-label="笔记标题"
|
||||||
: savedAt
|
/>
|
||||||
? `已保存 ${new Date(savedAt).toLocaleTimeString('zh-CN')}`
|
|
||||||
: isNew
|
|
||||||
? '未保存'
|
|
||||||
: '无改动'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 gap-3">
|
<div className="pn-ae-meta">
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-3">
|
<span className="pn-ae-slug" title={`/notes/${slugDisplay}`}>
|
||||||
<div className="min-h-0 flex-1">
|
<Link2 size={12} />
|
||||||
<Suspense fallback={<div className="flex h-full items-center justify-center text-sm text-zinc-500">编辑器加载中…</div>}>
|
<span>/notes/{slugDisplay}</span>
|
||||||
<Editor
|
</span>
|
||||||
value={form.content}
|
<div className="pn-ae-tags">
|
||||||
onChange={(v) => set('content', v)}
|
<Select
|
||||||
onUploadStart={(name) => setUploadMsg(`上传中:${name}…`)}
|
mode="tags"
|
||||||
onUploadEnd={(err) => setUploadMsg(err ?? null)}
|
className="pn-ae-tags-select"
|
||||||
/>
|
value={form.tags}
|
||||||
</Suspense>
|
options={tagOptions}
|
||||||
</div>
|
placeholder="添加或选择标签"
|
||||||
{uploadMsg && <p className="text-xs text-zinc-400">{uploadMsg}</p>}
|
aria-label="标签"
|
||||||
{showPreview && (
|
tokenSeparators={[',']}
|
||||||
<div className="hidden min-h-0 flex-1 overflow-auto rounded-lg border border-zinc-200 p-4 lg:block dark:border-zinc-800">
|
onChange={(v) => set('tags', v)}
|
||||||
<MarkdownViewer source={form.content} />
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<aside className="w-64 shrink-0 space-y-4 overflow-auto rounded-lg border border-zinc-200 p-4 dark:border-zinc-800">
|
<div className="pn-ae-toolbar" role="toolbar" aria-label="格式工具">
|
||||||
<div>
|
<Button type="text" className="pn-ae-tool" aria-label="加粗" title="加粗" icon={<Bold size={15} />} onClick={() => insert('**文本**')} />
|
||||||
<label className="text-xs font-medium text-zinc-500">slug</label>
|
<Button type="text" className="pn-ae-tool" aria-label="斜体" title="斜体" icon={<Italic size={15} />} onClick={() => insert('*文本*')} />
|
||||||
<input
|
<Button type="text" className="pn-ae-tool" aria-label="插入链接" title="插入链接" icon={<Link2 size={15} />} onClick={() => insert('[标题](https://)')} />
|
||||||
value={form.slug}
|
<Button type="text" className="pn-ae-tool" aria-label="插入代码" title="插入代码" icon={<Code size={15} />} onClick={() => insert('\n```\n代码\n```\n')} />
|
||||||
onChange={(e) => set('slug', e.target.value)}
|
<Button type="text" className="pn-ae-tool" aria-label="无序列表" title="无序列表" icon={<List size={15} />} onClick={() => insert('\n- 列表项\n')} />
|
||||||
placeholder={form.title ? slugPreview(form.title, new Date()) : '保存后自动生成'}
|
<Button type="text" className="pn-ae-tool" aria-label="引用" title="引用" icon={<Quote size={15} />} onClick={() => insert('\n> 引用\n')} />
|
||||||
className={`mt-1 w-full rounded-md border px-2 py-1.5 font-mono text-xs outline-none ${
|
<Button type="text" className="pn-ae-tool" aria-label="插入表格" title="插入表格" icon={<Table size={15} />} onClick={() => insert('\n| 列1 | 列2 |\n| --- | --- |\n| a | b |\n')} />
|
||||||
slugFieldError
|
<span className="pn-ae-tool-hint">{sourceMode ? '源码模式 · Markdown' : '即时渲染 · Markdown'}</span>
|
||||||
? 'border-red-500 bg-red-50 dark:bg-red-950'
|
</div>
|
||||||
: 'border-zinc-300 dark:border-zinc-700'
|
|
||||||
}`}
|
<div className="pn-ae-body-wrap">
|
||||||
/>
|
<Suspense fallback={<div className="pn-admin-loading" style={{ minHeight: 420 }}><Spin /></div>}>
|
||||||
{slugFieldError && <p className="mt-1 text-xs text-red-600">{slugFieldError}</p>}
|
<Editor
|
||||||
<p className="mt-1 text-[10px] leading-relaxed text-zinc-400">
|
value={form.content}
|
||||||
留空保持不变;首次保存自动生成后定稿
|
onChange={(v) => set('content', v)}
|
||||||
</p>
|
sourceMode={sourceMode}
|
||||||
</div>
|
onUploadStart={(name) => setUploadMsg({ kind: 'busy', text: `上传中:${name}…` })}
|
||||||
<div>
|
onUploadEnd={(err) => setUploadMsg(err ? { kind: 'error', text: err } : null)}
|
||||||
<label className="text-xs font-medium text-zinc-500">标签(逗号分隔)</label>
|
/>
|
||||||
<input
|
</Suspense>
|
||||||
value={form.tags}
|
</div>
|
||||||
onChange={(e) => set('tags', e.target.value)}
|
{uploadMsg && (
|
||||||
placeholder="随笔, go"
|
<Alert
|
||||||
className="mt-1 w-full rounded-md border border-zinc-300 px-2 py-1.5 text-xs outline-none focus:border-blue-500 dark:border-zinc-700"
|
className="pn-ae-upload-alert"
|
||||||
/>
|
type={uploadMsg.kind === 'busy' ? 'info' : 'error'}
|
||||||
</div>
|
showIcon
|
||||||
<div>
|
message={uploadMsg.text}
|
||||||
<label className="text-xs font-medium text-zinc-500">摘要(留空自动截取正文)</label>
|
|
||||||
<textarea
|
|
||||||
value={form.summary}
|
|
||||||
onChange={(e) => set('summary', e.target.value)}
|
|
||||||
rows={3}
|
|
||||||
className="mt-1 w-full resize-none rounded-md border border-zinc-300 px-2 py-1.5 text-xs outline-none focus:border-blue-500 dark:border-zinc-700"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-xs font-medium text-zinc-500">公开</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="switch"
|
|
||||||
aria-checked={form.status === 'public'}
|
|
||||||
onClick={() => set('status', form.status === 'public' ? 'private' : 'public')}
|
|
||||||
className={`relative h-5 w-9 rounded-full transition ${
|
|
||||||
form.status === 'public' ? 'bg-green-500' : 'bg-zinc-300 dark:bg-zinc-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all ${
|
|
||||||
form.status === 'public' ? 'left-[1.15rem]' : 'left-0.5'
|
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
</button>
|
)}
|
||||||
</div>
|
</Card>
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-xs font-medium text-zinc-500">置顶</span>
|
<aside className="pn-ae-side">
|
||||||
<button
|
<Card styles={{ body: { padding: 20 } }}>
|
||||||
type="button"
|
<h3 className="pn-ae-side-title">发布设置</h3>
|
||||||
role="switch"
|
|
||||||
aria-checked={form.pinned}
|
<div className="pn-ae-prow">
|
||||||
onClick={() => set('pinned', !form.pinned)}
|
<span className="pn-ae-plabel">状态</span>
|
||||||
className={`relative h-5 w-9 rounded-full transition ${
|
<Badge
|
||||||
form.pinned ? 'bg-amber-500' : 'bg-zinc-300 dark:bg-zinc-700'
|
status={form.status === 'public' ? 'success' : 'default'}
|
||||||
}`}
|
text={form.status === 'public' ? '公开' : '私有'}
|
||||||
>
|
/>
|
||||||
<span
|
<Switch
|
||||||
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all ${
|
checked={form.status === 'public'}
|
||||||
form.pinned ? 'left-[1.15rem]' : 'left-0.5'
|
onChange={(checked) => set('status', checked ? 'public' : 'private')}
|
||||||
}`}
|
aria-label="公开笔记"
|
||||||
/>
|
/>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
|
||||||
{errorMsg && <p className="text-xs text-red-600">{errorMsg}</p>}
|
<div className="pn-ae-prow">
|
||||||
</aside>
|
<span className="pn-ae-plabel">置顶</span>
|
||||||
|
<Switch
|
||||||
|
checked={form.pinned}
|
||||||
|
onChange={(checked) => set('pinned', checked)}
|
||||||
|
aria-label="置顶笔记"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pn-ae-prow">
|
||||||
|
<span className="pn-ae-plabel">发布日期</span>
|
||||||
|
<DatePicker
|
||||||
|
className="pn-ae-datepicker"
|
||||||
|
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 className="pn-ae-pfield">
|
||||||
|
<label className="pn-field-label text-xs" htmlFor="pn-ae-slug-input">固定链接</label>
|
||||||
|
<Input
|
||||||
|
id="pn-ae-slug-input"
|
||||||
|
type="text"
|
||||||
|
value={form.slug}
|
||||||
|
spellCheck={false}
|
||||||
|
status={slugFieldError ? 'error' : undefined}
|
||||||
|
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)}
|
||||||
|
/>
|
||||||
|
<span className="pn-ae-phint">留空保持不变;首次保存自动生成后定稿</span>
|
||||||
|
{slugFieldError && <span className="pn-ae-error">{slugFieldError}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pn-ae-pfield">
|
||||||
|
<label className="pn-field-label text-xs" htmlFor="pn-ae-summary">摘要</label>
|
||||||
|
<Input.TextArea
|
||||||
|
id="pn-ae-summary"
|
||||||
|
rows={3}
|
||||||
|
styles={{ textarea: { fontSize: 12.5, lineHeight: 1.6 } }}
|
||||||
|
value={form.summary}
|
||||||
|
placeholder="留空自动截取正文"
|
||||||
|
onChange={(e) => set('summary', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errorMsg && <p className="pn-ae-error">{errorMsg}</p>}
|
||||||
|
|
||||||
|
{!isNew && (
|
||||||
|
<>
|
||||||
|
<Divider className="pn-ae-divider" />
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
block
|
||||||
|
icon={<Trash2 size={15} />}
|
||||||
|
disabled={trashMutation.isPending}
|
||||||
|
onClick={() => trashMutation.mutate()}
|
||||||
|
>
|
||||||
|
{trashMutation.isPending ? '删除中…' : '移至回收站'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+231
-111
@@ -1,11 +1,18 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router'
|
import { Link, useNavigate } from 'react-router'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Pencil, Plus, Trash2 } from 'lucide-react'
|
import { CirclePlus, Eye, EyeOff, Pencil, Pin, Search, Trash2 } from 'lucide-react'
|
||||||
import { 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 { useAuth } from '../lib/auth'
|
import { invalidatePublic } from '../lib/invalidate'
|
||||||
import { formatDateTime } from '../lib/utils'
|
import { useDocumentTitle } from '../features/site'
|
||||||
|
import { formatDateISO } from '../lib/utils'
|
||||||
|
import { AdminTopbar } from '../components/Layout'
|
||||||
|
import { useToast } from '@/hooks/use-toast'
|
||||||
|
|
||||||
|
type StatusFilter = 'all' | 'public' | 'private'
|
||||||
|
|
||||||
function useAdminNotes() {
|
function useAdminNotes() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -15,18 +22,24 @@ function useAdminNotes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminList() {
|
export default function AdminList() {
|
||||||
|
useDocumentTitle('笔记管理')
|
||||||
const { data, isLoading } = useAdminNotes()
|
const { data, isLoading } = useAdminNotes()
|
||||||
const { logout } = useAuth()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const [confirmId, setConfirmId] = useState<number | null>(null)
|
const navigate = useNavigate()
|
||||||
|
const { toast } = useToast()
|
||||||
|
const [confirmNote, setConfirmNote] = useState<NoteItem | null>(null)
|
||||||
|
const [filter, setFilter] = useState<StatusFilter>('all')
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
|
||||||
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: () => {
|
||||||
setConfirmId(null)
|
setConfirmNote(null)
|
||||||
|
toast({ description: '笔记已移至回收站' })
|
||||||
void qc.invalidateQueries({ queryKey: ['admin'] })
|
void qc.invalidateQueries({ queryKey: ['admin'] })
|
||||||
|
invalidatePublic(qc)
|
||||||
},
|
},
|
||||||
|
onError: () => toast({ description: '操作失败,请重试', variant: 'error' }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const toggleStatus = useMutation({
|
const toggleStatus = useMutation({
|
||||||
@@ -46,114 +59,221 @@ export default function AdminList() {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onSuccess: () => void qc.invalidateQueries({ queryKey: ['admin'] }),
|
onSuccess: (_d, note) => {
|
||||||
|
toast({ description: note.status === 'public' ? '已转为私有' : '已转为公开' })
|
||||||
|
void qc.invalidateQueries({ queryKey: ['admin'] })
|
||||||
|
invalidatePublic(qc)
|
||||||
|
},
|
||||||
|
onError: () => toast({ description: '操作失败,请重试', variant: 'error' }),
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
const items = data?.items ?? []
|
||||||
<div>
|
const publicCount = items.filter((n) => n.status === 'public').length
|
||||||
<div className="mb-4 flex items-center justify-between">
|
const privateCount = items.length - publicCount
|
||||||
<h1 className="text-lg font-semibold">笔记管理({data?.total ?? 0})</h1>
|
|
||||||
<div className="flex gap-2">
|
const visible = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase()
|
||||||
|
return items.filter((n) => {
|
||||||
|
if (filter === 'public' && n.status !== 'public') return false
|
||||||
|
if (filter === 'private' && n.status === 'public') return false
|
||||||
|
if (q) {
|
||||||
|
const hay = `${n.title} ${n.slug} ${n.tags.join(' ')}`.toLowerCase()
|
||||||
|
if (!hay.includes(q)) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}, [items, filter, query])
|
||||||
|
|
||||||
|
const columns: TableProps<NoteItem>['columns'] = [
|
||||||
|
{
|
||||||
|
title: '标题',
|
||||||
|
key: 'title',
|
||||||
|
render: (_, n) => (
|
||||||
|
<>
|
||||||
<Link
|
<Link
|
||||||
to="/admin/notes/new"
|
to={`/admin/notes/${n.id}/edit`}
|
||||||
className="inline-flex items-center gap-1 rounded-lg bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
className="pn-al-note-title"
|
||||||
|
style={{ textDecoration: 'none' }}
|
||||||
>
|
>
|
||||||
<Plus size={15} /> 新建
|
{n.pinned && <Pin size={12} aria-label="置顶" />}
|
||||||
|
<span>{n.title}</span>
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<span className="pn-al-note-slug">/{n.slug}</span>
|
||||||
type="button"
|
</>
|
||||||
onClick={() => void logout().then(() => navigate('/'))}
|
),
|
||||||
className="rounded-lg border border-zinc-300 px-3 py-1.5 text-sm text-zinc-600 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800"
|
},
|
||||||
>
|
{
|
||||||
登出
|
title: '标签',
|
||||||
</button>
|
key: 'tags',
|
||||||
</div>
|
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 (
|
||||||
|
<>
|
||||||
|
<AdminTopbar title="笔记管理">
|
||||||
|
<Input
|
||||||
|
className="pn-al-search"
|
||||||
|
allowClear
|
||||||
|
placeholder="搜索笔记…"
|
||||||
|
aria-label="搜索笔记"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
prefix={<Search size={14} aria-hidden />}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<CirclePlus size={15} />}
|
||||||
|
onClick={() => void navigate('/admin/notes/new')}
|
||||||
|
>
|
||||||
|
新建笔记
|
||||||
|
</Button>
|
||||||
|
</AdminTopbar>
|
||||||
|
|
||||||
|
<div className="pn-admin-content">
|
||||||
|
<section className="pn-al-metrics">
|
||||||
|
<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]">
|
||||||
|
{data?.total ?? items.length}
|
||||||
|
</strong>
|
||||||
|
<span className="text-xs leading-normal text-muted-foreground max-md:hidden">
|
||||||
|
全部笔记,含 {privateCount} 篇私有
|
||||||
|
</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]">
|
||||||
|
{publicCount}
|
||||||
|
</strong>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<Card className="pn-al-card min-w-0 flex-1 overflow-hidden" styles={{ body: { padding: 0 } }}>
|
||||||
|
<div className="pn-al-table-head">
|
||||||
|
<Segmented
|
||||||
|
value={filter}
|
||||||
|
onChange={(v) => setFilter(v as StatusFilter)}
|
||||||
|
className="min-w-0"
|
||||||
|
options={[
|
||||||
|
{ value: 'all', label: <>全部<Badge count={items.length} showZero size="small" className="pn-al-tab-badge" /></> },
|
||||||
|
{ value: 'public', label: <>已发布<Badge count={publicCount} showZero size="small" className="pn-al-tab-badge" /></> },
|
||||||
|
{ value: 'private', label: <>私有<Badge count={privateCount} showZero size="small" className="pn-al-tab-badge" /></> },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<span className="pn-al-table-note">共 {visible.length} 篇笔记</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
className="pn-al-table"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={visible}
|
||||||
|
rowKey="id"
|
||||||
|
loading={isLoading}
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ x: 600 }}
|
||||||
|
rowClassName={(n) => (n.status === 'public' ? '' : 'pn-al-is-draft')}
|
||||||
|
locale={{
|
||||||
|
emptyText: (
|
||||||
|
<Empty
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
description={items.length === 0 ? '还没有笔记,点击「新建笔记」开始' : '没有符合筛选条件的笔记'}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="pn-al-table-foot">
|
||||||
|
<span className="pn-al-foot-note">共 {data?.total ?? 0} 篇 · 显示 {visible.length} 篇</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
<Modal
|
||||||
<p className="py-12 text-center text-zinc-400">加载中…</p>
|
open={confirmNote !== null}
|
||||||
) : !data || data.items.length === 0 ? (
|
onCancel={() => setConfirmNote(null)}
|
||||||
<p className="py-12 text-center text-zinc-400">还没有笔记,点击「新建」开始</p>
|
title="移入回收站?"
|
||||||
) : (
|
width={380}
|
||||||
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-900 dark:border-zinc-800">
|
centered
|
||||||
{data.items.map((n) => (
|
maskClosable={false}
|
||||||
<li key={n.id} className="flex items-center gap-3 px-4 py-3">
|
cancelText="取消"
|
||||||
<span
|
okText={trashMutation.isPending ? '删除中…' : '确认删除'}
|
||||||
className={`rounded px-1.5 py-0.5 text-xs ${
|
confirmLoading={trashMutation.isPending}
|
||||||
n.status === 'public'
|
onOk={() => {
|
||||||
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400'
|
if (confirmNote) trashMutation.mutate(confirmNote.id)
|
||||||
: 'bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400'
|
}}
|
||||||
}`}
|
>
|
||||||
>
|
<p className="pn-modal-desc">
|
||||||
{n.status === 'public' ? '公开' : '私有'}
|
「{confirmNote?.title}」将被软删除,30 天内可在回收站恢复。
|
||||||
</span>
|
</p>
|
||||||
{n.pinned && <span className="text-xs text-amber-600">置顶</span>}
|
{trashMutation.isError && <p className="pn-modal-error">操作失败,请重试</p>}
|
||||||
<div className="min-w-0 flex-1">
|
</Modal>
|
||||||
<Link to={`/admin/notes/${n.id}/edit`} className="block truncate font-medium hover:text-blue-600">
|
</>
|
||||||
{n.title}
|
|
||||||
</Link>
|
|
||||||
<p className="truncate text-xs text-zinc-400">
|
|
||||||
/{n.slug} · {formatDateTime(n.updated_at)}
|
|
||||||
{n.tags.length > 0 && ' · ' + n.tags.map((t) => '#' + t).join(' ')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void toggleStatus.mutate(n)}
|
|
||||||
disabled={toggleStatus.isPending}
|
|
||||||
className="rounded px-2 py-1 text-xs text-zinc-500 hover:bg-zinc-100 disabled:opacity-50 dark:hover:bg-zinc-800"
|
|
||||||
>
|
|
||||||
{n.status === 'public' ? '转私有' : '转公开'}
|
|
||||||
</button>
|
|
||||||
<Link
|
|
||||||
to={`/admin/notes/${n.id}/edit`}
|
|
||||||
className="rounded p-1.5 text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
|
||||||
title="编辑"
|
|
||||||
>
|
|
||||||
<Pencil size={15} />
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setConfirmId(n.id)}
|
|
||||||
className="rounded p-1.5 text-red-500 hover:bg-red-50 dark:hover:bg-red-950"
|
|
||||||
title="删除(进回收站)"
|
|
||||||
>
|
|
||||||
<Trash2 size={15} />
|
|
||||||
</button>
|
|
||||||
{confirmId === n.id && (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4">
|
|
||||||
<div className="w-full max-w-sm rounded-xl bg-white p-6 dark:bg-zinc-900">
|
|
||||||
<h3 className="font-semibold">移入回收站?</h3>
|
|
||||||
<p className="mt-1 text-sm text-zinc-500">
|
|
||||||
「{n.title}」将被软删除,30 天内可在回收站恢复。
|
|
||||||
</p>
|
|
||||||
{trashMutation.isError && (
|
|
||||||
<p className="mt-2 text-sm text-red-600">操作失败,请重试</p>
|
|
||||||
)}
|
|
||||||
<div className="mt-4 flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setConfirmId(null)}
|
|
||||||
className="rounded-lg border border-zinc-300 px-3 py-1.5 text-sm dark:border-zinc-700"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => trashMutation.mutate(n.id)}
|
|
||||||
disabled={trashMutation.isPending}
|
|
||||||
className="rounded-lg bg-red-600 px-3 py-1.5 text-sm text-white hover:bg-red-700 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{trashMutation.isPending ? '删除中…' : '确认删除'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+185
-91
@@ -1,10 +1,20 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
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 { useDocumentTitle } from '../features/site'
|
||||||
|
import { AdminTopbar } from '../components/Layout'
|
||||||
|
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('设置')
|
||||||
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'),
|
||||||
@@ -12,14 +22,17 @@ export default function AdminSettings() {
|
|||||||
|
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [desc, setDesc] = useState('')
|
const [desc, setDesc] = useState('')
|
||||||
|
const [beian, setBeian] = useState('')
|
||||||
const [pageSize, setPageSize] = useState(10)
|
const [pageSize, setPageSize] = useState(10)
|
||||||
const [msg, setMsg] = useState<string | null>(null)
|
const [logo, setLogo] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
setTitle(data.site_title)
|
setTitle(data.site_title)
|
||||||
setDesc(data.site_desc)
|
setDesc(data.site_desc)
|
||||||
|
setBeian(data.beian_no)
|
||||||
setPageSize(data.page_size)
|
setPageSize(data.page_size)
|
||||||
|
setLogo(data.site_logo ?? '')
|
||||||
}
|
}
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
@@ -27,75 +40,137 @@ 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, page_size: pageSize }),
|
body: JSON.stringify({ site_title: title, site_desc: desc, beian_no: beian, page_size: pageSize, site_logo: logo }),
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setMsg('已保存')
|
toast({ description: '设置已保存' })
|
||||||
void qc.invalidateQueries({ queryKey: ['site'] })
|
void qc.invalidateQueries({ queryKey: ['site'] })
|
||||||
|
invalidatePublic(qc) // page_size 等设置影响公共列表
|
||||||
},
|
},
|
||||||
onError: (e) => setMsg(e instanceof ApiError ? e.message : '保存失败'),
|
onError: (e) => toast({ description: e instanceof ApiError ? e.message : '保存失败', variant: 'error' }),
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
// antd Upload customRequest 的最小结构化类型(避免引入 rc-upload 内部类型路径)
|
||||||
<div className="max-w-lg space-y-10">
|
const uploadLogo = async ({ file, onSuccess, onError }: {
|
||||||
<section>
|
file: unknown
|
||||||
<h1 className="mb-4 text-lg font-semibold">站点设置</h1>
|
onSuccess?: (ret?: unknown, xhr?: XMLHttpRequest) => void
|
||||||
<div className="space-y-4">
|
onError?: (event: Error, body?: unknown) => void
|
||||||
<div>
|
}) => {
|
||||||
<label className="text-xs font-medium text-zinc-500" htmlFor="site-title">站点标题</label>
|
const fd = new FormData()
|
||||||
<input
|
fd.append('file', file as File)
|
||||||
id="site-title"
|
try {
|
||||||
value={title}
|
const res = await api<{ url: string }>('/api/admin/images', { method: 'POST', body: fd })
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
setLogo(res.url)
|
||||||
className="mt-1 w-full rounded-lg border border-zinc-300 bg-transparent px-3 py-2 text-sm outline-none focus:border-blue-500 dark:border-zinc-700"
|
onSuccess?.(res)
|
||||||
/>
|
toast({ description: 'Logo 已上传,记得保存更改' })
|
||||||
</div>
|
} catch (e) {
|
||||||
<div>
|
onError?.(e instanceof Error ? e : new Error('上传失败'))
|
||||||
<label className="text-xs font-medium text-zinc-500" htmlFor="site-desc">副标题 / 描述</label>
|
toast({ description: e instanceof ApiError ? e.message : '上传失败', variant: 'error' })
|
||||||
<textarea
|
}
|
||||||
id="site-desc"
|
}
|
||||||
value={desc}
|
|
||||||
onChange={(e) => setDesc(e.target.value)}
|
|
||||||
rows={2}
|
|
||||||
className="mt-1 w-full resize-none rounded-lg border border-zinc-300 bg-transparent px-3 py-2 text-sm outline-none focus:border-blue-500 dark:border-zinc-700"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs font-medium text-zinc-500" htmlFor="page-size">首页每页条数(1–100)</label>
|
|
||||||
<input
|
|
||||||
id="page-size"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={100}
|
|
||||||
value={pageSize}
|
|
||||||
onChange={(e) => setPageSize(Number(e.target.value))}
|
|
||||||
className="mt-1 w-28 rounded-lg border border-zinc-300 bg-transparent px-3 py-2 text-sm outline-none focus:border-blue-500 dark:border-zinc-700"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => saveSettings.mutate()}
|
|
||||||
disabled={saveSettings.isPending}
|
|
||||||
className="rounded-lg bg-zinc-900 px-4 py-1.5 text-sm font-medium text-white disabled:opacity-50 dark:bg-zinc-100 dark:text-zinc-900"
|
|
||||||
>
|
|
||||||
{saveSettings.isPending ? '保存中…' : '保存设置'}
|
|
||||||
</button>
|
|
||||||
{msg && <span className="text-sm text-zinc-500">{msg}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<ChangePassword />
|
return (
|
||||||
</div>
|
<>
|
||||||
|
<AdminTopbar title="站点设置">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<Save size={15} />}
|
||||||
|
disabled={saveSettings.isPending}
|
||||||
|
onClick={() => saveSettings.mutate()}
|
||||||
|
>
|
||||||
|
{saveSettings.isPending ? '保存中…' : '保存更改'}
|
||||||
|
</Button>
|
||||||
|
</AdminTopbar>
|
||||||
|
|
||||||
|
<div className="pn-admin-content pn-as-content">
|
||||||
|
<Card className="pn-as-card">
|
||||||
|
<div className="pn-as-card-head">
|
||||||
|
<h3 className="pn-as-card-title">站点信息</h3>
|
||||||
|
<p className="pn-as-card-desc">这些信息会显示在博客的公开页面</p>
|
||||||
|
</div>
|
||||||
|
<Divider style={{ margin: '16px 0 20px' }} />
|
||||||
|
<div className="pn-as-fields">
|
||||||
|
<div className="pn-as-field">
|
||||||
|
<label htmlFor="site-title" className="pn-field-label">站点名称</label>
|
||||||
|
<Input
|
||||||
|
id="site-title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="pn-as-field">
|
||||||
|
<label htmlFor="site-desc" className="pn-field-label">站点描述</label>
|
||||||
|
<Input.TextArea
|
||||||
|
id="site-desc"
|
||||||
|
rows={3}
|
||||||
|
styles={{ textarea: { minHeight: 76, padding: '8px 12px', lineHeight: 1.7 } }}
|
||||||
|
value={desc}
|
||||||
|
onChange={(e) => setDesc(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="pn-as-field">
|
||||||
|
<label htmlFor="beian-no" className="pn-field-label">公安备案号</label>
|
||||||
|
<Input
|
||||||
|
id="beian-no"
|
||||||
|
placeholder="如:京公网安备11000000000000号"
|
||||||
|
value={beian}
|
||||||
|
onChange={(e) => setBeian(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="pn-as-hint">显示在博客页脚并链接至公安部备案官网;留空则不显示。</p>
|
||||||
|
</div>
|
||||||
|
<div className="pn-as-field">
|
||||||
|
<label htmlFor="page-size" className="pn-field-label">首页每页条数</label>
|
||||||
|
<Select
|
||||||
|
id="page-size"
|
||||||
|
style={{ width: 140 }}
|
||||||
|
options={PAGE_SIZE_OPTIONS}
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(v) => setPageSize(v)}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ChangePassword onToast={toast} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChangePassword() {
|
function ChangePassword({
|
||||||
|
onToast,
|
||||||
|
}: {
|
||||||
|
onToast: ReturnType<typeof useToast>['toast']
|
||||||
|
}) {
|
||||||
const [oldPw, setOldPw] = useState('')
|
const [oldPw, setOldPw] = useState('')
|
||||||
const [newPw, setNewPw] = useState('')
|
const [newPw, setNewPw] = useState('')
|
||||||
const [msg, setMsg] = useState<string | null>(null)
|
const [showPw, setShowPw] = useState(false)
|
||||||
const [ok, setOk] = useState(false)
|
|
||||||
|
|
||||||
const change = useMutation({
|
const change = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
@@ -104,57 +179,76 @@ function ChangePassword() {
|
|||||||
body: JSON.stringify({ old_password: oldPw, new_password: newPw }),
|
body: JSON.stringify({ old_password: oldPw, new_password: newPw }),
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setOk(true)
|
onToast({ description: '密码已修改' })
|
||||||
setMsg('密码已修改')
|
|
||||||
setOldPw('')
|
setOldPw('')
|
||||||
setNewPw('')
|
setNewPw('')
|
||||||
},
|
},
|
||||||
onError: (e) => {
|
onError: (e) => {
|
||||||
setOk(false)
|
|
||||||
if (e instanceof ApiError) {
|
if (e instanceof ApiError) {
|
||||||
setMsg(e.code === 'weak_password' ? '新密码长度至少 12 个字符' : e.status === 429 ? '尝试过于频繁,请稍后再试' : '旧密码不正确')
|
onToast({
|
||||||
|
description:
|
||||||
|
e.code === 'weak_password'
|
||||||
|
? '新密码长度至少 12 个字符'
|
||||||
|
: e.status === 429
|
||||||
|
? '尝试过于频繁,请稍后再试'
|
||||||
|
: '旧密码不正确',
|
||||||
|
variant: 'error',
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
setMsg('修改失败')
|
onToast({ description: '修改失败', variant: 'error' })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<Card className="pn-as-card">
|
||||||
<h2 className="mb-4 text-lg font-semibold">修改密码</h2>
|
<div className="pn-as-card-head">
|
||||||
<div className="space-y-4">
|
<h3 className="pn-as-card-title">修改密码</h3>
|
||||||
<div>
|
<p className="pn-as-card-desc">更新管理后台的登录密码</p>
|
||||||
<label className="text-xs font-medium text-zinc-500" htmlFor="old-pw">旧密码</label>
|
</div>
|
||||||
<input
|
<Divider style={{ margin: '16px 0 20px' }} />
|
||||||
|
<div className="pn-as-fields">
|
||||||
|
<div className="pn-as-field">
|
||||||
|
<label htmlFor="old-pw" className="pn-field-label">旧密码</label>
|
||||||
|
<Input
|
||||||
id="old-pw"
|
id="old-pw"
|
||||||
type="password"
|
type={showPw ? 'text' : 'password'}
|
||||||
|
autoComplete="current-password"
|
||||||
|
prefix={<Lock size={15} className="pn-input-icon" aria-hidden />}
|
||||||
value={oldPw}
|
value={oldPw}
|
||||||
onChange={(e) => setOldPw(e.target.value)}
|
onChange={(e) => setOldPw(e.target.value)}
|
||||||
className="mt-1 w-full rounded-lg border border-zinc-300 bg-transparent px-3 py-2 text-sm outline-none focus:border-blue-500 dark:border-zinc-700"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="pn-as-field">
|
||||||
<label className="text-xs font-medium text-zinc-500" htmlFor="new-pw">新密码(≥12 字符)</label>
|
<label htmlFor="new-pw" className="pn-field-label">新密码(≥12 字符)</label>
|
||||||
<input
|
<Input
|
||||||
id="new-pw"
|
id="new-pw"
|
||||||
type="password"
|
type={showPw ? 'text' : 'password'}
|
||||||
value={newPw}
|
autoComplete="new-password"
|
||||||
onChange={(e) => setNewPw(e.target.value)}
|
placeholder="请输入新密码,至少 12 个字符"
|
||||||
className="mt-1 w-full rounded-lg border border-zinc-300 bg-transparent px-3 py-2 text-sm outline-none focus:border-blue-500 dark:border-zinc-700"
|
prefix={<Lock size={15} className="pn-input-icon" aria-hidden />}
|
||||||
|
suffix={
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
aria-label={showPw ? '隐藏密码' : '显示密码'}
|
||||||
|
onClick={() => setShowPw((v) => !v)}
|
||||||
|
icon={showPw ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||||
|
/>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
<p className="pn-as-hint">新密码长度至少 12 个字符,建议混合字母、数字与符号。</p>
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => change.mutate()}
|
|
||||||
disabled={change.isPending || oldPw.length === 0 || newPw.length === 0}
|
|
||||||
className="rounded-lg bg-zinc-900 px-4 py-1.5 text-sm font-medium text-white disabled:opacity-50 dark:bg-zinc-100 dark:text-zinc-900"
|
|
||||||
>
|
|
||||||
{change.isPending ? '提交中…' : '修改密码'}
|
|
||||||
</button>
|
|
||||||
{msg && <span className={`text-sm ${ok ? 'text-green-600' : 'text-red-600'}`}>{msg}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<div className="pn-as-card-foot">
|
||||||
|
<Button
|
||||||
|
disabled={change.isPending || oldPw.length === 0 || newPw.length === 0}
|
||||||
|
onClick={() => change.mutate()}
|
||||||
|
>
|
||||||
|
<KeyRound size={15} />
|
||||||
|
{change.isPending ? '提交中…' : '修改密码'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+128
-38
@@ -1,11 +1,20 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { RotateCcw } from 'lucide-react'
|
import { RotateCcw, Trash2 } from 'lucide-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 { formatDateTime } from '../lib/utils'
|
import { invalidatePublic } from '../lib/invalidate'
|
||||||
|
import { useDocumentTitle } from '../features/site'
|
||||||
|
import { daysRemaining, formatDateISO } from '../lib/utils'
|
||||||
|
import { AdminTopbar } from '../components/Layout'
|
||||||
|
import { useToast } from '@/hooks/use-toast'
|
||||||
|
|
||||||
export default function AdminTrash() {
|
export default function AdminTrash() {
|
||||||
|
useDocumentTitle('回收站')
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
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'),
|
||||||
@@ -13,43 +22,124 @@ 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: () => void qc.invalidateQueries({ queryKey: ['admin'] }),
|
onSuccess: () => {
|
||||||
|
toast({ description: '笔记已恢复' })
|
||||||
|
void qc.invalidateQueries({ queryKey: ['admin'] })
|
||||||
|
invalidatePublic(qc)
|
||||||
|
},
|
||||||
|
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 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 (
|
||||||
<div>
|
<>
|
||||||
<h1 className="mb-4 text-lg font-semibold">回收站({data?.total ?? 0})</h1>
|
<AdminTopbar title="回收站">
|
||||||
<p className="mb-4 text-sm text-zinc-400">软删除的笔记保留 30 天,到期由每日 gc 物理清除;恢复后图片引用天然保全。</p>
|
<Button
|
||||||
{isLoading ? (
|
danger
|
||||||
<p className="py-12 text-center text-zinc-400">加载中…</p>
|
icon={<Trash2 size={15} />}
|
||||||
) : !data || data.items.length === 0 ? (
|
disabled={isLoading || items.length === 0 || clear.isPending}
|
||||||
<p className="py-12 text-center text-zinc-400">回收站是空的</p>
|
onClick={confirmEmpty}
|
||||||
) : (
|
>
|
||||||
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-900 dark:border-zinc-800">
|
清空回收站
|
||||||
{data.items.map((n) => (
|
</Button>
|
||||||
<li key={n.id} className="flex items-center gap-3 px-4 py-3">
|
</AdminTopbar>
|
||||||
<span className="rounded bg-zinc-100 px-1.5 py-0.5 text-xs text-zinc-500 dark:bg-zinc-800">
|
|
||||||
{n.status === 'public' ? '公开' : '私有'}
|
<div className="pn-admin-content">
|
||||||
</span>
|
<Alert
|
||||||
<div className="min-w-0 flex-1">
|
role="note"
|
||||||
<p className="truncate font-medium">{n.title}</p>
|
type="info"
|
||||||
<p className="truncate text-xs text-zinc-400">
|
showIcon
|
||||||
/{n.slug} · 删除于 {formatDateTime(n.deleted_at ?? 0)}
|
message="回收站中的笔记会在删除 30 天后永久删除,请及时恢复需要的内容。"
|
||||||
</p>
|
/>
|
||||||
</div>
|
|
||||||
<button
|
<Card className="pn-al-card min-w-0 flex-1 overflow-hidden" styles={{ body: { padding: 0 } }}>
|
||||||
type="button"
|
<Table
|
||||||
onClick={() => restore.mutate(n.id)}
|
className="pn-al-table"
|
||||||
disabled={restore.isPending}
|
columns={columns}
|
||||||
className="inline-flex items-center gap-1 rounded px-2 py-1 text-xs text-zinc-600 hover:bg-zinc-100 disabled:opacity-50 dark:text-zinc-400 dark:hover:bg-zinc-800"
|
dataSource={items}
|
||||||
>
|
rowKey="id"
|
||||||
<RotateCcw size={13} /> 恢复
|
loading={isLoading}
|
||||||
</button>
|
pagination={false}
|
||||||
</li>
|
scroll={{ x: 560 }}
|
||||||
))}
|
locale={{
|
||||||
</ul>
|
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="回收站是空的" />,
|
||||||
)}
|
}}
|
||||||
{restore.isError && <p className="mt-3 text-sm text-red-600">恢复失败,请重试</p>}
|
/>
|
||||||
</div>
|
|
||||||
|
<div className="pn-al-table-foot">
|
||||||
|
<span className="pn-al-foot-note">共 {data?.total ?? 0} 篇已删除笔记</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+66
-93
@@ -1,126 +1,99 @@
|
|||||||
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 { 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 { formatDate } from '../lib/utils'
|
import { useDocumentTitle } from '../features/site'
|
||||||
|
import { formatDateISO } from '../lib/utils'
|
||||||
|
|
||||||
function useNotes(page: number, tag?: string) {
|
function useNotes(page: number) {
|
||||||
const qs = new URLSearchParams({ page: String(page), page_size: '10' })
|
// 不传 page_size:服务端默认值即站点设置(管理员可改),首页随设置生效
|
||||||
if (tag) qs.set('tag', tag)
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['notes', page, tag ?? ''],
|
queryKey: ['notes', page],
|
||||||
queryFn: () => api<NoteList>(`/api/notes?${qs}`),
|
queryFn: () => api<NoteList>(`/api/notes?page=${page}`),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function useTags() {
|
function NotePost({ note }: { note: NoteItem }) {
|
||||||
return useQuery({
|
|
||||||
queryKey: ['tags'],
|
|
||||||
queryFn: () => api<{ tags: TagCount[] }>('/api/tags'),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function NoteCard({ note }: { note: NoteItem }) {
|
|
||||||
return (
|
return (
|
||||||
<article className="group border-b border-zinc-100 py-6 first:pt-0 dark:border-zinc-900">
|
<article className="pn-home-post">
|
||||||
<div className="flex items-center gap-2">
|
{note.cover && (
|
||||||
{note.pinned && (
|
|
||||||
<span className="inline-flex items-center gap-1 rounded bg-amber-100 px-1.5 py-0.5 text-xs text-amber-700 dark:bg-amber-900/40 dark:text-amber-400">
|
|
||||||
<Pin size={11} /> 置顶
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<time className="text-xs text-zinc-400">{formatDate(note.updated_at)}</time>
|
|
||||||
</div>
|
|
||||||
<h2 className="mt-1 text-xl font-semibold leading-snug">
|
|
||||||
<Link
|
<Link
|
||||||
to={`/notes/${encodeURIComponent(note.slug)}`}
|
to={`/notes/${encodeURIComponent(note.slug)}`}
|
||||||
className="hover:text-blue-600 dark:hover:text-blue-400"
|
className="pn-home-cover"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-hidden
|
||||||
>
|
>
|
||||||
{note.title}
|
<img src={note.cover} alt="" loading="lazy" />
|
||||||
</Link>
|
</Link>
|
||||||
</h2>
|
|
||||||
{note.summary && (
|
|
||||||
<p className="mt-2 line-clamp-3 text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">
|
|
||||||
{note.summary}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{note.tags.length > 0 && (
|
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
|
||||||
{note.tags.map((t) => (
|
|
||||||
<Link
|
|
||||||
key={t}
|
|
||||||
to={`/tags/${encodeURIComponent(t)}`}
|
|
||||||
className="rounded-full bg-zinc-100 px-2.5 py-0.5 text-xs text-zinc-600 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-700"
|
|
||||||
>
|
|
||||||
#{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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
|
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
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-8 lg:flex-row-reverse">
|
<div className="pn-home-content">
|
||||||
<aside className="lg:w-48 lg:shrink-0">
|
<section aria-label="笔记列表">
|
||||||
{tagsData && tagsData.tags.length > 0 && (
|
|
||||||
<div className="rounded-lg border border-zinc-200 p-4 dark:border-zinc-800">
|
|
||||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wider text-zinc-400">标签</h3>
|
|
||||||
<div className="flex flex-wrap gap-2 lg:flex-col lg:gap-1">
|
|
||||||
{tagsData.tags.map((t) => (
|
|
||||||
<Link
|
|
||||||
key={t.name}
|
|
||||||
to={`/tags/${encodeURIComponent(t.name)}`}
|
|
||||||
className="text-sm text-zinc-600 hover:text-blue-600 dark:text-zinc-400 dark:hover:text-blue-400"
|
|
||||||
>
|
|
||||||
#{t.name} <span className="text-zinc-400">({t.count})</span>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</aside>
|
|
||||||
<section className="min-w-0 flex-1">
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<p className="py-12 text-center text-zinc-400">加载中…</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="py-12 text-center text-zinc-400">还没有公开的笔记</p>
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="还没有公开的笔记" />
|
||||||
) : (
|
) : (
|
||||||
data.items.map((n) => <NoteCard key={n.slug} note={n} />)
|
data.items.map((n) => <NotePost key={n.slug} note={n} />)
|
||||||
)}
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<nav className="mt-8 flex items-center justify-between text-sm">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={page <= 1}
|
|
||||||
onClick={() => setPage((p) => p - 1)}
|
|
||||||
className="rounded px-3 py-1.5 disabled:opacity-40 hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
|
||||||
>
|
|
||||||
← 上一页
|
|
||||||
</button>
|
|
||||||
<span className="text-zinc-400">
|
|
||||||
{page} / {totalPages}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={page >= totalPages}
|
|
||||||
onClick={() => setPage((p) => p + 1)}
|
|
||||||
className="rounded px-3 py-1.5 disabled:opacity-40 hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
|
||||||
>
|
|
||||||
下一页 →
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<nav className="pn-home-pager" aria-label="分页">
|
||||||
|
<Pagination
|
||||||
|
size="small"
|
||||||
|
current={page}
|
||||||
|
pageSize={data?.page_size ?? 10}
|
||||||
|
total={data?.total ?? 0}
|
||||||
|
showSizeChanger={false}
|
||||||
|
showTotal={() => `共 ${data?.total ?? 0} 篇 · 第 ${page} 页 / 共 ${totalPages} 页`}
|
||||||
|
onChange={(p) => setPage(p)}
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+81
-33
@@ -1,13 +1,17 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
import { 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'
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
|
useDocumentTitle('管理员登录')
|
||||||
const { login, authenticated } = useAuth()
|
const { login, authenticated } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
|
const [showPw, setShowPw] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
@@ -24,7 +28,9 @@ export default function Login() {
|
|||||||
navigate('/admin', { replace: true })
|
navigate('/admin', { replace: true })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
setError(err.status === 429 ? '尝试过于频繁,请稍后再试' : '密码错误')
|
if (err.status === 429) setError('尝试过于频繁,请稍后再试')
|
||||||
|
else if (err.status === 403) setError('请求被安全策略拦截,请刷新页面后重试')
|
||||||
|
else setError('密码错误,请重试')
|
||||||
} else {
|
} else {
|
||||||
setError('登录失败,请重试')
|
setError('登录失败,请重试')
|
||||||
}
|
}
|
||||||
@@ -34,38 +40,80 @@ export default function Login() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center px-4">
|
<div className="pn-login-wrap">
|
||||||
<form
|
<div className="pn-login-brand">
|
||||||
onSubmit={onSubmit}
|
<span className="pn-login-eyebrow">ADMIN</span>
|
||||||
className="w-full max-w-sm rounded-xl border border-zinc-200 p-8 dark:border-zinc-800"
|
<span className="pn-login-wordmark">Pure Note</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="pn-login-card" styles={{ body: { padding: 0 } }}>
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
<h1 className="pn-login-title">管理员登录</h1>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<Input
|
||||||
|
id="pn-login-password"
|
||||||
|
size="large"
|
||||||
|
variant="outlined"
|
||||||
|
type={showPw ? 'text' : 'password'}
|
||||||
|
name="password"
|
||||||
|
placeholder="请输入管理密码"
|
||||||
|
autoComplete="current-password"
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
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} />}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert role="alert" type="error" showIcon message={error} className="pn-login-alert" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
htmlType="submit"
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
className="mt-5"
|
||||||
|
disabled={submitting || password.length === 0}
|
||||||
|
>
|
||||||
|
<span>{submitting ? '登录中…' : '登录'}</span>
|
||||||
|
<ArrowRight size={16} />
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
className="pn-login-back"
|
||||||
|
icon={<ArrowLeft size={14} />}
|
||||||
|
onClick={() => navigate('/')}
|
||||||
>
|
>
|
||||||
<div className="mb-6 flex items-center justify-center gap-2 text-zinc-500">
|
返回博客
|
||||||
<Lock size={18} />
|
</Button>
|
||||||
<h1 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">管理员登录</h1>
|
|
||||||
</div>
|
|
||||||
<label className="block text-sm text-zinc-600 dark:text-zinc-400" htmlFor="password">
|
|
||||||
口令
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
autoFocus
|
|
||||||
required
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
className="mt-1.5 w-full rounded-lg border border-zinc-300 bg-transparent px-3 py-2 text-sm outline-none focus:border-blue-500 dark:border-zinc-700"
|
|
||||||
placeholder="输入管理员口令"
|
|
||||||
/>
|
|
||||||
{error && <p className="mt-2 text-sm text-red-600 dark:text-red-400">{error}</p>}
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={submitting || password.length === 0}
|
|
||||||
className="mt-4 w-full rounded-lg bg-zinc-900 py-2 text-sm font-medium text-white hover:bg-zinc-800 disabled:opacity-50 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
|
||||||
>
|
|
||||||
{submitting ? '登录中…' : '登录'}
|
|
||||||
</button>
|
|
||||||
<p className="mt-4 text-center text-xs text-zinc-400">连续失败将被暂时锁定</p>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
import { Link } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
|
import { ArrowLeft } from 'lucide-react'
|
||||||
|
import { Button } from 'antd'
|
||||||
|
import { useDocumentTitle } from '../features/site'
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
|
useDocumentTitle('页面不存在')
|
||||||
|
const navigate = useNavigate()
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[60vh] flex-col items-center justify-center">
|
<div className="pn-404-page">
|
||||||
<p className="text-6xl font-bold text-zinc-200 dark:text-zinc-800">404</p>
|
<p className="pn-404-code">404</p>
|
||||||
<p className="mt-4 text-zinc-500">页面不存在</p>
|
<h1 className="pn-404-title">页面不存在</h1>
|
||||||
<Link to="/" className="mt-6 text-sm text-blue-600 hover:underline">
|
<p className="pn-404-desc">这篇笔记可能已被删除,或链接有误。</p>
|
||||||
← 返回首页
|
<div className="pn-404-actions">
|
||||||
</Link>
|
<Button type="primary" size="small" icon={<ArrowLeft size={16} />} onClick={() => navigate('/')}>
|
||||||
|
返回首页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+73
-47
@@ -1,86 +1,112 @@
|
|||||||
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 { AlertTriangle } 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'
|
||||||
|
import { useDocumentTitle } from '../features/site'
|
||||||
import MarkdownViewer from '../components/MarkdownViewer'
|
import MarkdownViewer from '../components/MarkdownViewer'
|
||||||
import { formatDate } from '../lib/utils'
|
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],
|
||||||
queryFn: () => api<NoteDetail>(`/api/notes/${encodeURIComponent(slug)}`),
|
queryFn: () => api<NoteDetail>(`/api/notes/${encodeURIComponent(slug)}`),
|
||||||
retry: false,
|
retry: false,
|
||||||
})
|
})
|
||||||
|
useDocumentTitle(note?.title)
|
||||||
|
|
||||||
if (isLoading) return <p className="py-12 text-center text-zinc-400">加载中…</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="py-12 text-center">
|
<article className="pn-note-article">
|
||||||
<p className="text-4xl font-bold text-zinc-300 dark:text-zinc-700">404</p>
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="笔记不存在或未公开">
|
||||||
<p className="mt-2 text-zinc-500">笔记不存在或未公开</p>
|
<Button
|
||||||
<Link to="/" className="mt-4 inline-block text-sm text-blue-600 hover:underline">
|
type="text"
|
||||||
← 返回首页
|
size="small"
|
||||||
</Link>
|
className="pn-note-back"
|
||||||
</div>
|
icon={<ArrowLeft size={14} />}
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
>
|
||||||
|
返回首页
|
||||||
|
</Button>
|
||||||
|
</Empty>
|
||||||
|
</article>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const privatePreview = note.status === 'private'
|
const privatePreview = note.status === 'private'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article>
|
<article className="pn-note-article">
|
||||||
<nav className="mb-4 text-xs text-zinc-400">
|
<Button type="text" size="small" className="pn-note-back" icon={<ArrowLeft size={14} />} onClick={() => navigate('/')}>
|
||||||
<Link to="/" className="hover:text-zinc-600">首页</Link>
|
返回首页
|
||||||
<span className="mx-1.5">/</span>
|
</Button>
|
||||||
<span>{note.title}</span>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{privatePreview && authenticated && (
|
{privatePreview && authenticated && (
|
||||||
<div className="mb-6 flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-50 px-4 py-2.5 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300">
|
<Alert
|
||||||
<AlertTriangle size={16} />
|
role="note"
|
||||||
私有预览:此笔记未公开,仅管理员可见
|
type="info"
|
||||||
</div>
|
showIcon={false}
|
||||||
|
className="pn-note-preview-alert"
|
||||||
|
message={
|
||||||
|
<>
|
||||||
|
<Eye size={15} />
|
||||||
|
私有预览:此笔记未公开,仅管理员可见
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<header className="mb-8">
|
<h1 className="pn-note-title">{note.title}</h1>
|
||||||
<h1 className="text-3xl font-bold leading-tight">{note.title}</h1>
|
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-zinc-400">
|
<div className="pn-note-meta">
|
||||||
<time>{formatDate(note.created_at)}</time>
|
<time className="pn-note-meta-date" dateTime={formatDateISO(note.published_at)}>
|
||||||
{note.tags.map((t) => (
|
{formatDateISO(note.published_at)}
|
||||||
<Link
|
</time>
|
||||||
key={t}
|
<span className="pn-note-meta-sep">·</span>
|
||||||
to={`/tags/${encodeURIComponent(t)}`}
|
<span className="pn-note-meta-read"><Clock size={14} />约 {readingMinutes(note.content)} 分钟阅读</span>
|
||||||
className="rounded-full bg-zinc-100 px-2 py-0.5 hover:bg-zinc-200 dark:bg-zinc-800 dark:hover:bg-zinc-700"
|
{note.tags.length > 0 && (
|
||||||
>
|
<>
|
||||||
#{t}
|
<span className="pn-note-meta-sep">·</span>
|
||||||
</Link>
|
{note.tags.map((t) => (
|
||||||
))}
|
<Tag key={t}>
|
||||||
</div>
|
<Link className="pn-tag-link" to={`/tags/${encodeURIComponent(t)}`}>{t}</Link>
|
||||||
</header>
|
</Tag>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<MarkdownViewer source={note.content} />
|
<MarkdownViewer source={note.content} />
|
||||||
|
|
||||||
<nav className="mt-12 flex justify-between gap-4 border-t border-zinc-100 pt-6 text-sm dark:border-zinc-900">
|
<nav className="pn-note-postnav" aria-label="笔记导航">
|
||||||
{note.prev ? (
|
{note.prev ? (
|
||||||
<Link
|
<Link to={`/notes/${encodeURIComponent(note.prev.slug)}`} className="pn-note-nav-link">
|
||||||
to={`/notes/${encodeURIComponent(note.prev.slug)}`}
|
<Card className="pn-note-nav-card" styles={{ body: { padding: 0 } }}>
|
||||||
className="text-zinc-600 hover:text-blue-600 dark:text-zinc-400"
|
<span className="pn-note-nav-label"><ArrowLeft size={14} />上一篇</span>
|
||||||
>
|
<span className="pn-note-nav-title">{note.prev.title}</span>
|
||||||
← {note.prev.title}
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<span />
|
<span />
|
||||||
)}
|
)}
|
||||||
{note.next ? (
|
{note.next ? (
|
||||||
<Link
|
<Link to={`/notes/${encodeURIComponent(note.next.slug)}`} className="pn-note-nav-link">
|
||||||
to={`/notes/${encodeURIComponent(note.next.slug)}`}
|
<Card className="pn-note-nav-card pn-note-nav-next" styles={{ body: { padding: 0 } }}>
|
||||||
className="text-right text-zinc-600 hover:text-blue-600 dark:text-zinc-400"
|
<span className="pn-note-nav-label">下一篇<ArrowRight size={14} /></span>
|
||||||
>
|
<span className="pn-note-nav-title">{note.next.title}</span>
|
||||||
{note.next.title} →
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<span />
|
<span />
|
||||||
|
|||||||
+83
-35
@@ -1,45 +1,93 @@
|
|||||||
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 { Empty, Skeleton, Spin, Tag as AntTag } from 'antd'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import type { NoteList } from '../lib/api'
|
import type { NoteList, TagCount } from '../lib/api'
|
||||||
import { formatDate } from '../lib/utils'
|
import { useDocumentTitle } from '../features/site'
|
||||||
|
import { formatDateISO } from '../lib/utils'
|
||||||
|
|
||||||
|
function useTags() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['tags'],
|
||||||
|
queryFn: () => api<{ tags: TagCount[] }>('/api/tags'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export default function TagPage() {
|
export default function TagPage() {
|
||||||
const { tag = '' } = useParams()
|
const { tag } = useParams()
|
||||||
const { data, isLoading } = useQuery({
|
const navigate = useNavigate()
|
||||||
queryKey: ['tag', tag],
|
useDocumentTitle(tag ? `标签:${tag}` : '标签')
|
||||||
queryFn: () => api<NoteList>(`/api/notes?tag=${encodeURIComponent(tag)}&page=1&page_size=100`),
|
const { data: tagsData } = useTags()
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['tag', tag ?? ''],
|
||||||
|
queryFn: () =>
|
||||||
|
api<NoteList>(`/api/notes?tag=${encodeURIComponent(tag ?? '')}&page=1&page_size=100`),
|
||||||
|
enabled: tag !== undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="pn-tag-content">
|
||||||
<header className="mb-6">
|
<section className="pn-tag-pagehead">
|
||||||
<nav className="mb-2 text-xs text-zinc-400">
|
<h1>标签</h1>
|
||||||
<Link to="/" className="hover:text-zinc-600">首页</Link>
|
<p>按主题浏览所有公开笔记</p>
|
||||||
<span className="mx-1.5">/</span>
|
</section>
|
||||||
<span>标签</span>
|
|
||||||
</nav>
|
{/* 单选 CheckableTagGroup:选中态由组件承接;再次点击已选标签 = 取消(回全部) */}
|
||||||
<h1 className="text-2xl font-bold">#{tag}</h1>
|
<AntTag.CheckableTagGroup
|
||||||
<p className="mt-1 text-sm text-zinc-400">{data?.total ?? 0} 篇</p>
|
multiple={false}
|
||||||
</header>
|
className="pn-tag-cloud"
|
||||||
{isLoading ? (
|
value={tag ?? null}
|
||||||
<p className="py-12 text-center text-zinc-400">加载中…</p>
|
onChange={(v) => navigate(v === null ? '/tags' : `/tags/${encodeURIComponent(v)}`)}
|
||||||
) : !data || data.items.length === 0 ? (
|
options={(tagsData?.tags ?? []).map((t) => ({
|
||||||
<p className="py-12 text-center text-zinc-400">该标签下暂无公开笔记</p>
|
value: t.name,
|
||||||
) : (
|
className: 'pn-cloud-tag',
|
||||||
<ul className="divide-y divide-zinc-100 dark:divide-zinc-900">
|
label: (
|
||||||
{data.items.map((n) => (
|
<>
|
||||||
<li key={n.slug} className="py-4">
|
<Tag size={14} />
|
||||||
<Link
|
<span>{t.name}</span>
|
||||||
to={`/notes/${encodeURIComponent(n.slug)}`}
|
<span className="pn-cloud-count">{t.count}</span>
|
||||||
className="font-medium hover:text-blue-600 dark:hover:text-blue-400"
|
</>
|
||||||
>
|
),
|
||||||
{n.title}
|
}))}
|
||||||
</Link>
|
aria-label="全部标签"
|
||||||
<time className="ml-3 text-xs text-zinc-400">{formatDate(n.updated_at)}</time>
|
/>
|
||||||
</li>
|
{!tagsData && (
|
||||||
))}
|
<div className="pn-tag-cloud"><Spin size="small" /></div>
|
||||||
</ul>
|
)}
|
||||||
|
{tagsData && tagsData.tags.length === 0 && (
|
||||||
|
<div className="pn-tag-empty">
|
||||||
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="还没有标签" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tag === undefined && tagsData && (
|
||||||
|
<p className="pn-home-list-empty" style={{ marginTop: 48 }}>选择一个标签,查看对应笔记</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tag && (
|
||||||
|
<section className="pn-tag-groups">
|
||||||
|
<section>
|
||||||
|
<h3 className="pn-tag-group-title"><Tag size={16} />{tag}</h3>
|
||||||
|
<div className="pn-tag-rows">
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton active title paragraph={{ rows: 3 }} />
|
||||||
|
) : error ? (
|
||||||
|
<p className="pn-home-list-empty">加载失败,请稍后重试</p>
|
||||||
|
) : !data || data.items.length === 0 ? (
|
||||||
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="该标签下暂无公开笔记" />
|
||||||
|
) : (
|
||||||
|
data.items.map((n) => (
|
||||||
|
<Link key={n.slug} to={`/notes/${encodeURIComponent(n.slug)}`} className="pn-tag-row">
|
||||||
|
<time className="pn-tag-date">{formatDateISO(n.updated_at)}</time>
|
||||||
|
<span className="pn-tag-title">{n.title}</span>
|
||||||
|
<span className="pn-tag-arrow" aria-hidden><ArrowRight size={16} /></span>
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// api 客户端单测(评审 round2 P1-2 / P2-15 的回归守护):
|
||||||
|
// 1. FormData 请求不得强设 Content-Type(multipart boundary 由浏览器生成);
|
||||||
|
// 2. 401 触发全局未授权回调(AuthProvider 据此跳登录页)。
|
||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||||
|
import { api, ApiError, setUnauthorizedHandler } from '../src/lib/api'
|
||||||
|
|
||||||
|
function jsonResponse(status: number, body: unknown): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('api 客户端', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
setUnauthorizedHandler(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FormData 请求不强设 Content-Type(保留浏览器 multipart boundary)', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(201, { data: { id: 1, url: '/api/images/1' } }))
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', new Blob(['x'], { type: 'image/png' }), 'a.png')
|
||||||
|
await api<{ id: number }>('/api/admin/images', { method: 'POST', body: form })
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledOnce()
|
||||||
|
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
||||||
|
const headers = init.headers as Headers
|
||||||
|
expect(headers.has('Content-Type')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('JSON 请求默认补 application/json 并携带 CSRF 头', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(200, { data: { ok: true } }))
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
const { setCsrfToken } = await import('../src/lib/api')
|
||||||
|
setCsrfToken('token-abc')
|
||||||
|
|
||||||
|
await api('/api/admin/notes/1', { method: 'PUT', body: JSON.stringify({ title: 'x' }) })
|
||||||
|
|
||||||
|
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
||||||
|
const headers = init.headers as Headers
|
||||||
|
expect(headers.get('Content-Type')).toBe('application/json')
|
||||||
|
expect(headers.get('X-CSRF-Token')).toBe('token-abc')
|
||||||
|
setCsrfToken(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('401 触发全局未授权回调并抛出 ApiError', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
|
jsonResponse(401, { error: { code: 'unauthorized', message: '未登录或会话已过期' } }),
|
||||||
|
)
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
const handler = vi.fn()
|
||||||
|
setUnauthorizedHandler(handler)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api('/api/admin/notes', { method: 'DELETE' })
|
||||||
|
expect.unreachable('应抛出 ApiError')
|
||||||
|
} catch (e) {
|
||||||
|
expect(e).toBeInstanceOf(ApiError)
|
||||||
|
expect((e as ApiError).status).toBe(401)
|
||||||
|
expect((e as ApiError).code).toBe('unauthorized')
|
||||||
|
}
|
||||||
|
expect(handler).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,449 @@
|
|||||||
|
// P3 加固回归(方案 §7.2 / §7.3 / §7.4、§12.4):
|
||||||
|
// 1) 文首边界:文档以标题/列表/任务/围栏/水平线开头;
|
||||||
|
// 2) 文末边界:文档以水平线/图片/围栏/任务结尾(对应 CM issue #625「文末块 widget 消失」一类的回归面);
|
||||||
|
// 3) 跨原子区间选区:全选替换、跨隐藏标记的选区替换、只选中可见内容时标记不被破坏;
|
||||||
|
// 4) 不变量:原子区间不跨行、有序不重叠、不含可编辑内容、隐藏标记必在原子区间内;
|
||||||
|
// 5) 性能基准:3000 行文档的装饰计算耗时(§7.4 目标 < 16ms/帧)。
|
||||||
|
//
|
||||||
|
// 说明:happy-dom 下 CodeMirror 可以真实创建 EditorView 并渲染装饰 DOM,
|
||||||
|
// 因此本文件用真实 view 断言,而不是只看纯函数输出。
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { EditorState } from '@codemirror/state'
|
||||||
|
import { EditorView } from '@codemirror/view'
|
||||||
|
import { syntaxTree } from '@codemirror/language'
|
||||||
|
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
|
||||||
|
import { deleteCharBackward } from '@codemirror/commands'
|
||||||
|
import { livePreview } from '../src/editor/livePreview'
|
||||||
|
import { collectSpecs } from '../src/editor/syntax'
|
||||||
|
import { buildDecorations, visibleSyntaxTree } from '../src/editor/decorations'
|
||||||
|
|
||||||
|
const views: EditorView[] = []
|
||||||
|
|
||||||
|
function mount(doc: string, anchor = 0, focus = true): EditorView {
|
||||||
|
const state = EditorState.create({
|
||||||
|
doc,
|
||||||
|
selection: { anchor },
|
||||||
|
extensions: [markdown({ base: markdownLanguage }), livePreview()],
|
||||||
|
})
|
||||||
|
const parent = document.createElement('div')
|
||||||
|
document.body.appendChild(parent)
|
||||||
|
const view = new EditorView({ state, parent })
|
||||||
|
if (focus) view.focus()
|
||||||
|
views.push(view)
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通过公开 facet 读出当前生效的原子区间(升序)。 */
|
||||||
|
function atomicRanges(view: EditorView): Array<[number, number]> {
|
||||||
|
const out: Array<[number, number]> = []
|
||||||
|
for (const fn of view.state.facet(EditorView.atomicRanges)) {
|
||||||
|
fn(view).between(0, view.state.doc.length, (from, to) => {
|
||||||
|
out.push([from, to])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => a[0] - b[0] || a[1] - b[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineTexts(view: EditorView): string[] {
|
||||||
|
return [...view.dom.querySelectorAll('.cm-line')].map((el) => el.textContent ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceDoc(view: EditorView, from: number, to: number, insert: string): void {
|
||||||
|
view.dispatch({ changes: { from, to, insert }, selection: { anchor: from + insert.length } })
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const v of views.splice(0)) v.destroy()
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('文首边界', () => {
|
||||||
|
it('以标题开头:标记隐藏、整行 h1、标记区间原子化', () => {
|
||||||
|
const doc = '# 标题\n\n正文'
|
||||||
|
const view = mount(doc, doc.length)
|
||||||
|
expect(lineTexts(view)[0]).toBe('标题')
|
||||||
|
expect(view.dom.querySelectorAll('.cm-md-h1')).toHaveLength(1)
|
||||||
|
expect(atomicRanges(view)).toContainEqual([0, 2])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('光标停在文档起点回退:不抛错、内容不变、光标不越界', () => {
|
||||||
|
const doc = '# 标题\n\n正文'
|
||||||
|
const view = mount(doc, 0)
|
||||||
|
deleteCharBackward(view)
|
||||||
|
expect(view.state.doc.toString()).toBe(doc)
|
||||||
|
expect(view.state.selection.main.head).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('以任务项开头:复选框渲染在文首、标记让位、列表标记原子化', () => {
|
||||||
|
const doc = '- [ ] 第一项\n\n正文'
|
||||||
|
const view = mount(doc, doc.length)
|
||||||
|
expect(view.dom.querySelectorAll('.cm-md-task')).toHaveLength(1)
|
||||||
|
expect(lineTexts(view)[0]).not.toContain('-')
|
||||||
|
expect(atomicRanges(view)).toContainEqual([0, 2]) // `- `
|
||||||
|
})
|
||||||
|
|
||||||
|
it('以围栏开头:语言标签在文首渲染,块内不解析行内语法', () => {
|
||||||
|
const doc = '```js\n**原样**\n```\n\n正文'
|
||||||
|
const view = mount(doc, doc.length)
|
||||||
|
expect(view.dom.querySelector('.cm-md-fence-lang')?.textContent).toBe('js')
|
||||||
|
expect(view.dom.querySelectorAll('.cm-md-strong')).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('以水平线开头:hr widget 渲染在文首', () => {
|
||||||
|
const doc = '---\n\n正文'
|
||||||
|
const view = mount(doc, doc.length)
|
||||||
|
expect(view.dom.querySelectorAll('.cm-md-hr')).toHaveLength(1)
|
||||||
|
expect(lineTexts(view)[0]).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('文末边界', () => {
|
||||||
|
it('以水平线结尾:hr widget 渲染在最后一行', () => {
|
||||||
|
const doc = '正文\n\n---'
|
||||||
|
const view = mount(doc, 0)
|
||||||
|
expect(view.dom.querySelectorAll('.cm-md-hr')).toHaveLength(1)
|
||||||
|
const last = lineTexts(view).at(-1)
|
||||||
|
expect(last).toBe('')
|
||||||
|
expect(atomicRanges(view)).toContainEqual([doc.length - 3, doc.length])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('文档只有一条水平线:不抛错且渲染 hr', () => {
|
||||||
|
const view = mount('---', 0)
|
||||||
|
expect(view.state.doc.length).toBe(3)
|
||||||
|
expect(view.dom.querySelectorAll('.cm-md-hr')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('以图片结尾:img widget 在文末,src 与 alt 正确', () => {
|
||||||
|
const doc = '正文\n\n'
|
||||||
|
const view = mount(doc, 0)
|
||||||
|
const img = view.dom.querySelector('.cm-md-image') as HTMLImageElement | null
|
||||||
|
expect(img).not.toBeNull()
|
||||||
|
expect(img?.getAttribute('src')).toBe('/api/images/9')
|
||||||
|
expect(img?.getAttribute('alt')).toBe('配图')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('以围栏结尾:闭围栏隐藏,末行仍带 fenced 类', () => {
|
||||||
|
const doc = '正文\n\n```js\nconst a = 1\n```'
|
||||||
|
const view = mount(doc, 0)
|
||||||
|
expect(lineTexts(view).at(-1)).toBe('')
|
||||||
|
expect(lineTexts(view).at(-2)).toBe('const a = 1')
|
||||||
|
expect(view.dom.querySelectorAll('.cm-md-fenced')).toHaveLength(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('以任务项结尾:复选框渲染在最后一行', () => {
|
||||||
|
const doc = '正文\n\n- [x] 末项'
|
||||||
|
const view = mount(doc, 0)
|
||||||
|
const tasks = view.dom.querySelectorAll('.cm-md-task')
|
||||||
|
expect(tasks).toHaveLength(1)
|
||||||
|
expect((tasks[0] as HTMLInputElement).checked).toBe(true)
|
||||||
|
expect(lineTexts(view).at(-1)).not.toContain('[x]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('文末追加内容后装饰正确重建(不给文末留残影)', () => {
|
||||||
|
const doc = '正文\n\n---'
|
||||||
|
const view = mount(doc, 0)
|
||||||
|
view.dispatch({ changes: { from: doc.length, insert: '\n\n## 新小节\n\n新段落' } })
|
||||||
|
expect(lineTexts(view).some((t) => t === '新小节')).toBe(true)
|
||||||
|
expect(view.dom.querySelectorAll('.cm-md-h2')).toHaveLength(1)
|
||||||
|
expect(view.dom.querySelectorAll('.cm-md-hr')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('文末整体清空(全选删除)不抛错', () => {
|
||||||
|
const doc = '# 标题\n\n- A\n\n\n\n---\n\n```js\nx\n```\n'
|
||||||
|
const view = mount(doc, 0)
|
||||||
|
replaceDoc(view, 0, doc.length, '')
|
||||||
|
expect(view.state.doc.toString()).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('跨原子区间选区', () => {
|
||||||
|
const doc = '# 标题\n\n- 甲\n\n段落 **粗体** 结尾\n'
|
||||||
|
|
||||||
|
it('全选替换:跨多个隐藏标记的选区可整体替换', () => {
|
||||||
|
const view = mount(doc, doc.length)
|
||||||
|
replaceDoc(view, 0, doc.length, 'X')
|
||||||
|
expect(view.state.doc.toString()).toBe('X')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('选区横跨隐藏标记:替换结果不残留标记碎片', () => {
|
||||||
|
const view = mount(doc, doc.length)
|
||||||
|
const boldStart = doc.indexOf('**')
|
||||||
|
replaceDoc(view, 0, boldStart + 2, '替换')
|
||||||
|
expect(view.state.doc.toString()).toBe('替换粗体** 结尾\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('只选中可见内容时替换,标记完整保留', () => {
|
||||||
|
const doc2 = '段落 **粗体** 结尾'
|
||||||
|
const view = mount(doc2, doc2.length)
|
||||||
|
const from = doc2.indexOf('粗体')
|
||||||
|
replaceDoc(view, from, from + 2, 'X')
|
||||||
|
expect(view.state.doc.toString()).toBe('段落 **X** 结尾')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('选中「标记 + 内容 + 标记」整体删除后,只剩两侧文字', () => {
|
||||||
|
const doc2 = '段落 **粗体** 结尾'
|
||||||
|
const view = mount(doc2, doc2.length)
|
||||||
|
replaceDoc(view, 3, 9, '')
|
||||||
|
expect(view.state.doc.toString()).toBe('段落 结尾')
|
||||||
|
expect(view.dom.querySelectorAll('.cm-md-strong')).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('删除跨行的选区(含列表行与引用行)后装饰重建正确', () => {
|
||||||
|
const doc2 = '前言\n\n- 甲\n- 乙\n\n> 引用\n\n结尾'
|
||||||
|
const view = mount(doc2, doc2.length)
|
||||||
|
replaceDoc(view, 2, doc2.indexOf('结尾'), '')
|
||||||
|
const left = view.state.doc.toString()
|
||||||
|
expect(left).toContain('前言')
|
||||||
|
expect(left).toContain('结尾')
|
||||||
|
expect(left).not.toContain('甲')
|
||||||
|
expect(left).not.toContain('引用')
|
||||||
|
expect(view.dom.querySelectorAll('.cm-md-bullet')).toHaveLength(0)
|
||||||
|
expect(view.dom.querySelectorAll('.cm-md-quote-bar')).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('装饰层不变量', () => {
|
||||||
|
const docs = [
|
||||||
|
'# 标题\n\n正文 **粗** 与 *斜* 与 `码` 与 [链](https://a.example)\n',
|
||||||
|
'- [ ] 待办\n- [x] 完成\n - 嵌套\n1. 有序\n',
|
||||||
|
'> 一\n> > 二\n\n---\n\n```js\ncode\n```\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n',
|
||||||
|
'\n\n结尾\n',
|
||||||
|
]
|
||||||
|
|
||||||
|
it('原子区间不跨行(CM6 硬性约束)', () => {
|
||||||
|
for (const doc of docs) {
|
||||||
|
for (const anchor of [0, doc.length]) {
|
||||||
|
const view = mount(doc, anchor)
|
||||||
|
for (const [from, to] of atomicRanges(view)) {
|
||||||
|
expect(view.state.doc.lineAt(from).number).toBe(view.state.doc.lineAt(to).number)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('原子区间有序且互不重叠', () => {
|
||||||
|
for (const doc of docs) {
|
||||||
|
const view = mount(doc, doc.length)
|
||||||
|
const ranges = atomicRanges(view)
|
||||||
|
for (let i = 1; i < ranges.length; i++) {
|
||||||
|
expect(ranges[i][0]).toBeGreaterThanOrEqual(ranges[i - 1][1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('隐藏标记一定落在原子区间内(退格可整段删净)', () => {
|
||||||
|
const doc = '# 标题\n\n- 甲\n> 乙\n\n**粗**\n'
|
||||||
|
const view = mount(doc, doc.length)
|
||||||
|
const ranges = atomicRanges(view)
|
||||||
|
const tree = syntaxTree(view.state)
|
||||||
|
for (const spec of collectSpecs(tree, view.state.doc, 0, doc.length)) {
|
||||||
|
if (spec.kind !== 'hide') continue
|
||||||
|
expect(ranges).toContainEqual([spec.from, spec.to])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('强调 / 行内代码 / 链接的内容区间绝不被原子化(§4.2)', () => {
|
||||||
|
const doc = '文字 **粗体** 与 `代码` 与 [链接](https://a.example) 结束\n'
|
||||||
|
const view = mount(doc, doc.length)
|
||||||
|
const ranges = atomicRanges(view)
|
||||||
|
const tree = syntaxTree(view.state)
|
||||||
|
const contentSpecs = collectSpecs(tree, view.state.doc, 0, doc.length).filter(
|
||||||
|
(s) => s.kind === 'mark' && ['cm-md-strong', 'cm-md-code', 'cm-md-link'].includes(s.cls),
|
||||||
|
)
|
||||||
|
expect(contentSpecs).toHaveLength(3)
|
||||||
|
for (const spec of contentSpecs) {
|
||||||
|
for (const [from, to] of ranges) {
|
||||||
|
expect(spec.from >= to || spec.to <= from).toBe(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reveal 契约:光标进入某行后该行原子区间消失,离开后恢复', () => {
|
||||||
|
const doc = '# 标题\n\n正文 **粗** 结束\n'
|
||||||
|
const view = mount(doc, doc.length) // 光标在末行
|
||||||
|
expect(atomicRanges(view)).toContainEqual([0, 2])
|
||||||
|
expect(lineTexts(view)[0]).toBe('标题')
|
||||||
|
|
||||||
|
view.dispatch({ selection: { anchor: 1 } }) // 光标进入标题行
|
||||||
|
expect(atomicRanges(view)).not.toContainEqual([0, 2])
|
||||||
|
expect(lineTexts(view)[0]).toBe('# 标题')
|
||||||
|
|
||||||
|
view.dispatch({ selection: { anchor: doc.length } }) // 离开
|
||||||
|
expect(atomicRanges(view)).toContainEqual([0, 2])
|
||||||
|
expect(lineTexts(view)[0]).toBe('标题')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('失焦(未聚焦)时不展开任何行,整篇按渲染态呈现', () => {
|
||||||
|
const doc = '# 标题\n\n正文\n'
|
||||||
|
const view = mount(doc, 5, false) // 不调用 focus()
|
||||||
|
expect(view.hasFocus).toBe(false)
|
||||||
|
expect(lineTexts(view)[0]).toBe('标题')
|
||||||
|
expect(atomicRanges(view)).toContainEqual([0, 2])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('长文档:惰性解析与装饰补齐', () => {
|
||||||
|
function buildDoc(lines: number): string {
|
||||||
|
const out: string[] = []
|
||||||
|
for (let i = 0; i < lines; i++) {
|
||||||
|
if (i % 7 === 0) out.push(`## 小节 ${i}`)
|
||||||
|
else if (i % 5 === 0) out.push(`- 条目 ${i} 带 **粗体** 与 \`代码\` 和 [链接](https://a.example/${i})`)
|
||||||
|
else if (i % 11 === 0) out.push('> 引用一行内容')
|
||||||
|
else out.push(`第 ${i} 行正文,包含 **强调**、*斜体*、\`行内代码\` 与 [链接](https://a.example/${i})。`)
|
||||||
|
}
|
||||||
|
return out.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = buildDoc(3000)
|
||||||
|
const state = EditorState.create({ doc, extensions: [markdown({ base: markdownLanguage })] })
|
||||||
|
|
||||||
|
it('如实记录:syntaxTree 对长文档只返回已解析的前段(lezer 惰性增量)', () => {
|
||||||
|
const partial = syntaxTree(state).length
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[parse] 3000 行文档(${doc.length} 字符):syntaxTree 首次仅覆盖 ${partial} 字符`)
|
||||||
|
expect(partial).toBeLessThan(doc.length)
|
||||||
|
expect(partial).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('visibleSyntaxTree 在给定预算内把可见区间补齐,装饰不再缺失', () => {
|
||||||
|
const win = { from: state.doc.line(1500).from, to: state.doc.line(1600).to }
|
||||||
|
const cold = syntaxTree(state).length
|
||||||
|
const tree = visibleSyntaxTree(state, [win], 2000)
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[parse] 补解析窗口 [${win.from}, ${win.to}]:树覆盖 ${cold} → ${tree.length}`)
|
||||||
|
expect(tree.length).toBeGreaterThanOrEqual(win.to)
|
||||||
|
const { decorations } = buildDecorations(state, [win], false, tree)
|
||||||
|
expect(decorations.size).toBeGreaterThan(50)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('预算受限时越界区间保持源码态,且不会抛错(等解析推进后自动补齐)', () => {
|
||||||
|
const win = { from: state.doc.line(2900).from, to: state.doc.line(2950).to }
|
||||||
|
const tree = visibleSyntaxTree(state, [win], 20)
|
||||||
|
const { decorations } = buildDecorations(state, [win], false, tree)
|
||||||
|
if (tree.length < win.from) expect(decorations.size).toBe(0)
|
||||||
|
else expect(decorations.size).toBeGreaterThanOrEqual(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('装饰不会越过已解析边界(避免对半截节点做出错误渲染)', () => {
|
||||||
|
const partialLen = syntaxTree(state).length
|
||||||
|
const { decorations } = buildDecorations(state, [{ from: 0, to: doc.length }], false, syntaxTree(state))
|
||||||
|
let maxTo = 0
|
||||||
|
decorations.between(0, doc.length, (_f, to) => {
|
||||||
|
if (to > maxTo) maxTo = to
|
||||||
|
})
|
||||||
|
expect(maxTo).toBeLessThanOrEqual(partialLen)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('光标行远在可见区间之外时仍被装饰(CM 会单独渲染选区所在块)', () => {
|
||||||
|
// 选区停在文档开头(第 1 行是 `## 小节 0`),可见区间取远端窗口
|
||||||
|
const win = { from: state.doc.line(2500).from, to: state.doc.line(2510).to }
|
||||||
|
const { decorations } = buildDecorations(state, [win], false)
|
||||||
|
let startLineClass: string | null = null
|
||||||
|
decorations.between(0, state.doc.line(1).to, (_f, _t, value) => {
|
||||||
|
if (typeof value.spec.class === 'string') startLineClass = value.spec.class
|
||||||
|
})
|
||||||
|
expect(startLineClass).toBe('cm-md-h2')
|
||||||
|
|
||||||
|
// 远端窗口同样被装饰:补解析 + 采集覆盖了两段
|
||||||
|
let farCount = 0
|
||||||
|
decorations.between(win.from, win.to, () => {
|
||||||
|
farCount++
|
||||||
|
})
|
||||||
|
expect(farCount).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('性能基准(§7.4:3000 行文档单帧 < 16ms)', () => {
|
||||||
|
function buildDoc(lines: number): string {
|
||||||
|
const out: string[] = []
|
||||||
|
for (let i = 0; i < lines; i++) {
|
||||||
|
if (i % 7 === 0) out.push(`## 小节 ${i}`)
|
||||||
|
else if (i % 5 === 0) out.push(`- 条目 ${i} 带 **粗体** 与 \`代码\` 和 [链接](https://a.example/${i})`)
|
||||||
|
else if (i % 11 === 0) out.push('> 引用一行内容')
|
||||||
|
else out.push(`第 ${i} 行正文,包含 **强调**、*斜体*、\`行内代码\` 与 [链接](https://a.example/${i})。`)
|
||||||
|
}
|
||||||
|
return out.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
const median = (xs: number[]): number => xs.slice().sort((a, b) => a - b)[Math.floor(xs.length / 2)]
|
||||||
|
|
||||||
|
it('视口窗口(约 100 行)装饰计算中位数 < 16ms', () => {
|
||||||
|
const doc = buildDoc(3000)
|
||||||
|
const state = EditorState.create({ doc, extensions: [markdown({ base: markdownLanguage })] })
|
||||||
|
const win = { from: state.doc.line(1500).from, to: state.doc.line(1600).to }
|
||||||
|
const tree = visibleSyntaxTree(state, [win], 5000) // 预热到窗口可用
|
||||||
|
|
||||||
|
const samples: number[] = []
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
const t0 = performance.now()
|
||||||
|
const specs = collectSpecs(tree, state.doc, win.from, win.to)
|
||||||
|
samples.push(performance.now() - t0)
|
||||||
|
expect(specs.length).toBeGreaterThan(50)
|
||||||
|
}
|
||||||
|
const med = median(samples)
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(
|
||||||
|
`[perf] 3000 行 / 视口 100 行:collectSpecs 中位数 ${med.toFixed(3)}ms,p95 ${median(samples.slice().sort((a, b) => a - b).slice(-2)).toFixed(3)}ms,最大 ${Math.max(...samples).toFixed(3)}ms`,
|
||||||
|
)
|
||||||
|
expect(med).toBeLessThan(16)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('整篇作为单一可见区间(最坏情况)仍远低于一帧量级', () => {
|
||||||
|
const doc = buildDoc(3000)
|
||||||
|
const state = EditorState.create({ doc, extensions: [markdown({ base: markdownLanguage })] })
|
||||||
|
const tree = visibleSyntaxTree(state, [{ from: 0, to: doc.length }], 10000)
|
||||||
|
expect(tree.length).toBe(doc.length)
|
||||||
|
|
||||||
|
const samples: number[] = []
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const t0 = performance.now()
|
||||||
|
const specs = collectSpecs(tree, state.doc, 0, doc.length)
|
||||||
|
samples.push(performance.now() - t0)
|
||||||
|
expect(specs.length).toBeGreaterThan(3000)
|
||||||
|
}
|
||||||
|
const med = median(samples)
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(
|
||||||
|
`[perf] 3000 行 / 全篇单区间:collectSpecs 中位数 ${med.toFixed(3)}ms,最大 ${Math.max(...samples).toFixed(3)}ms`,
|
||||||
|
)
|
||||||
|
expect(med).toBeLessThan(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('冷启动成本实测:首次强制解析整篇 3000 行', () => {
|
||||||
|
const doc = buildDoc(3000)
|
||||||
|
const state = EditorState.create({ doc, extensions: [markdown({ base: markdownLanguage })] })
|
||||||
|
const t0 = performance.now()
|
||||||
|
const tree = visibleSyntaxTree(state, [{ from: 0, to: doc.length }], 10000)
|
||||||
|
const ms = performance.now() - t0
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[perf] 3000 行(${doc.length} 字符)全量解析耗时 ${ms.toFixed(1)}ms(树覆盖 ${tree.length} 字符)`)
|
||||||
|
expect(tree.length).toBe(doc.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('最坏选区:Cmd+A 整篇选区 + 视口窗口,装饰构建仍在一帧预算内', () => {
|
||||||
|
const doc = buildDoc(3000)
|
||||||
|
// 整篇选区 + 聚焦(触发 reveal,展开区间多达 3000 个)
|
||||||
|
const state = EditorState.create({
|
||||||
|
doc,
|
||||||
|
selection: { anchor: 0, head: doc.length },
|
||||||
|
extensions: [markdown({ base: markdownLanguage })],
|
||||||
|
})
|
||||||
|
const win = { from: state.doc.line(1500).from, to: state.doc.line(1560).to }
|
||||||
|
const tree = visibleSyntaxTree(state, [win], 10000)
|
||||||
|
|
||||||
|
const samples: number[] = []
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const t0 = performance.now()
|
||||||
|
const { decorations } = buildDecorations(state, [win], true, tree)
|
||||||
|
samples.push(performance.now() - t0)
|
||||||
|
expect(decorations.size).toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
const med = median(samples)
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(
|
||||||
|
`[perf] 最坏选区(整篇选中):buildDecorations 中位数 ${med.toFixed(3)}ms,最大 ${Math.max(...samples).toFixed(3)}ms`,
|
||||||
|
)
|
||||||
|
expect(med).toBeLessThan(16)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
// 即时渲染装饰层单测(方案 §8):
|
||||||
|
// 1) syntax.ts 区间识别:标题 / 强调 / 行内代码 / 链接 / 图片 / 引用 / 列表 /
|
||||||
|
// 任务 / 围栏 / 水平线 / 表格,覆盖嵌套、代码块内不解析、中文标点;
|
||||||
|
// 2) reveal.ts:行粒度展开只撤销「标记替代物」,样式类保留;
|
||||||
|
// 3) decorations.ts:白名单外的图片 src 退回源码文本;widget DOM 无注入面。
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { EditorState } from '@codemirror/state'
|
||||||
|
import { syntaxTree } from '@codemirror/language'
|
||||||
|
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
|
||||||
|
import { collectSpecs, type Spec } from '../src/editor/syntax'
|
||||||
|
import { activeLineRanges, applyReveal, intersectsAny, selectionEndpointLines } from '../src/editor/reveal'
|
||||||
|
import { buildDecorations, IMAGE_SRC_ALLOW } from '../src/editor/decorations'
|
||||||
|
import { FenceLangWidget, ImageWidget, TaskWidget } from '../src/editor/widgets'
|
||||||
|
|
||||||
|
function makeState(src: string, cursor = 0) {
|
||||||
|
return EditorState.create({
|
||||||
|
doc: src,
|
||||||
|
selection: { anchor: cursor },
|
||||||
|
extensions: [markdown({ base: markdownLanguage })],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 采集全文档 specs,按位置排序,便于断言。 */
|
||||||
|
function collect(src: string): Spec[] {
|
||||||
|
const state = makeState(src)
|
||||||
|
const specs = collectSpecs(syntaxTree(state), state.doc, 0, src.length)
|
||||||
|
return specs.slice().sort((a, b) => a.from - b.from || a.to - b.to)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 断言用的简化视图:`kind:原文`。 */
|
||||||
|
function digest(src: string, specs = collect(src)): string[] {
|
||||||
|
return specs.map((s) => {
|
||||||
|
if (s.kind === 'line') return `line(#${s.line}:${s.cls})`
|
||||||
|
const text = src.slice(s.from, s.to)
|
||||||
|
const extra =
|
||||||
|
s.kind === 'bullet' || s.kind === 'quote' ? ` d${s.depth}` : s.kind === 'mark' ? ` ${s.cls}` : ''
|
||||||
|
return `${s.kind}(${JSON.stringify(text)}${extra})`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('syntax.ts 区间识别', () => {
|
||||||
|
it('标题:隐藏 `# `,整行上 h1 类,行内语法继续解析', () => {
|
||||||
|
const src = '# 标题 **粗**'
|
||||||
|
const d = digest(src)
|
||||||
|
expect(d).toContain('line(#1:cm-md-h1)')
|
||||||
|
expect(d).toContain('hide("# ")')
|
||||||
|
expect(d).toContain('hide("**")')
|
||||||
|
expect(d).toContain('mark("粗" cm-md-strong)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('强调:隐藏定界符,内容上样式(含嵌套)', () => {
|
||||||
|
const d = digest('a **粗** b *斜* c ~~删~~')
|
||||||
|
expect(d).toEqual([
|
||||||
|
'hide("**")',
|
||||||
|
'mark("粗" cm-md-strong)',
|
||||||
|
'hide("**")',
|
||||||
|
'hide("*")',
|
||||||
|
'mark("斜" cm-md-em)',
|
||||||
|
'hide("*")',
|
||||||
|
'hide("~~")',
|
||||||
|
'mark("删" cm-md-del)',
|
||||||
|
'hide("~~")',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('行内代码:隐藏反引号,内容不二次解析(`*x*` 保持字面量)', () => {
|
||||||
|
const d = digest('`*x*`')
|
||||||
|
expect(d).toEqual(['hide("`")', 'mark("*x*" cm-md-code)', 'hide("`")'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('链接:隐藏 `[` 与 `](url)`,文本上链接类;不产出 <a>', () => {
|
||||||
|
const src = '[标题](https://a.example/x)'
|
||||||
|
const d = digest(src)
|
||||||
|
expect(d).toEqual(['hide("[")', 'mark("标题" cm-md-link)', 'hide("](https://a.example/x)")'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('图片:整段替换为 image spec,带 alt 与 src', () => {
|
||||||
|
const specs = collect('')
|
||||||
|
const img = specs.find((s) => s.kind === 'image')
|
||||||
|
expect(img).toMatchObject({ alt: '配图', src: '/api/images/7' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('引用:每个 QuoteMark 一个竖线 widget,嵌套深度递增,同行去重加 cm-md-quote', () => {
|
||||||
|
const src = '> 一\n> > 二'
|
||||||
|
const d = digest(src)
|
||||||
|
expect(d.filter((x) => x.startsWith('quote('))).toEqual(['quote(">" d1)', 'quote(">" d1)', 'quote(">" d2)'])
|
||||||
|
// 第 2 行有两个 QuoteMark,原样各出一条行类;构建阶段按键去重后每行只剩一条
|
||||||
|
expect(d.filter((x) => x === 'line(#1:cm-md-quote)')).toHaveLength(1)
|
||||||
|
expect(d.filter((x) => x === 'line(#2:cm-md-quote)')).toHaveLength(2)
|
||||||
|
|
||||||
|
const state = makeState(src)
|
||||||
|
const { decorations } = buildDecorations(state, [{ from: 0, to: src.length }], false)
|
||||||
|
const lineClasses: unknown[] = []
|
||||||
|
decorations.between(0, src.length, (_f, _t, value) => {
|
||||||
|
if (value.spec.class) lineClasses.push(value.spec.class)
|
||||||
|
})
|
||||||
|
expect(lineClasses.filter((c) => c === 'cm-md-quote')).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('无序列表:圆点 widget 覆盖「标记 + 空格」,嵌套深度递增', () => {
|
||||||
|
const d = digest('- A\n - B')
|
||||||
|
expect(d.filter((x) => x.startsWith('bullet('))).toEqual(['bullet("- " d1)', 'bullet("- " d2)'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('有序列表:保留数字,仅上样式,不替换不原子(决策 E3)', () => {
|
||||||
|
const d = digest('1. 一\n2. 二')
|
||||||
|
expect(d).toEqual(['mark("1." cm-md-ol-mark)', 'mark("2." cm-md-ol-mark)'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('任务项:标记让位给复选框,不画圆点;checked 正确', () => {
|
||||||
|
const d = digest('- [ ] 待办\n- [x] 完成')
|
||||||
|
expect(d.some((x) => x.startsWith('bullet('))).toBe(false)
|
||||||
|
expect(d).toContain('hide("- ")')
|
||||||
|
expect(d.filter((x) => x.startsWith('task('))).toEqual(['task("[ ]")', 'task("[x]")'])
|
||||||
|
const specs = collect('- [x] 完成')
|
||||||
|
expect(specs.find((s) => s.kind === 'task')).toMatchObject({ checked: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('围栏:语言标签 + 隐藏闭围栏 + 每行 fenced 类;块内不解析行内语法', () => {
|
||||||
|
const src = '```js\n**not bold**\n```'
|
||||||
|
const d = digest(src)
|
||||||
|
expect(d).toContain('fenceLang("```js")')
|
||||||
|
expect(collect(src).find((s) => s.kind === 'fenceLang')).toMatchObject({ lang: 'js' })
|
||||||
|
expect(d).toContain('line(#1:cm-md-fenced cm-md-fenced-first)')
|
||||||
|
expect(d).toContain('line(#3:cm-md-fenced cm-md-fenced-last)')
|
||||||
|
expect(d.some((x) => x.includes('cm-md-strong'))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('无语言围栏:起始围栏直接隐藏', () => {
|
||||||
|
const d = digest('```\nx\n```')
|
||||||
|
expect(d.some((x) => x.startsWith('fenceLang('))).toBe(false)
|
||||||
|
expect(d).toContain('hide("```")')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('水平线:整行替换', () => {
|
||||||
|
const d = digest('---')
|
||||||
|
expect(d).toEqual(['hr("---")'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('表格:每行 cm-md-table,定界符淡化(分隔行两级)', () => {
|
||||||
|
const src = '| a | b |\n| --- | --- |\n| 1 | 2 |'
|
||||||
|
const d = digest(src)
|
||||||
|
expect(d.filter((x) => x.startsWith('line('))).toEqual([
|
||||||
|
'line(#1:cm-md-table)',
|
||||||
|
'line(#2:cm-md-table)',
|
||||||
|
'line(#3:cm-md-table)',
|
||||||
|
])
|
||||||
|
const specs = collect(src)
|
||||||
|
const delims = specs.filter((s): s is Extract<Spec, { kind: 'tableMark' }> => s.kind === 'tableMark')
|
||||||
|
expect(delims.some((s) => s.delimiter)).toBe(true)
|
||||||
|
expect(delims.filter((s) => s.delimiter)).toHaveLength(1) // 只有分隔行是两级淡化
|
||||||
|
expect(delims).toHaveLength(7) // 4 行共 7 个竖线标记
|
||||||
|
})
|
||||||
|
|
||||||
|
it('中文标点与紧邻标记不误判', () => {
|
||||||
|
const d = digest('他说:「**重点**」。')
|
||||||
|
expect(d).toEqual(['hide("**")', 'mark("重点" cm-md-strong)', 'hide("**")'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('转义星号不触发强调', () => {
|
||||||
|
const d = digest('\\*不是斜体\\*')
|
||||||
|
expect(d).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('可见区间裁剪:只采集区间内节点', () => {
|
||||||
|
const src = '# 一\n\n# 二'
|
||||||
|
const state = makeState(src)
|
||||||
|
const line3 = state.doc.line(3)
|
||||||
|
const specs = collectSpecs(syntaxTree(state), state.doc, line3.from, line3.to)
|
||||||
|
expect(specs.filter((s) => s.kind === 'line')).toHaveLength(1)
|
||||||
|
expect(specs.find((s) => s.kind === 'line')).toMatchObject({ line: 3 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('reveal.ts 光标处展开(行粒度)', () => {
|
||||||
|
it('intersectsAny 只认真正的相交', () => {
|
||||||
|
expect(intersectsAny([{ from: 0, to: 5 }], 5, 6)).toBe(true)
|
||||||
|
expect(intersectsAny([{ from: 0, to: 5 }], 6, 8)).toBe(false)
|
||||||
|
expect(intersectsAny([{ from: 0, to: 5 }], 3, 3)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('光标行上的标记替代物被撤销,样式类保留', () => {
|
||||||
|
const src = 'a **粗** b'
|
||||||
|
const state = makeState(src, 5) // 光标落在粗体所在行
|
||||||
|
const specs = collect(src)
|
||||||
|
const shown = applyReveal(specs, activeLineRanges(state))
|
||||||
|
expect(shown.some((s) => s.kind === 'hide')).toBe(false)
|
||||||
|
expect(shown.some((s) => s.kind === 'mark' && s.cls === 'cm-md-strong')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('光标在别的行时标记保持隐藏', () => {
|
||||||
|
const src = '第一行\n第二行 **粗**'
|
||||||
|
const state = makeState(src, 0)
|
||||||
|
const specs = collect(src)
|
||||||
|
const shown = applyReveal(specs, activeLineRanges(state))
|
||||||
|
expect(shown.filter((s) => s.kind === 'hide')).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('选区横跨多行时,涉及的行全部展开', () => {
|
||||||
|
const src = '> 一\n> > 二'
|
||||||
|
const state = EditorState.create({
|
||||||
|
doc: src,
|
||||||
|
selection: { anchor: 0, head: src.length },
|
||||||
|
extensions: [markdown({ base: markdownLanguage })],
|
||||||
|
})
|
||||||
|
const shown = applyReveal(collect(src), activeLineRanges(state))
|
||||||
|
expect(shown.filter((s) => s.kind === 'quote')).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('二分优化与线性扫描结果完全一致(含空/重叠/乱序/相邻区间)', () => {
|
||||||
|
const src = '# 标题 **粗** 与 `码` 与 [链](https://a.example)\n\n- 甲\n> 乙\n\n---\n'
|
||||||
|
const specs = collect(src)
|
||||||
|
const cases: { from: number; to: number }[][] = [
|
||||||
|
[],
|
||||||
|
[{ from: 0, to: 2 }],
|
||||||
|
[{ from: 0, to: 2 }, { from: 2, to: 4 }],
|
||||||
|
[{ from: 5, to: 9 }, { from: 0, to: 3 }], // 乱序
|
||||||
|
[{ from: 0, to: 10 }, { from: 4, to: 6 }], // 重叠
|
||||||
|
[{ from: 0, to: src.length }], // 整篇
|
||||||
|
[{ from: 3, to: 3 }], // 零长
|
||||||
|
]
|
||||||
|
for (const ranges of cases) {
|
||||||
|
const linear = specs.filter((s) => !(s.reveal && intersectsAny(ranges, s.from, s.to)))
|
||||||
|
expect(applyReveal(specs, ranges)).toEqual(linear)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selectionEndpointLines 只取选区端点行(Cmd+A 不退化为全文档装饰)', () => {
|
||||||
|
const src = '第 1 行\n第 2 行\n第 3 行\n第 4 行'
|
||||||
|
const state = EditorState.create({
|
||||||
|
doc: src,
|
||||||
|
selection: { anchor: 0, head: src.length },
|
||||||
|
extensions: [markdown({ base: markdownLanguage })],
|
||||||
|
})
|
||||||
|
expect(activeLineRanges(state)).toHaveLength(4)
|
||||||
|
const endpoints = selectionEndpointLines(state)
|
||||||
|
expect(endpoints).toHaveLength(2)
|
||||||
|
expect(endpoints[0].from).toBe(0)
|
||||||
|
expect(endpoints[1].to).toBe(state.doc.line(4).to)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('decorations.ts 构建与安全', () => {
|
||||||
|
const src = '# 标题\n\n- A\n\n\n'
|
||||||
|
|
||||||
|
it('生成装饰集且原子区间与隐藏区间数量一致', () => {
|
||||||
|
const state = makeState(src)
|
||||||
|
const { decorations, atomic } = buildDecorations(state, [{ from: 0, to: src.length }], true)
|
||||||
|
expect(decorations.size).toBeGreaterThan(0)
|
||||||
|
// hide(1: `# `) + hide(1: `- `) = 2 个原子区间
|
||||||
|
expect(atomic.size).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('光标行上的标记不进入原子区间', () => {
|
||||||
|
const state = makeState(src, 0) // 光标在标题行
|
||||||
|
const { atomic } = buildDecorations(state, [{ from: 0, to: src.length }], true)
|
||||||
|
expect(atomic.size).toBe(2) // 标题标记已展开,只剩列表标记 + 图片替换
|
||||||
|
})
|
||||||
|
|
||||||
|
it('未聚焦(reveal=false)时整篇按渲染态呈现,光标行的标记也隐藏(决策 E11)', () => {
|
||||||
|
const state = makeState(src, 0)
|
||||||
|
const { atomic, decorations } = buildDecorations(state, [{ from: 0, to: src.length }], false)
|
||||||
|
expect(atomic.size).toBe(3) // 标题 + 列表 + 图片全部原子化
|
||||||
|
const hidden: string[] = []
|
||||||
|
decorations.between(0, src.length, (from, to) => {
|
||||||
|
if (to > from) hidden.push(src.slice(from, to))
|
||||||
|
})
|
||||||
|
expect(hidden).toContain('# ')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('图片 src 白名单:站内接口放行,javascript:/外链降级为源码文本', () => {
|
||||||
|
expect(IMAGE_SRC_ALLOW.test('/api/images/12')).toBe(true)
|
||||||
|
expect(IMAGE_SRC_ALLOW.test('data:image/png;base64,AAAA')).toBe(true)
|
||||||
|
expect(IMAGE_SRC_ALLOW.test('javascript:alert(1)')).toBe(false)
|
||||||
|
expect(IMAGE_SRC_ALLOW.test('https://evil.example/x.png')).toBe(false)
|
||||||
|
|
||||||
|
const bad = ')'
|
||||||
|
const badState = makeState(bad)
|
||||||
|
const badSet = buildDecorations(badState, [{ from: 0, to: bad.length }], true).decorations
|
||||||
|
expect(badSet.size).toBe(0) // 不产出 <img>,源码原样可见
|
||||||
|
|
||||||
|
const goodState = makeState(src)
|
||||||
|
const goodSet = buildDecorations(goodState, [{ from: 0, to: src.length }], true).decorations
|
||||||
|
const widgets: unknown[] = []
|
||||||
|
goodSet.between(0, src.length, (_from, _to, value) => {
|
||||||
|
widgets.push(value.spec.widget)
|
||||||
|
})
|
||||||
|
expect(widgets.some((w) => w instanceof ImageWidget)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('widget DOM 走 textContent,语言名里的 HTML 不被解析', () => {
|
||||||
|
const dom = new FenceLangWidget('<img src=x onerror=alert(1)>').toDOM()
|
||||||
|
expect(dom.children).toHaveLength(0)
|
||||||
|
expect(dom.innerHTML).not.toContain('<img')
|
||||||
|
expect(dom.textContent).toBe('<img src=x onerror=alert(1)>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('图片 widget 不写入 innerHTML,只设置属性', () => {
|
||||||
|
const dom = new ImageWidget('/api/images/1', '"><script>alert(1)</script>').toDOM()
|
||||||
|
expect(dom.querySelector('script')).toBeNull()
|
||||||
|
expect(dom.getAttribute('src')).toBe('/api/images/1')
|
||||||
|
expect(dom.getAttribute('alt')).toBe('"><script>alert(1)</script>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('任务 widget 挂载时不产生额外 DOM 面', () => {
|
||||||
|
const dom = new TaskWidget(0, 3, false).toDOM({ dispatch() {} } as never) as HTMLInputElement
|
||||||
|
expect(dom.tagName).toBe('INPUT')
|
||||||
|
expect(dom.getAttribute('type')).toBe('checkbox')
|
||||||
|
expect(dom.checked).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { MemoryRouter } from 'react-router'
|
||||||
|
import Login from '../src/pages/Login'
|
||||||
|
|
||||||
|
vi.mock('../src/lib/auth', () => ({
|
||||||
|
useAuth: () => ({ login: vi.fn(), authenticated: false }),
|
||||||
|
}))
|
||||||
|
vi.mock('../src/features/site', () => ({
|
||||||
|
useDocumentTitle: () => {},
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('Login 表单密码管理器适配', () => {
|
||||||
|
it('暴露隐藏用户名字段与 current-password 自动填充标记', () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<Login />
|
||||||
|
</MemoryRouter>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const username = screen.getByLabelText('用户名') as HTMLInputElement
|
||||||
|
expect(username.type).toBe('text')
|
||||||
|
expect(username.value).toBe('admin')
|
||||||
|
expect(username.readOnly).toBe(true)
|
||||||
|
expect(username.getAttribute('autocomplete')).toBe('username')
|
||||||
|
|
||||||
|
const password = screen.getByLabelText('管理密码') as HTMLInputElement
|
||||||
|
expect(password.getAttribute('autocomplete')).toBe('current-password')
|
||||||
|
})
|
||||||
|
})
|
||||||
+5
-1
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-13
@@ -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,26 +9,28 @@ 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)
|
||||||
'/api': 'http://127.0.0.1:8080',
|
// changeOrigin:false —— 保留浏览器原始 Host(localhost:5173),
|
||||||
|
// 否则后端 Origin 校验会因 Host 被改写为 127.0.0.1:8080 而判定跨源 403。
|
||||||
|
'/api': {
|
||||||
|
target: 'http://127.0.0.1:8080',
|
||||||
|
changeOrigin: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
// 产物内容 hash 文件名(§8.3-5)
|
// 产物内容 hash 文件名(§8.3-5)
|
||||||
assetsDir: 'assets',
|
assetsDir: 'assets',
|
||||||
rolldownOptions: {
|
// 不做手动 codeSplitting 分组:分组 chunk 会被提升进入口静态依赖,
|
||||||
output: {
|
// HTML 对其发出 modulepreload,导致 lazy() 路由的 CodeMirror 在
|
||||||
// CodeMirror 体积过大,手动拆分避免单 chunk 超 500kB:
|
// 所有页面被预加载(Lighthouse unused-javascript 549KiB 的根因)。
|
||||||
// @codemirror/view 是最大的包,单独成组,其余 cm/lezer/uiw 归一组
|
|
||||||
codeSplitting: {
|
|
||||||
groups: [
|
|
||||||
{ name: 'codemirror-view', test: /node_modules[\\/](@codemirror[\\/]view|style-mod|w3c-keyname)/ },
|
|
||||||
{ name: 'codemirror', test: /node_modules[\\/](@codemirror|@lezer|@uiw)/ },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)'],
|
||||||
|
|||||||
Reference in New Issue
Block a user