Compare commits
18
Commits
412821684b
..
v0.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1ea89ba3d | ||
|
|
9b0f2785cf | ||
|
|
ccc858bfdd | ||
|
|
2be5d4d25b | ||
|
|
95cac4688d | ||
|
|
ad4a87fc9f | ||
|
|
92d89717c5 | ||
|
|
969d537aee | ||
|
|
614bfe2852 | ||
|
|
5bac272f6d | ||
|
|
5457aeaa33 | ||
|
|
87d9bab5dc | ||
|
|
5a78de6aa6 | ||
|
|
646a49a26e | ||
|
|
13ad202924 | ||
|
|
f16f583978 | ||
|
|
dda5ad1e5a | ||
|
|
51f528f516 |
@@ -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-linux-*
|
||||
|
||||
# 发布产物(make dist / CI 发版构建输出)
|
||||
/dist/
|
||||
|
||||
# 系统文件
|
||||
.DS_Store
|
||||
@@ -5,17 +5,21 @@
|
||||
# make sync-assets 拷贝 web/dist → internal/webui/dist(go:embed 约束,§8.3-1)
|
||||
# make build 全量构建(前端 + 单二进制 pn)
|
||||
# make linux 交叉编译 linux/amd64
|
||||
# make dist 全平台发布产物 → dist/(VERSION ?= git describe,与 CI 发版共用脚本)
|
||||
# make test Go 测试 + 前端测试
|
||||
# make smoke 真实构建冒烟测试(§8.3-6)
|
||||
# make dev 双进程热重载(air + vite dev)
|
||||
# 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)
|
||||
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none)
|
||||
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)
|
||||
|
||||
.PHONY: web sync-assets build linux test smoke dev vulncheck clean
|
||||
.PHONY: web sync-assets build linux dist test smoke dev vulncheck clean
|
||||
|
||||
web:
|
||||
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)" \
|
||||
-o pn-linux-amd64 ./cmd/pn
|
||||
|
||||
# 全平台发布产物(版本注入逻辑与 CI 发版共用 scripts/build-release.sh)
|
||||
dist: web sync-assets
|
||||
scripts/build-release.sh "$(VERSION)"
|
||||
|
||||
test:
|
||||
go vet ./...
|
||||
go test ./...
|
||||
@@ -51,3 +59,4 @@ vulncheck:
|
||||
|
||||
clean:
|
||||
rm -f pn pn-linux-amd64
|
||||
rm -rf dist
|
||||
|
||||
@@ -7,11 +7,12 @@
|
||||
|
||||
## 功能
|
||||
|
||||
- **笔记**:Markdown CRUD、粘贴/拖拽图片上传(≤5MB、魔数校验、BLOB 入库去重)、公开/私有两态、置顶、标签
|
||||
- **博客**:首页(置顶优先 + 分页 + 标签云)、详情(上一篇/下一篇)、标签页、RSS、sitemap、SEO meta 注入(仅公开内容)
|
||||
- **笔记**:Markdown CRUD、粘贴/拖拽图片上传(≤5MB、魔数校验、BLOB 入库去重)、公开/私有两态、置顶、标签、发布日期可自选
|
||||
- **博客**:首页(置顶优先 + 分页 + 标签云)、详情(上一篇/下一篇)、标签页、RSS、sitemap、SEO meta 注入(仅公开内容);站点名称前可显示自定义 Logo
|
||||
- **管理**:单管理员口令登录(Argon2id)、会话 7 天滑动续期(CSRF 轮换保持不变)、防爆破限流(per-IP + per-账号)、改密
|
||||
- **回收站**:删除 = 软删除,30 天内可恢复,`gc` 到期物理清除
|
||||
- **运维**:`init` / `passwd`(重设口令并吊销全部会话)/ `start` / `backup`(在线一致快照)/ `gc`(默认 dry-run)/ `version`(亦可 `-v`)
|
||||
- **回收站**:删除 = 软删除,30 天内可恢复,可一键清空,`gc` 到期物理清除
|
||||
- **界面**:9 页面响应式 SPA(React 19 + Tailwind 4 + Ant Design 6),深色/浅色/跟随系统三态主题
|
||||
- **运维**:`init`(含 Markdown 示例文档与示例图片种子)/ `passwd`(重设口令并吊销全部会话)/ `start` / `backup`(在线一致快照)/ `gc`(默认 dry-run)/ `version`(亦可 `-v`)
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -19,7 +20,7 @@
|
||||
# 1. 构建单二进制(前端 + 后端)
|
||||
make build
|
||||
|
||||
# 2. 初始化(设置管理员口令与站点标题)
|
||||
# 2. 初始化(设置管理员口令与站点标题,自动写入一篇 Markdown 示例文档)
|
||||
./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
|
||||
```
|
||||
|
||||
> **为什么必须带 `--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 可达即具备服务器权限,可直接重设;重设后全部会话被吊销):
|
||||
|
||||
```bash
|
||||
@@ -75,6 +78,22 @@ sudo systemctl enable --now pn pn-maint.timer
|
||||
|
||||
**恢复 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/webui/ go:embed 前端产物 + SPA fallback + index.html meta 注入
|
||||
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
|
||||
```
|
||||
|
||||
|
||||
@@ -226,6 +226,10 @@ func runInit(args []string) error {
|
||||
if err := st.SetSetting(store.KeySiteTitle, title); err != nil {
|
||||
return err
|
||||
}
|
||||
// 种子内容:一篇 Markdown 语法示例文档(公开)+ 自动生成的示例图片
|
||||
if _, err := seedWelcome(st); err != nil {
|
||||
return fmt.Errorf("写入初始文档失败: %w", err)
|
||||
}
|
||||
fmt.Println("初始化完成。现在可以启动:pn start --data-dir", cfg.DataDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
+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[:])
|
||||
}
|
||||
+17
-13
@@ -58,8 +58,8 @@
|
||||
| F3 | 图片 | 编辑器内粘贴/拖拽上传(≤5MB);Markdown 中引用;图片可见性 = 引用它的笔记可见性的并集;去重存储 |
|
||||
| F4 | 博客 | 匿名访问:首页(公开列表+分页+标签)、详情页、标签页;RSS Feed;sitemap.xml;SEO 基础(仅公开笔记注入 title/description/og meta) |
|
||||
| F5 | 认证 | 单管理员密码登录/登出/**改密**;会话失效/续期;登录与改密防爆破 |
|
||||
| F6 | 站点设置 | 站点标题、副标题、每页条数(白名单字段,绝不含口令哈希) |
|
||||
| F7 | 回收站 | 删除 = 软删除进入回收站;30 天内可恢复;`gc` 到期物理清除 |
|
||||
| F6 | 站点设置 | 站点标题、副标题、每页条数(10/20/30/50)、站点 Logo(白名单字段,绝不含口令哈希) |
|
||||
| F7 | 回收站 | 删除 = 软删除进入回收站;30 天内可恢复;可一键清空;`gc` 到期物理清除 |
|
||||
| F8 | 运维 | 单二进制启动;`--data-dir` 指定数据目录;在线备份子命令;`gc` 子命令(默认 dry-run);版本号 |
|
||||
|
||||
### 2.2 非功能需求
|
||||
@@ -100,13 +100,13 @@
|
||||
| 框架 | React | 19.2.8 | 生态最大、md 渲染/编辑器组件最丰富 | — |
|
||||
| 语言 | TypeScript | 7.x(不动则 5.9) | 原生编译器版已可用;若第三方库兼容有问题回退 5.9 | — |
|
||||
| 构建 | 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(类型安全更强,但本项目路由简单) |
|
||||
| 数据请求 | 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 编辑器 | **CodeMirror 6**(@uiw/react-codemirror + @codemirror/lang-markdown) | 4.25.11 / 6.5.2 | 源码编辑 + 分屏实时预览,零魔法、可预期;图片粘贴自写 handler(≤50 行) | Tiptap 3.31(体验派,WYSIWYG+成熟图片上传,文档全);MDXEditor(重);ByteMD(字节已弃坑)、Vditor 4(体积大、心智旧) |
|
||||
| 状态管理 | React Query + Context(**不引入** zustand) | — | 会话态 + 服务端缓存已覆盖;确需跨页 UI 态再加 zustand(≈1kB) | redux(不必要) |
|
||||
| 图标 | lucide-react | 1.42.0 | 活跃、风格统一、与 shadcn 默认匹配 | — |
|
||||
| 图标 | lucide-react | 1.42.0 | 活跃、风格统一(antd 组件内亦复用) | — |
|
||||
|
||||
### 3.3 关键决策记录(ADR 摘要)
|
||||
|
||||
@@ -194,9 +194,11 @@ pure-note/
|
||||
│ └── src/
|
||||
│ ├── main.tsx / App.tsx / router.tsx
|
||||
│ ├── lib/ # api client、auth、csrf、格式化
|
||||
│ ├── components/ # shadcn/ui 生成目录 + 业务组件
|
||||
│ ├── theme/ # antd ConfigProvider 主题(品牌 token + 明暗算法)
|
||||
│ ├── components/ # 业务组件
|
||||
│ │ ├── markdown/ # MarkdownViewer(渲染管线,可替换)
|
||||
│ │ └── editor/ # Editor(CodeMirror)+ ImagePaste + Toolbar
|
||||
│ ├── hooks/ # use-toast(antd notification 封装)
|
||||
│ ├── features/ # notes / admin 业务 hooks
|
||||
│ └── pages/ # Home / Note / Tags / AdminLogin / AdminList / AdminTrash / AdminEdit
|
||||
└── deploy/
|
||||
@@ -230,7 +232,8 @@ CREATE TABLE IF NOT EXISTS notes (
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
deleted_at INTEGER, -- NULL=正常;非空=回收站(软删除)
|
||||
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
|
||||
ON notes (status, pinned, updated_at DESC);
|
||||
@@ -265,7 +268,7 @@ CREATE TABLE IF NOT EXISTS settings (
|
||||
);
|
||||
-- 键白名单(§7.1 SettingsDTO 同源):
|
||||
-- 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)
|
||||
```
|
||||
|
||||
@@ -324,10 +327,11 @@ CREATE TABLE IF NOT EXISTS settings (
|
||||
| PUT | `/api/admin/notes/{id}` | 更新(事务内重建 image_refs);slug 冲突返回 409 + 字段级错误 |
|
||||
| DELETE | `/api/admin/notes/{id}` | **软删除**:置 `deleted_at` 进入回收站 |
|
||||
| GET | `/api/admin/trash` | 回收站列表 |
|
||||
| DELETE | `/api/admin/trash` | **清空回收站**:物理删除全部软删除笔记(refs 级联) |
|
||||
| POST | `/api/admin/trash/{id}/restore` | 恢复:清空 `deleted_at`(slug 仍被自身占用,无冲突;外部占用则在 30 天内不可能,见 §6.2) |
|
||||
| POST | `/api/admin/images` | multipart 上传 → `{id, url}`;≤5MB |
|
||||
| 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 字符;复用登录限流 |
|
||||
|
||||
### 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 init` | 首次初始化:设口令(Argon2id+PHC)、站点标题 |
|
||||
| `pn init` | 首次初始化:设口令(Argon2id+PHC)、站点标题;并写入一篇 Markdown 语法示例文档(公开,slug `welcome`)及程序生成的示例图片 |
|
||||
| `pn passwd` | 重设管理员口令(覆盖旧哈希)并吊销全部会话;口令来源同 `init`(环境变量 `PN_ADMIN_PASSWORD` 或交互输入,不经 argv 避免 ps 泄露)。CLI 可达即具备服务器权限,允许直接重设 |
|
||||
| `pn backup [path]` | 在线备份:`VACUUM INTO`(一致快照,不停服);默认输出 0600 权限 |
|
||||
| `pn gc` | 回收站过期清除 + 孤儿图片清除 + 会话清理;**默认 `--dry-run`**,`--commit` 才执行(§6.3) |
|
||||
@@ -396,8 +400,8 @@ SecurityHeaders(含 HSTS,§9.2)
|
||||
| `/tags/:tag` | 标签页 | 该标签下公开笔记 |
|
||||
| `/admin/login` | 登录页 | 口令登录、防爆破提示 |
|
||||
| `/admin` | 管理列表 | 全部笔记(公/私标签可见)、状态开关、新建、删除(入回收站) |
|
||||
| `/admin/trash` | 回收站 | 已删列表、恢复 |
|
||||
| `/admin/notes/new` `/admin/notes/:id/edit` | 编辑器 | CodeMirror 源码编辑 + 实时预览(分屏/切换)、元信息侧栏(标题/slug/标签/摘要/公开开关/置顶)、图片粘贴上传 |
|
||||
| `/admin/trash` | 回收站 | 已删列表、恢复、一键清空 |
|
||||
| `/admin/notes/new` `/admin/notes/:id/edit` | 编辑器 | CodeMirror 源码编辑 + 实时预览(分屏/切换)、元信息侧栏(标题/slug/标签选择器/发布日期/摘要/公开开关/置顶)、图片粘贴上传 |
|
||||
| `*` | 404 | 统一兜底 |
|
||||
|
||||
### 8.2 核心技术要点
|
||||
@@ -705,7 +709,7 @@ timer 触发 `pn-maint.service`:先 `pn gc --commit`,后 `pn backup /backup/
|
||||
| react / react-dom | 19.2.8 | UI 框架 |
|
||||
| vite / @vitejs/plugin-react | 8.2.2 | 构建(base 默认 `'/'`) |
|
||||
| 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 | 路由 |
|
||||
| @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 |
|
||||
@@ -719,7 +723,7 @@ timer 触发 `pn-maint.service`:先 `pn gc --commit`,后 `pn backup /backup/
|
||||
- https://go.dev/dl/ · https://pkg.go.dev/modernc.org/sqlite
|
||||
- https://github.com/yuin/goldmark · https://github.com/microcosm-cc/bluemonday
|
||||
- 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/ueberdosis/tiptap · https://github.com/uiwjs/react-codemirror
|
||||
|
||||
|
||||
+68
-22
@@ -27,16 +27,17 @@ import (
|
||||
|
||||
// adminNoteItem 管理列表项(不含全文)。
|
||||
type adminNoteItem struct {
|
||||
ID int64 `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Status string `json:"status"`
|
||||
Tags []string `json:"tags"`
|
||||
Pinned bool `json:"pinned"`
|
||||
DeletedAt *int64 `json:"deleted_at,omitempty"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
ID int64 `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Status string `json:"status"`
|
||||
Tags []string `json:"tags"`
|
||||
Pinned bool `json:"pinned"`
|
||||
DeletedAt *int64 `json:"deleted_at,omitempty"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
PublishedAt int64 `json:"published_at"`
|
||||
}
|
||||
|
||||
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,
|
||||
Status: n.Status, Tags: n.Tags, Pinned: n.Pinned,
|
||||
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)})
|
||||
}
|
||||
|
||||
// notePayload 笔记写请求体。
|
||||
// notePayload 笔记写请求体。PublishedAt 为发布日期(Unix 秒);nil = 创建取当前时刻 / 更新保持不变。
|
||||
type notePayload struct {
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
Summary string `json:"summary"`
|
||||
Content string `json:"content"`
|
||||
Status string `json:"status"`
|
||||
Tags []string `json:"tags"`
|
||||
Pinned bool `json:"pinned"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
Summary string `json:"summary"`
|
||||
Content string `json:"content"`
|
||||
Status string `json:"status"`
|
||||
Tags []string `json:"tags"`
|
||||
Pinned bool `json:"pinned"`
|
||||
PublishedAt *int64 `json:"published_at"`
|
||||
}
|
||||
|
||||
func (p *notePayload) validate() (string, string) { // 返回 (错误码, 消息)
|
||||
@@ -106,10 +109,14 @@ func (s *Server) handleAdminNoteCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.TrimSpace(summary) == "" {
|
||||
summary = markdown.Summary(p.Content, 200)
|
||||
}
|
||||
publishedAt := now
|
||||
if p.PublishedAt != nil && *p.PublishedAt > 0 {
|
||||
publishedAt = *p.PublishedAt
|
||||
}
|
||||
n := &store.Note{
|
||||
Slug: slug, Title: strings.TrimSpace(p.Title), Summary: summary,
|
||||
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)
|
||||
if err != nil {
|
||||
@@ -203,6 +210,9 @@ func (s *Server) handleAdminNoteUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
n.Tags = store.NormalizeTags(p.Tags)
|
||||
n.Pinned = p.Pinned
|
||||
n.Content = p.Content
|
||||
if p.PublishedAt != nil && *p.PublishedAt > 0 {
|
||||
n.PublishedAt = *p.PublishedAt
|
||||
}
|
||||
if strings.TrimSpace(p.Summary) == "" {
|
||||
n.Summary = markdown.Summary(p.Content, 200)
|
||||
} 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)})
|
||||
}
|
||||
|
||||
// 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)。
|
||||
func (s *Server) handleAdminTrashRestore(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathID(r)
|
||||
@@ -396,6 +417,8 @@ type settingsDTO struct {
|
||||
SiteTitle *string `json:"site_title"`
|
||||
SiteDesc *string `json:"site_desc"`
|
||||
PageSize *int `json:"page_size"`
|
||||
BeianNo *string `json:"beian_no"`
|
||||
SiteLogo *string `json:"site_logo"`
|
||||
}
|
||||
|
||||
// handleAdminSettingsGet GET /api/admin/settings:白名单三键;永不序列化 admin_password_hash。
|
||||
@@ -414,7 +437,7 @@ func (s *Server) handleAdminSettingsPut(w http.ResponseWriter, r *http.Request)
|
||||
dec.DisallowUnknownFields()
|
||||
var dto settingsDTO
|
||||
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
|
||||
}
|
||||
if dto.SiteTitle != nil {
|
||||
@@ -439,8 +462,8 @@ func (s *Server) handleAdminSettingsPut(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
}
|
||||
if dto.PageSize != nil {
|
||||
if *dto.PageSize < 1 || *dto.PageSize > 100 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "page_size ∈ [1,100]")
|
||||
if !store.ValidPageSize(*dto.PageSize) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "page_size 仅支持 10/20/30/50")
|
||||
return
|
||||
}
|
||||
if err := s.st.SetSetting(store.KeyPageSize, strconv.Itoa(*dto.PageSize)); err != nil {
|
||||
@@ -448,6 +471,29 @@ func (s *Server) handleAdminSettingsPut(w http.ResponseWriter, r *http.Request)
|
||||
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):不在白名单结构体中,天然拒绝。
|
||||
s.log.Info("admin_action", "op", "settings.update")
|
||||
ss, err := s.st.GetSiteSettings()
|
||||
|
||||
@@ -284,20 +284,43 @@ func TestSettingsWhitelist(t *testing.T) {
|
||||
}
|
||||
// PUT 合法键
|
||||
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 {
|
||||
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 生效为公开列表默认
|
||||
_, 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)
|
||||
}
|
||||
// page_size 越界
|
||||
resp, _ = e.do(admin, http.MethodPut, "/api/admin/settings",
|
||||
[]byte(`{"page_size":0}`), e.adminHeaders())
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("page_size=0 应 400: %d", resp.StatusCode)
|
||||
// page_size 越界/非候选项
|
||||
for _, bad := range []string{"0", "5", "101"} {
|
||||
resp, _ = e.do(admin, http.MethodPut, "/api/admin/settings",
|
||||
[]byte(`{"page_size":`+bad+`}`), e.adminHeaders())
|
||||
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"`
|
||||
Link string `xml:"link"`
|
||||
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 自动转义)
|
||||
}
|
||||
|
||||
@@ -92,8 +92,8 @@ func (s *Server) handleRSS(w http.ResponseWriter, r *http.Request) {
|
||||
// ---- sitemap(仅公开笔记)----
|
||||
|
||||
type urlSet struct {
|
||||
XMLName xml.Name `xml:"urlset"`
|
||||
XMLNS string `xml:"xmlns,attr"`
|
||||
XMLName xml.Name `xml:"urlset"`
|
||||
XMLNS string `xml:"xmlns,attr"`
|
||||
URLs []siteURL `xml:"url"`
|
||||
}
|
||||
|
||||
|
||||
+15
-11
@@ -40,15 +40,17 @@ func (s *Server) handleSiteInfo(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, ss)
|
||||
}
|
||||
|
||||
// publicNoteItem 公开列表项(元信息,不含全文)。
|
||||
// publicNoteItem 公开列表项(元信息 + 封面图,不含全文)。
|
||||
type publicNoteItem struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Tags []string `json:"tags"`
|
||||
Pinned bool `json:"pinned"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Tags []string `json:"tags"`
|
||||
Pinned bool `json:"pinned"`
|
||||
Cover string `json:"cover,omitempty"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
PublishedAt int64 `json:"published_at"`
|
||||
}
|
||||
|
||||
// handlePublicNotes GET /api/notes:公开笔记列表(可见性过滤在查询层,§9.1-T10)。
|
||||
@@ -70,7 +72,8 @@ func (s *Server) handlePublicNotes(w http.ResponseWriter, r *http.Request) {
|
||||
items = append(items, publicNoteItem{
|
||||
Slug: n.Slug, Title: n.Title, Summary: n.Summary,
|
||||
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{
|
||||
@@ -119,8 +122,9 @@ func (s *Server) handlePublicNote(w http.ResponseWriter, r *http.Request) {
|
||||
"summary": note.Summary, "content": note.Content,
|
||||
"status": note.Status, "tags": note.Tags, "pinned": note.Pinned,
|
||||
"created_at": note.CreatedAt, "updated_at": note.UpdatedAt,
|
||||
"prev": siblingOrEmpty(prevSlug, prevTitle),
|
||||
"next": siblingOrEmpty(nextSlug, nextTitle),
|
||||
"published_at": note.PublishedAt,
|
||||
"prev": siblingOrEmpty(prevSlug, prevTitle),
|
||||
"next": siblingOrEmpty(nextSlug, nextTitle),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+22
-12
@@ -29,12 +29,12 @@ const (
|
||||
|
||||
// Server HTTP 服务。
|
||||
type Server struct {
|
||||
st *store.Store
|
||||
cfg *config.Config
|
||||
log *slog.Logger
|
||||
global *middleware.Limiter // 全局宽松限流(per-IP)
|
||||
loginIP *middleware.Limiter // 登录/改密 per-IP:10 次/5 分钟
|
||||
loginAcct *middleware.Limiter // 登录/改密 per-账号:5 次/10 分钟
|
||||
st *store.Store
|
||||
cfg *config.Config
|
||||
log *slog.Logger
|
||||
global *middleware.Limiter // 全局宽松限流(per-IP)
|
||||
loginIP *middleware.Limiter // 登录/改密 per-IP:10 次/5 分钟
|
||||
loginAcct *middleware.Limiter // 登录/改密 per-账号:5 次/10 分钟
|
||||
}
|
||||
|
||||
// New 构造 Server(生产限流参数,§7.2)。
|
||||
@@ -93,6 +93,7 @@ func (s *Server) Handler(ui http.Handler) http.Handler {
|
||||
adminMux.Handle("PUT /api/admin/notes/{id}", middleware.MaxBytes(maxNoteBody)(http.HandlerFunc(s.handleAdminNoteUpdate)))
|
||||
adminMux.HandleFunc("DELETE /api/admin/notes/{id}", s.handleAdminNoteDelete)
|
||||
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.Handle("POST /api/admin/images", middleware.MaxBytes(maxUploadBody)(http.HandlerFunc(s.handleAdminImageUpload)))
|
||||
adminMux.HandleFunc("GET /api/admin/images", s.handleAdminImages)
|
||||
@@ -291,13 +292,14 @@ func (s *Server) ResolveMeta(r *http.Request) webui.Meta {
|
||||
ss = &store.SiteSettings{SiteTitle: store.DefaultSiteTitle}
|
||||
}
|
||||
m := webui.Meta{
|
||||
Title: ss.SiteTitle,
|
||||
Description: ss.SiteDesc,
|
||||
OGTitle: ss.SiteTitle,
|
||||
Title: ss.SiteTitle,
|
||||
Description: ss.SiteDesc,
|
||||
OGTitle: ss.SiteTitle,
|
||||
OGDescription: ss.SiteDesc,
|
||||
OGType: "website",
|
||||
SiteName: ss.SiteTitle,
|
||||
OGURL: baseURL(r) + "/",
|
||||
OGType: "website",
|
||||
SiteName: ss.SiteTitle,
|
||||
OGURL: baseURL(r) + "/",
|
||||
Icon: favicon(ss.SiteLogo),
|
||||
}
|
||||
slug := metaSlug(r.URL.Path)
|
||||
if slug == "" {
|
||||
@@ -317,6 +319,14 @@ func (s *Server) ResolveMeta(r *http.Request) webui.Meta {
|
||||
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 {
|
||||
desc := n.Summary
|
||||
if desc == "" {
|
||||
|
||||
@@ -154,12 +154,12 @@ func TestVisibilityMatrix(t *testing.T) {
|
||||
|
||||
// ---- 图片出口(并集语义 + 缓存头分流,§6.2/§7.4)----
|
||||
imageCases := []struct {
|
||||
name string
|
||||
id int64
|
||||
anonCode int
|
||||
anonCache string
|
||||
adminCode int
|
||||
adminCache string
|
||||
name string
|
||||
id int64
|
||||
anonCode int
|
||||
anonCache string
|
||||
adminCode int
|
||||
adminCache string
|
||||
}{
|
||||
{"公开图", f.imgPub, 200, "public, max-age=31536000, immutable", 200, "public, max-age=31536000, immutable"},
|
||||
{"私有图", f.imgPriv, 404, "", 200, "private, no-store"},
|
||||
|
||||
@@ -64,6 +64,16 @@ func (s *Store) ImageIsPublic(id int64) (bool, error) {
|
||||
// imageRefRe 扫描 content 中对 /api/images/{id} 的引用(§6.2 引用维护)。
|
||||
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 在**同一事务**内重建笔记的图片引用:
|
||||
// 删除该笔记全部 refs → 正则扫描 content → 重建(§6.2)。
|
||||
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 检视用)。
|
||||
// 站点 Logo 引用的图片虽无笔记引用仍受保护,不视为孤儿。
|
||||
func (s *Store) ListOrphanImages() ([]Image, error) {
|
||||
rows, err := s.db.Query(`
|
||||
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)
|
||||
ORDER BY i.created_at DESC`)
|
||||
AND i.id <> ?
|
||||
ORDER BY i.created_at DESC`, s.logoImageID())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -54,12 +54,14 @@ func (s *Store) GC(now time.Time, dryRun bool) (*GCReport, error) {
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
// 孤儿图片:0 引用且超过宽限期
|
||||
// 孤儿图片:0 引用且超过宽限期;站点 Logo 引用的图片受保护
|
||||
imageCutoff := now.Add(-OrphanGrace).Unix()
|
||||
logoID := s.logoImageID()
|
||||
orphanRows, err := s.db.Query(`
|
||||
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)
|
||||
ORDER BY i.created_at`, imageCutoff)
|
||||
AND i.id <> ?
|
||||
ORDER BY i.created_at`, imageCutoff, logoID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -101,9 +103,10 @@ func (s *Store) GC(now time.Time, dryRun bool) (*GCReport, error) {
|
||||
}
|
||||
}
|
||||
for _, img := range rep.OrphanImages {
|
||||
// 条件复查含 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=?)`,
|
||||
img.ID, img.ID)
|
||||
`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)
|
||||
}
|
||||
|
||||
+34
-21
@@ -9,31 +9,34 @@ import (
|
||||
)
|
||||
|
||||
// Note 笔记实体。DeletedAt 非 nil 表示处于回收站(软删除)。
|
||||
// PublishedAt 为对外展示的发布日期(可自选,§8.2);排序仍按 updated_at。
|
||||
type Note struct {
|
||||
ID int64 `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Tags []string `json:"tags"`
|
||||
Pinned bool `json:"pinned"`
|
||||
DeletedAt *int64 `json:"deleted_at,omitempty"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
ID int64 `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Tags []string `json:"tags"`
|
||||
Pinned bool `json:"pinned"`
|
||||
DeletedAt *int64 `json:"deleted_at,omitempty"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
PublishedAt int64 `json:"published_at"`
|
||||
}
|
||||
|
||||
// ErrNotFound 统一的「不存在」错误。
|
||||
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) {
|
||||
var n Note
|
||||
var tagsJSON string
|
||||
var pinned int
|
||||
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
|
||||
}
|
||||
n.Content = content
|
||||
@@ -54,9 +57,9 @@ func (s *Store) CreateNote(n *Note) (int64, error) {
|
||||
return 0, err
|
||||
}
|
||||
res, err := s.db.Exec(
|
||||
`INSERT INTO notes (slug, title, summary, content, status, tags, pinned, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
n.Slug, n.Title, n.Summary, n.Content, n.Status, tags, boolToInt(n.Pinned), n.CreatedAt, n.UpdatedAt)
|
||||
`INSERT INTO notes (slug, title, summary, content, status, tags, pinned, published_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
n.Slug, n.Title, n.Summary, n.Content, n.Status, tags, boolToInt(n.Pinned), n.PublishedAt, n.CreatedAt, n.UpdatedAt)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -70,8 +73,8 @@ func (s *Store) UpdateNote(n *Note) error {
|
||||
return err
|
||||
}
|
||||
res, err := s.db.Exec(
|
||||
`UPDATE notes SET title=?, summary=?, content=?, status=?, tags=?, pinned=?, 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)
|
||||
`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.PublishedAt, n.UpdatedAt, n.ID)
|
||||
if err != nil {
|
||||
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 {
|
||||
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 + `
|
||||
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)...)
|
||||
@@ -234,7 +238,7 @@ func (s *Store) ListPublicNotesFull(limit int) ([]Note, error) {
|
||||
|
||||
// ListAdminNotes 全部正常笔记(含私有,不含回收站),不含全文。
|
||||
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
|
||||
ORDER BY pinned DESC, updated_at DESC, id DESC`
|
||||
return s.queryNotes(q)
|
||||
@@ -242,12 +246,21 @@ func (s *Store) ListAdminNotes() ([]Note, error) {
|
||||
|
||||
// ListTrash 回收站列表(软删除中),不含全文。
|
||||
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
|
||||
ORDER BY deleted_at DESC`
|
||||
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 用)。
|
||||
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)
|
||||
|
||||
@@ -13,6 +13,8 @@ const (
|
||||
KeySiteTitle = "site_title"
|
||||
KeySiteDesc = "site_desc"
|
||||
KeyPageSize = "page_size"
|
||||
KeyBeianNo = "beian_no"
|
||||
KeySiteLogo = "site_logo"
|
||||
)
|
||||
|
||||
// GetSetting 读取单个设置。
|
||||
@@ -37,6 +39,8 @@ type SiteSettings struct {
|
||||
SiteTitle string `json:"site_title"`
|
||||
SiteDesc string `json:"site_desc"`
|
||||
PageSize int `json:"page_size"`
|
||||
BeianNo string `json:"beian_no"`
|
||||
SiteLogo string `json:"site_logo"`
|
||||
}
|
||||
|
||||
// Defaults,未初始化时兜底。
|
||||
@@ -44,11 +48,21 @@ const (
|
||||
DefaultSiteTitle = "Pure Note"
|
||||
DefaultSiteDesc = ""
|
||||
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) {
|
||||
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 {
|
||||
return nil, err
|
||||
} else if ok && v != "" {
|
||||
@@ -62,10 +76,36 @@ func (s *Store) GetSiteSettings() (*SiteSettings, error) {
|
||||
if v, ok, err := s.GetSetting(KeyPageSize); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
// strconv.Atoi 全文解析:拒绝 "10abc" 类部分解析的脏值(评审 round2 P2-13)
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n >= 1 && n <= 100 {
|
||||
// strconv.Atoi 全文解析:拒绝 "10abc" 类部分解析的脏值(评审 round2 P2-13);
|
||||
// 脏值/越界值回退默认,而非落入任意 [1,100]
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && ValidPageSize(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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -69,6 +69,11 @@ CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
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;
|
||||
`,
|
||||
}
|
||||
|
||||
|
||||
@@ -150,10 +150,10 @@ func TestAdjacentPublicNote(t *testing.T) {
|
||||
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("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 {
|
||||
|
||||
@@ -30,6 +30,7 @@ type Meta struct {
|
||||
OGURL string
|
||||
OGImage string
|
||||
SiteName string
|
||||
Icon string // favicon:站点 Logo,未设置回退 /favicon.svg
|
||||
}
|
||||
|
||||
// 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">
|
||||
<head>
|
||||
<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" />
|
||||
<!--
|
||||
以下占位符由服务端 internal/webui 以 html/template 渲染并自动转义(§8.3-4)。
|
||||
|
||||
Generated
+1440
-32
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,9 @@
|
||||
"@codemirror/lang-markdown": "^6.5.2",
|
||||
"@tanstack/react-query": "^5.102.8",
|
||||
"@uiw/react-codemirror": "^4.25.11",
|
||||
"antd": "^6.6.3",
|
||||
"antd-style": "^4.1.0",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.42.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
+49
-21
@@ -1,7 +1,11 @@
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { Navigate, Route, Routes } from 'react-router'
|
||||
import { lazy, Suspense, useEffect, useState } 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 { useTheme } from './lib/theme'
|
||||
import useShadcnTheme from './theme/shadcnTheme'
|
||||
import { BlogLayout, AdminLayout } from './components/Layout'
|
||||
import Home from './pages/Home'
|
||||
import TagPage from './pages/TagPage'
|
||||
@@ -16,11 +20,32 @@ import NotFound from './pages/NotFound'
|
||||
const Note = lazy(() => import('./pages/Note'))
|
||||
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)
|
||||
function RequireAuth({ children }: { children: ReactNode }) {
|
||||
const { authenticated, loading } = useAuth()
|
||||
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) {
|
||||
return <Navigate to="/admin/login" replace />
|
||||
@@ -30,23 +55,26 @@ function RequireAuth({ children }: { children: ReactNode }) {
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex min-h-screen items-center justify-center text-zinc-500">加载中…</div>}>
|
||||
<Routes>
|
||||
<Route element={<BlogLayout />}>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/notes/:slug" element={<Note />} />
|
||||
<Route path="/tags/:tag" element={<TagPage />} />
|
||||
</Route>
|
||||
<Route path="/admin/login" element={<Login />} />
|
||||
<Route element={<RequireAuth><AdminLayout /></RequireAuth>}>
|
||||
<Route path="/admin" element={<AdminList />} />
|
||||
<Route path="/admin/trash" element={<AdminTrash />} />
|
||||
<Route path="/admin/settings" element={<AdminSettings />} />
|
||||
<Route path="/admin/notes/new" element={<AdminEdit />} />
|
||||
<Route path="/admin/notes/:id/edit" element={<AdminEdit />} />
|
||||
</Route>
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
<AntdProvider>
|
||||
<Suspense fallback={<div className="pn-loading"><Spin size="large" /></div>}>
|
||||
<Routes>
|
||||
<Route element={<BlogLayout />}>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/notes/:slug" element={<Note />} />
|
||||
<Route path="/tags" element={<TagPage />} />
|
||||
<Route path="/tags/:tag" element={<TagPage />} />
|
||||
</Route>
|
||||
<Route path="/admin/login" element={<Login />} />
|
||||
<Route element={<RequireAuth><AdminLayout /></RequireAuth>}>
|
||||
<Route path="/admin" element={<AdminList />} />
|
||||
<Route path="/admin/trash" element={<AdminTrash />} />
|
||||
<Route path="/admin/settings" element={<AdminSettings />} />
|
||||
<Route path="/admin/notes/new" element={<AdminEdit />} />
|
||||
<Route path="/admin/notes/:id/edit" element={<AdminEdit />} />
|
||||
</Route>
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</AntdProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import CodeMirror from '@uiw/react-codemirror'
|
||||
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { api, ApiError } from '../lib/api'
|
||||
import { useTheme } from '../lib/theme'
|
||||
|
||||
/**
|
||||
* Editor(编辑态,§8.2):CodeMirror 6 源码编辑 + 图片粘贴/拖拽上传。
|
||||
@@ -22,6 +23,12 @@ async function uploadImage(file: File): Promise<UploadResult> {
|
||||
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({
|
||||
value,
|
||||
onChange,
|
||||
@@ -35,6 +42,7 @@ export default function Editor({
|
||||
}) {
|
||||
const viewRef = useRef<{ view?: EditorView } | null>(null)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const { mode } = useTheme()
|
||||
|
||||
const extensions = useMemo(
|
||||
() => [
|
||||
@@ -81,9 +89,8 @@ export default function Editor({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative h-full overflow-hidden rounded-lg border ${
|
||||
dragOver ? 'border-blue-500 bg-blue-50 dark:bg-blue-950' : 'border-zinc-200 dark:border-zinc-800'
|
||||
}`}
|
||||
className={dragOver ? 'pn-cm-drag' : undefined}
|
||||
style={{ position: 'relative', height: '100%', overflow: 'hidden', borderRadius: 'var(--radius-sm)' }}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
setDragOver(true)
|
||||
@@ -100,9 +107,9 @@ export default function Editor({
|
||||
onChange={onChange}
|
||||
extensions={extensions}
|
||||
basicSetup={{ lineNumbers: true, foldGutter: false, highlightActiveLine: true }}
|
||||
theme="light"
|
||||
theme={isDarkMode(mode) ? 'dark' : 'light'}
|
||||
height="100%"
|
||||
style={{ height: '100%' }}
|
||||
style={{ height: '100%', minHeight: 420 }}
|
||||
onCreateEditor={(view) => {
|
||||
viewRef.current = { view }
|
||||
}}
|
||||
@@ -115,7 +122,7 @@ export default function Editor({
|
||||
}}
|
||||
/>
|
||||
{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)
|
||||
</div>
|
||||
)}
|
||||
|
||||
+303
-83
@@ -1,101 +1,321 @@
|
||||
import { Link, NavLink, Outlet } from 'react-router'
|
||||
import { Moon, Sun, Monitor } from 'lucide-react'
|
||||
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router'
|
||||
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 { useTheme } from '../lib/theme'
|
||||
import { useAuth } from '../lib/auth'
|
||||
|
||||
export function SiteHeader() {
|
||||
const { data } = useSiteTitle()
|
||||
const { mode, setMode } = useTheme()
|
||||
/* ── 溢出菜单:antd Dropdown(外点关闭/Esc/焦点管理由 antd 承接) ── */
|
||||
|
||||
function MoreMenu({ triggerClassName, triggerLabel, items }: {
|
||||
triggerClassName: string
|
||||
triggerLabel: string
|
||||
items: MenuProps['items']
|
||||
}) {
|
||||
return (
|
||||
<header className="border-b border-zinc-200 dark:border-zinc-800">
|
||||
<div className="mx-auto flex max-w-3xl items-center justify-between px-4 py-4">
|
||||
<Link to="/" className="text-xl font-bold tracking-tight hover:opacity-80">
|
||||
{data?.site_title ?? 'Pure Note'}
|
||||
</Link>
|
||||
<div className="flex items-center gap-1">
|
||||
<ThemeButton mode={mode} setMode={setMode} />
|
||||
// wrapper:与设计稿一致占一个 flex 槽位(display:none 触发器不产生 gap,
|
||||
// 曾致桌面导航整体横移 4px)
|
||||
<div className="pn-moremenu">
|
||||
<Dropdown trigger={['click']} placement="bottomRight" menu={{ items }}>
|
||||
<Button type="text" className={triggerClassName} aria-label={triggerLabel} icon={<MoreHorizontal size={15} />} />
|
||||
</Dropdown>
|
||||
</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>
|
||||
</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>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
function ThemeButton({
|
||||
mode,
|
||||
setMode,
|
||||
}: {
|
||||
mode: 'light' | 'dark' | 'system'
|
||||
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 adminNav = [
|
||||
{ to: '/admin/notes/new', label: '新建笔记', icon: PenLine, end: false, match: 'prefix' as const },
|
||||
{ to: '/admin', label: '笔记管理', icon: House, end: true, match: 'exact' as const },
|
||||
{ to: '/admin/trash', label: '回收站', icon: Trash2, end: true, match: 'exact' as const },
|
||||
{ to: '/admin/settings', label: '设置', icon: Settings, end: true, match: 'exact' as const },
|
||||
]
|
||||
|
||||
const adminMenuPathByKey: Record<string, string> = {
|
||||
notes: '/admin',
|
||||
new: '/admin/notes/new',
|
||||
trash: '/admin/trash',
|
||||
settings: '/admin/settings',
|
||||
}
|
||||
|
||||
function AdminSidebar() {
|
||||
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() {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<SiteHeader />
|
||||
<div className="mx-auto max-w-5xl px-4">
|
||||
<nav className="flex gap-1 border-b border-zinc-200 py-2 dark:border-zinc-800">
|
||||
{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 className="pn-admin-shell">
|
||||
<AdminSidebar />
|
||||
<div className="pn-admin-main">
|
||||
<Outlet />
|
||||
</div>
|
||||
<AdminTabbar />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { memo } from 'react'
|
||||
import { memo, useState } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import rehypeHighlight from 'rehype-highlight'
|
||||
import { Button } from 'antd'
|
||||
import { sanitizePlugin } from '../lib/sanitize'
|
||||
|
||||
/**
|
||||
* MarkdownViewer:浏览态渲染管线(§8.2),组件边界独立、可整体替换。
|
||||
* 排版样式由 index.css 的 .pn-note-body 提供(设计稿正文排版契约)。
|
||||
*
|
||||
* Markdown 原文
|
||||
* → remark-gfm(表格/任务列表/删除线/自动链接)
|
||||
@@ -16,7 +18,7 @@ import { sanitizePlugin } from '../lib/sanitize'
|
||||
*/
|
||||
function CodeBlock(props: React.HTMLAttributes<HTMLPreElement>) {
|
||||
return (
|
||||
<div className="group relative">
|
||||
<div>
|
||||
<pre {...props} />
|
||||
<CopyButton />
|
||||
</div>
|
||||
@@ -24,24 +26,35 @@ function CodeBlock(props: React.HTMLAttributes<HTMLPreElement>) {
|
||||
}
|
||||
|
||||
function CopyButton() {
|
||||
// 复制按钮在点击时读取最近兄弟 <pre> 的文本;不注入任何 HTML
|
||||
// 复制按钮在点击时读取最近兄弟 <pre> 的文本;不注入任何 HTML。
|
||||
// MarkdownViewer 会在无 App 上下文的单测中渲染,反馈用本地状态而非 message。
|
||||
const [copied, setCopied] = useState(false)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
<Button
|
||||
size="small"
|
||||
className={copied ? 'pn-copy-btn is-copied' : 'pn-copy-btn'}
|
||||
onClick={(e) => {
|
||||
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 不可用(非安全上下文等)时静默 */
|
||||
}
|
||||
}}
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
{copied ? '已复制' : '复制'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const MarkdownViewer = memo(function MarkdownViewer({ source }: { source: string }) {
|
||||
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
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[sanitizePlugin, rehypeHighlight]}
|
||||
|
||||
@@ -1,11 +1,35 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from '../lib/api'
|
||||
import type { SiteSettings } from '../lib/api'
|
||||
|
||||
export function useSiteTitle() {
|
||||
return useQuery({
|
||||
const { data } = useQuery({
|
||||
queryKey: ['site'],
|
||||
queryFn: () => api<SiteSettings>('/api/site'),
|
||||
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,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
+1963
-7
File diff suppressed because it is too large
Load Diff
@@ -87,6 +87,8 @@ export interface SiteSettings {
|
||||
site_title: string
|
||||
site_desc: string
|
||||
page_size: number
|
||||
beian_no: string
|
||||
site_logo: string
|
||||
}
|
||||
|
||||
export interface NoteItem {
|
||||
@@ -97,9 +99,12 @@ export interface NoteItem {
|
||||
status?: string
|
||||
tags: string[]
|
||||
pinned: boolean
|
||||
/** 公开列表项的封面图(正文第一张图;无图缺省) */
|
||||
cover?: string
|
||||
deleted_at?: number | null
|
||||
created_at: number
|
||||
updated_at: number
|
||||
published_at: number
|
||||
}
|
||||
|
||||
export interface NoteDetail extends NoteItem {
|
||||
|
||||
+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 {
|
||||
return new Date(unix * 1000).toLocaleDateString('zh-CN', {
|
||||
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 时提示将自动生成
|
||||
export function slugPreview(title: string, date: Date): string {
|
||||
const t = title.trim().toLowerCase().replace(/_/g, '-').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').replace(/^-+|-+$/g, '')
|
||||
|
||||
+231
-157
@@ -1,11 +1,19 @@
|
||||
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 { 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, EyeOff, 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 type { NoteItem } from '../lib/api'
|
||||
import { invalidatePublic } from '../lib/invalidate'
|
||||
import { useDocumentTitle } from '../features/site'
|
||||
import MarkdownViewer from '../components/MarkdownViewer'
|
||||
import { AdminTopbar } from '../components/Layout'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import { slugPreview } from '../lib/utils'
|
||||
|
||||
// CodeMirror 体积大,单独拆 chunk,编辑器在首帧后异步加载
|
||||
@@ -23,26 +31,30 @@ type Form = {
|
||||
summary: string
|
||||
content: string
|
||||
status: 'public' | 'private'
|
||||
tags: string
|
||||
tags: string[]
|
||||
pinned: boolean
|
||||
publishedAt: number
|
||||
}
|
||||
|
||||
const emptyForm: Form = {
|
||||
const emptyForm = (): Form => ({
|
||||
title: '',
|
||||
slug: '',
|
||||
summary: '',
|
||||
content: '',
|
||||
status: 'private',
|
||||
tags: '',
|
||||
tags: [],
|
||||
pinned: false,
|
||||
}
|
||||
publishedAt: Math.floor(Date.now() / 1000),
|
||||
})
|
||||
|
||||
export default function AdminEdit() {
|
||||
const { id } = useParams()
|
||||
const noteId = id ? Number(id) : null
|
||||
const isNew = noteId === null
|
||||
useDocumentTitle(isNew ? '新建笔记' : '编辑笔记')
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
const { toast } = useToast()
|
||||
|
||||
const { data: existing, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'note', noteId],
|
||||
@@ -50,16 +62,28 @@ export default function AdminEdit() {
|
||||
enabled: !isNew,
|
||||
})
|
||||
|
||||
// 既有标签(含私有笔记)作为选择器候选项
|
||||
const { data: allNotes } = useQuery({
|
||||
queryKey: ['admin', 'notes'],
|
||||
queryFn: () => api<{ items: NoteItem[] }>('/api/admin/notes'),
|
||||
})
|
||||
|
||||
const [form, setForm] = useState<Form>(emptyForm)
|
||||
const [savedAt, setSavedAt] = useState<number | null>(null)
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null)
|
||||
const [slugFieldError, setSlugFieldError] = useState<string | null>(null)
|
||||
const [showPreview, setShowPreview] = useState(true)
|
||||
const [uploadMsg, setUploadMsg] = useState<string | null>(null)
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
const [uploadMsg, setUploadMsg] = useState<{ kind: 'busy' | 'error'; text: string } | null>(null)
|
||||
const dirtyRef = useRef(false)
|
||||
const slugTouchedRef = useRef(false)
|
||||
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(() => {
|
||||
if (existing) {
|
||||
setForm({
|
||||
@@ -68,8 +92,9 @@ export default function AdminEdit() {
|
||||
summary: existing.summary,
|
||||
content: existing.content,
|
||||
status: existing.status === 'public' ? 'public' : 'private',
|
||||
tags: existing.tags.join(', '),
|
||||
tags: existing.tags,
|
||||
pinned: existing.pinned,
|
||||
publishedAt: existing.published_at || existing.created_at,
|
||||
})
|
||||
slugTouchedRef.current = false
|
||||
dirtyRef.current = false
|
||||
@@ -84,15 +109,17 @@ export default function AdminEdit() {
|
||||
}
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (f: Form) => {
|
||||
mutationFn: async (vars: { f: Form; auto: boolean }) => {
|
||||
const { f } = vars
|
||||
const payload = {
|
||||
title: f.title,
|
||||
slug: f.slug,
|
||||
summary: f.summary,
|
||||
content: f.content,
|
||||
status: f.status,
|
||||
tags: f.tags.split(/[,,]/).map((t) => t.trim()).filter(Boolean),
|
||||
tags: f.tags,
|
||||
pinned: f.pinned,
|
||||
published_at: f.publishedAt,
|
||||
}
|
||||
if (isNew) {
|
||||
// 新建:slug 由服务端从标题自动生成(首次保存即定稿,§8.2)
|
||||
@@ -106,11 +133,11 @@ export default function AdminEdit() {
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
onSuccess: (saved) => {
|
||||
setSavedAt(Date.now())
|
||||
onSuccess: (saved, vars) => {
|
||||
setSlugFieldError(null)
|
||||
setErrorMsg(null)
|
||||
dirtyRef.current = false
|
||||
toast({ description: vars.auto ? '已自动保存' : '已保存' })
|
||||
// 仅失效列表与公共缓存;不失效当前编辑详情(['admin','note',id]),
|
||||
// 防 refetch 回滚保存期间继续输入的内容并抑制下一轮自动保存(评审 round2 P1-4)
|
||||
void qc.invalidateQueries({ queryKey: ['admin', 'notes'] })
|
||||
@@ -120,6 +147,7 @@ export default function AdminEdit() {
|
||||
}
|
||||
},
|
||||
onError: (e) => {
|
||||
toast({ description: '保存失败,请重试', variant: 'error' })
|
||||
if (e instanceof ApiError && e.code === 'slug_conflict') {
|
||||
setSlugFieldError(e.message)
|
||||
} else {
|
||||
@@ -128,6 +156,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 autosaveReady = !isNew || form.content.trim().length > 0
|
||||
|
||||
@@ -136,7 +174,9 @@ export default function AdminEdit() {
|
||||
if (!canSave || !autosaveReady || !dirtyRef.current) return
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
timerRef.current = setTimeout(() => {
|
||||
if (dirtyRef.current) save.mutate(form)
|
||||
if (dirtyRef.current) {
|
||||
save.mutate({ f: form, auto: true })
|
||||
}
|
||||
}, 2000)
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
@@ -144,162 +184,196 @@ export default function AdminEdit() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [form, canSave, autosaveReady])
|
||||
|
||||
const wrapSelection = useCallback(
|
||||
(before: string, after = before, placeholder = '文本') => {
|
||||
const el = document.querySelector<HTMLElement>('.cm-content')
|
||||
// 简化实现:在内容末尾/光标处理由 CodeMirror 自行接管,这里直接改源码
|
||||
void el
|
||||
set('content', form.content + `${before}${placeholder}${after}`)
|
||||
},
|
||||
[form.content],
|
||||
)
|
||||
const insert = (snippet: string) => set('content', form.content + snippet)
|
||||
|
||||
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 (
|
||||
<div className="flex h-[calc(100vh-8rem)] flex-col">
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<input
|
||||
value={form.title}
|
||||
onChange={(e) => set('title', e.target.value)}
|
||||
placeholder="标题(必填)"
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
<>
|
||||
<AdminTopbar title={isNew ? '新建笔记' : '编辑笔记'}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={showPreview ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||
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} />}
|
||||
{showPreview ? '隐藏预览' : '显示预览'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => save.mutate(form)}
|
||||
{showPreview ? '隐藏预览' : '预览'}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Save size={15} />}
|
||||
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 ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
{save.isPending ? '保存中…' : isNew ? '保存草稿' : '保存'}
|
||||
</Button>
|
||||
</AdminTopbar>
|
||||
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-zinc-400">
|
||||
<button type="button" onClick={() => wrapSelection('**')} className="rounded p-1 hover:bg-zinc-100 dark:hover:bg-zinc-800"><Bold size={14} /></button>
|
||||
<button type="button" onClick={() => wrapSelection('*')} className="rounded p-1 hover:bg-zinc-100 dark:hover:bg-zinc-800"><Italic size={14} /></button>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<span className="mx-1 text-zinc-300 dark:text-zinc-700">|</span>
|
||||
<span>粘贴 / 拖拽图片即可上传</span>
|
||||
<span className="ml-auto">
|
||||
{save.isPending
|
||||
? '保存中…'
|
||||
: savedAt
|
||||
? `已保存 ${new Date(savedAt).toLocaleTimeString('zh-CN')}`
|
||||
: isNew
|
||||
? '未保存'
|
||||
: '无改动'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pn-admin-content">
|
||||
<div className="pn-ae-grid">
|
||||
<Card className="pn-ae-main min-w-0">
|
||||
<Input
|
||||
variant="filled"
|
||||
className="pn-ae-title-input"
|
||||
type="text"
|
||||
value={form.title}
|
||||
onChange={(e) => set('title', e.target.value)}
|
||||
placeholder="输入标题…"
|
||||
aria-label="笔记标题"
|
||||
/>
|
||||
|
||||
<div className="flex min-h-0 flex-1 gap-3">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-3">
|
||||
<div className="min-h-0 flex-1">
|
||||
<Suspense fallback={<div className="flex h-full items-center justify-center text-sm text-zinc-500">编辑器加载中…</div>}>
|
||||
<Editor
|
||||
value={form.content}
|
||||
onChange={(v) => set('content', v)}
|
||||
onUploadStart={(name) => setUploadMsg(`上传中:${name}…`)}
|
||||
onUploadEnd={(err) => setUploadMsg(err ?? null)}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
{uploadMsg && <p className="text-xs text-zinc-400">{uploadMsg}</p>}
|
||||
{showPreview && (
|
||||
<div className="hidden min-h-0 flex-1 overflow-auto rounded-lg border border-zinc-200 p-4 lg:block dark:border-zinc-800">
|
||||
<MarkdownViewer source={form.content} />
|
||||
<div className="pn-ae-meta">
|
||||
<span className="pn-ae-slug" title={`/notes/${slugDisplay}`}>
|
||||
<Link2 size={12} />
|
||||
<span>/notes/{slugDisplay}</span>
|
||||
</span>
|
||||
<div className="pn-ae-tags">
|
||||
<Select
|
||||
mode="tags"
|
||||
className="pn-ae-tags-select"
|
||||
value={form.tags}
|
||||
options={tagOptions}
|
||||
placeholder="添加或选择标签"
|
||||
aria-label="标签"
|
||||
tokenSeparators={[',']}
|
||||
onChange={(v) => set('tags', v)}
|
||||
/>
|
||||
</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>
|
||||
<label className="text-xs font-medium text-zinc-500">slug</label>
|
||||
<input
|
||||
value={form.slug}
|
||||
onChange={(e) => set('slug', e.target.value)}
|
||||
placeholder={form.title ? slugPreview(form.title, new Date()) : '保存后自动生成'}
|
||||
className={`mt-1 w-full rounded-md border px-2 py-1.5 font-mono text-xs outline-none ${
|
||||
slugFieldError
|
||||
? 'border-red-500 bg-red-50 dark:bg-red-950'
|
||||
: 'border-zinc-300 dark:border-zinc-700'
|
||||
}`}
|
||||
/>
|
||||
{slugFieldError && <p className="mt-1 text-xs text-red-600">{slugFieldError}</p>}
|
||||
<p className="mt-1 text-[10px] leading-relaxed text-zinc-400">
|
||||
留空保持不变;首次保存自动生成后定稿
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-zinc-500">标签(逗号分隔)</label>
|
||||
<input
|
||||
value={form.tags}
|
||||
onChange={(e) => set('tags', e.target.value)}
|
||||
placeholder="随笔, go"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<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'
|
||||
}`}
|
||||
<div className="pn-ae-toolbar" role="toolbar" aria-label="格式工具">
|
||||
<Button type="text" className="pn-ae-tool" aria-label="加粗" title="加粗" icon={<Bold size={15} />} onClick={() => insert('**文本**')} />
|
||||
<Button type="text" className="pn-ae-tool" aria-label="斜体" title="斜体" icon={<Italic size={15} />} onClick={() => insert('*文本*')} />
|
||||
<Button type="text" className="pn-ae-tool" aria-label="插入链接" title="插入链接" icon={<Link2 size={15} />} onClick={() => insert('[标题](https://)')} />
|
||||
<Button type="text" className="pn-ae-tool" aria-label="插入代码" title="插入代码" icon={<Code size={15} />} onClick={() => insert('\n```\n代码\n```\n')} />
|
||||
<Button type="text" className="pn-ae-tool" aria-label="无序列表" title="无序列表" icon={<List size={15} />} onClick={() => insert('\n- 列表项\n')} />
|
||||
<Button type="text" className="pn-ae-tool" aria-label="引用" title="引用" icon={<Quote size={15} />} onClick={() => insert('\n> 引用\n')} />
|
||||
<Button type="text" className="pn-ae-tool" aria-label="插入表格" title="插入表格" icon={<Table size={15} />} onClick={() => insert('\n| 列1 | 列2 |\n| --- | --- |\n| a | b |\n')} />
|
||||
<span className="pn-ae-tool-hint">纯文本 · Markdown</span>
|
||||
</div>
|
||||
|
||||
<div className="pn-ae-body-wrap">
|
||||
<Suspense fallback={<div className="pn-admin-loading" style={{ minHeight: 420 }}><Spin /></div>}>
|
||||
<Editor
|
||||
value={form.content}
|
||||
onChange={(v) => set('content', v)}
|
||||
onUploadStart={(name) => setUploadMsg({ kind: 'busy', text: `上传中:${name}…` })}
|
||||
onUploadEnd={(err) => setUploadMsg(err ? { kind: 'error', text: err } : null)}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
{uploadMsg && (
|
||||
<Alert
|
||||
className="pn-ae-upload-alert"
|
||||
type={uploadMsg.kind === 'busy' ? 'info' : 'error'}
|
||||
showIcon
|
||||
message={uploadMsg.text}
|
||||
/>
|
||||
</button>
|
||||
</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.pinned}
|
||||
onClick={() => set('pinned', !form.pinned)}
|
||||
className={`relative h-5 w-9 rounded-full transition ${
|
||||
form.pinned ? 'bg-amber-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.pinned ? 'left-[1.15rem]' : 'left-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{errorMsg && <p className="text-xs text-red-600">{errorMsg}</p>}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{showPreview && (
|
||||
<div className="pn-ae-preview">
|
||||
<MarkdownViewer source={form.content} />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<aside className="pn-ae-side">
|
||||
<Card styles={{ body: { padding: 20 } }}>
|
||||
<h3 className="pn-ae-side-title">发布设置</h3>
|
||||
|
||||
<div className="pn-ae-prow">
|
||||
<span className="pn-ae-plabel">状态</span>
|
||||
<Badge
|
||||
status={form.status === 'public' ? 'success' : 'default'}
|
||||
text={form.status === 'public' ? '公开' : '私有'}
|
||||
/>
|
||||
<Switch
|
||||
checked={form.status === 'public'}
|
||||
onChange={(checked) => set('status', checked ? 'public' : 'private')}
|
||||
aria-label="公开笔记"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pn-ae-prow">
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+226
-111
@@ -1,12 +1,18 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { CirclePlus, Eye, EyeOff, Pencil, Pin, Search, Trash2 } from 'lucide-react'
|
||||
import { Badge, Button, Card, Empty, Input, Modal, Segmented, Table, Tag } from 'antd'
|
||||
import type { TableProps } from 'antd'
|
||||
import { api } from '../lib/api'
|
||||
import type { NoteItem } from '../lib/api'
|
||||
import { invalidatePublic } from '../lib/invalidate'
|
||||
import { useAuth } from '../lib/auth'
|
||||
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() {
|
||||
return useQuery({
|
||||
@@ -16,19 +22,24 @@ function useAdminNotes() {
|
||||
}
|
||||
|
||||
export default function AdminList() {
|
||||
useDocumentTitle('笔记管理')
|
||||
const { data, isLoading } = useAdminNotes()
|
||||
const { logout } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
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({
|
||||
mutationFn: (id: number) => api(`/api/admin/notes/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
setConfirmId(null)
|
||||
setConfirmNote(null)
|
||||
toast({ description: '笔记已移至回收站' })
|
||||
void qc.invalidateQueries({ queryKey: ['admin'] })
|
||||
invalidatePublic(qc)
|
||||
},
|
||||
onError: () => toast({ description: '操作失败,请重试', variant: 'error' }),
|
||||
})
|
||||
|
||||
const toggleStatus = useMutation({
|
||||
@@ -48,117 +59,221 @@ export default function AdminList() {
|
||||
}),
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
onSuccess: (_d, note) => {
|
||||
toast({ description: note.status === 'public' ? '已转为私有' : '已转为公开' })
|
||||
void qc.invalidateQueries({ queryKey: ['admin'] })
|
||||
invalidatePublic(qc)
|
||||
},
|
||||
onError: () => toast({ description: '操作失败,请重试', variant: 'error' }),
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h1 className="text-lg font-semibold">笔记管理({data?.total ?? 0})</h1>
|
||||
<div className="flex gap-2">
|
||||
const items = data?.items ?? []
|
||||
const publicCount = items.filter((n) => n.status === 'public').length
|
||||
const privateCount = items.length - publicCount
|
||||
|
||||
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
|
||||
to="/admin/notes/new"
|
||||
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"
|
||||
to={`/admin/notes/${n.id}/edit`}
|
||||
className="pn-al-note-title"
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
<Plus size={15} /> 新建
|
||||
{n.pinned && <Pin size={12} aria-label="置顶" />}
|
||||
<span>{n.title}</span>
|
||||
</Link>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
登出
|
||||
</button>
|
||||
</div>
|
||||
<span className="pn-al-note-slug">/{n.slug}</span>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '标签',
|
||||
key: 'tags',
|
||||
render: (_, n) =>
|
||||
n.tags.length > 0 ? (
|
||||
<div className="pn-al-tags">
|
||||
{n.tags.map((t) => <Tag key={t}>{t}</Tag>)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="pn-al-note-slug">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '日期',
|
||||
key: 'date',
|
||||
render: (_, n) => <span className="pn-al-date">{formatDateISO(n.updated_at)}</span>,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
render: (_, n) => (
|
||||
<Badge status={n.status === 'public' ? 'success' : 'default'} text={n.status === 'public' ? '已发布' : '私有'} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
className: 'pn-al-col-actions',
|
||||
render: (_, n) => {
|
||||
const isPublic = n.status === 'public'
|
||||
return (
|
||||
<div className="pn-al-actions-cell">
|
||||
<Button
|
||||
type="text"
|
||||
icon={isPublic ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||
title={isPublic ? '转为私有' : '转为公开'}
|
||||
aria-label={isPublic ? '转为私有' : '转为公开'}
|
||||
disabled={toggleStatus.isPending}
|
||||
onClick={() => void toggleStatus.mutate(n)}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Pencil size={15} />}
|
||||
title="编辑"
|
||||
aria-label={`编辑笔记:${n.title}`}
|
||||
onClick={() => void navigate(`/admin/notes/${n.id}/edit`)}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<Trash2 size={15} />}
|
||||
title="移至回收站"
|
||||
aria-label={`移至回收站:${n.title}`}
|
||||
onClick={() => setConfirmNote(n)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="py-12 text-center text-zinc-400">加载中…</p>
|
||||
) : !data || data.items.length === 0 ? (
|
||||
<p className="py-12 text-center text-zinc-400">还没有笔记,点击「新建」开始</p>
|
||||
) : (
|
||||
<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) => (
|
||||
<li key={n.id} className="flex items-center gap-3 px-4 py-3">
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-xs ${
|
||||
n.status === 'public'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400'
|
||||
: 'bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{n.status === 'public' ? '公开' : '私有'}
|
||||
</span>
|
||||
{n.pinned && <span className="text-xs text-amber-600">置顶</span>}
|
||||
<div className="min-w-0 flex-1">
|
||||
<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>
|
||||
<Modal
|
||||
open={confirmNote !== null}
|
||||
onCancel={() => setConfirmNote(null)}
|
||||
title="移入回收站?"
|
||||
width={380}
|
||||
centered
|
||||
maskClosable={false}
|
||||
cancelText="取消"
|
||||
okText={trashMutation.isPending ? '删除中…' : '确认删除'}
|
||||
confirmLoading={trashMutation.isPending}
|
||||
onOk={() => {
|
||||
if (confirmNote) trashMutation.mutate(confirmNote.id)
|
||||
}}
|
||||
>
|
||||
<p className="pn-modal-desc">
|
||||
「{confirmNote?.title}」将被软删除,30 天内可在回收站恢复。
|
||||
</p>
|
||||
{trashMutation.isError && <p className="pn-modal-error">操作失败,请重试</p>}
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+183
-91
@@ -1,11 +1,20 @@
|
||||
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 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() {
|
||||
useDocumentTitle('设置')
|
||||
const qc = useQueryClient()
|
||||
const { toast } = useToast()
|
||||
const { data } = useQuery({
|
||||
queryKey: ['admin', 'settings'],
|
||||
queryFn: () => api<SiteSettings>('/api/admin/settings'),
|
||||
@@ -13,14 +22,17 @@ export default function AdminSettings() {
|
||||
|
||||
const [title, setTitle] = useState('')
|
||||
const [desc, setDesc] = useState('')
|
||||
const [beian, setBeian] = useState('')
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [msg, setMsg] = useState<string | null>(null)
|
||||
const [logo, setLogo] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setTitle(data.site_title)
|
||||
setDesc(data.site_desc)
|
||||
setBeian(data.beian_no)
|
||||
setPageSize(data.page_size)
|
||||
setLogo(data.site_logo ?? '')
|
||||
}
|
||||
}, [data])
|
||||
|
||||
@@ -28,76 +40,137 @@ export default function AdminSettings() {
|
||||
mutationFn: () =>
|
||||
api('/api/admin/settings', {
|
||||
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: () => {
|
||||
setMsg('已保存')
|
||||
toast({ description: '设置已保存' })
|
||||
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 (
|
||||
<div className="max-w-lg space-y-10">
|
||||
<section>
|
||||
<h1 className="mb-4 text-lg font-semibold">站点设置</h1>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-zinc-500" htmlFor="site-title">站点标题</label>
|
||||
<input
|
||||
id="site-title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(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>
|
||||
<label className="text-xs font-medium text-zinc-500" htmlFor="site-desc">副标题 / 描述</label>
|
||||
<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>
|
||||
// antd Upload customRequest 的最小结构化类型(避免引入 rc-upload 内部类型路径)
|
||||
const uploadLogo = async ({ file, onSuccess, onError }: {
|
||||
file: unknown
|
||||
onSuccess?: (ret?: unknown, xhr?: XMLHttpRequest) => void
|
||||
onError?: (event: Error, body?: unknown) => void
|
||||
}) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file as File)
|
||||
try {
|
||||
const res = await api<{ url: string }>('/api/admin/images', { method: 'POST', body: fd })
|
||||
setLogo(res.url)
|
||||
onSuccess?.(res)
|
||||
toast({ description: 'Logo 已上传,记得保存更改' })
|
||||
} catch (e) {
|
||||
onError?.(e instanceof Error ? e : new Error('上传失败'))
|
||||
toast({ description: e instanceof ApiError ? e.message : '上传失败', variant: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
<ChangePassword />
|
||||
</div>
|
||||
return (
|
||||
<>
|
||||
<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 [newPw, setNewPw] = useState('')
|
||||
const [msg, setMsg] = useState<string | null>(null)
|
||||
const [ok, setOk] = useState(false)
|
||||
const [showPw, setShowPw] = useState(false)
|
||||
|
||||
const change = useMutation({
|
||||
mutationFn: () =>
|
||||
@@ -106,57 +179,76 @@ function ChangePassword() {
|
||||
body: JSON.stringify({ old_password: oldPw, new_password: newPw }),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setOk(true)
|
||||
setMsg('密码已修改')
|
||||
onToast({ description: '密码已修改' })
|
||||
setOldPw('')
|
||||
setNewPw('')
|
||||
},
|
||||
onError: (e) => {
|
||||
setOk(false)
|
||||
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 {
|
||||
setMsg('修改失败')
|
||||
onToast({ description: '修改失败', variant: 'error' })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2 className="mb-4 text-lg font-semibold">修改密码</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-zinc-500" htmlFor="old-pw">旧密码</label>
|
||||
<input
|
||||
<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="old-pw" className="pn-field-label">旧密码</label>
|
||||
<Input
|
||||
id="old-pw"
|
||||
type="password"
|
||||
type={showPw ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
prefix={<Lock size={15} className="pn-input-icon" aria-hidden />}
|
||||
value={oldPw}
|
||||
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>
|
||||
<label className="text-xs font-medium text-zinc-500" htmlFor="new-pw">新密码(≥12 字符)</label>
|
||||
<input
|
||||
<div className="pn-as-field">
|
||||
<label htmlFor="new-pw" className="pn-field-label">新密码(≥12 字符)</label>
|
||||
<Input
|
||||
id="new-pw"
|
||||
type="password"
|
||||
value={newPw}
|
||||
onChange={(e) => setNewPw(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"
|
||||
type={showPw ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
placeholder="请输入新密码,至少 12 个字符"
|
||||
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>
|
||||
<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>}
|
||||
<p className="pn-as-hint">新密码长度至少 12 个字符,建议混合字母、数字与符号。</p>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
+123
-37
@@ -1,12 +1,20 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { RotateCcw } from 'lucide-react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
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 type { NoteItem } from '../lib/api'
|
||||
import { invalidatePublic } from '../lib/invalidate'
|
||||
import { formatDateTime } from '../lib/utils'
|
||||
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() {
|
||||
useDocumentTitle('回收站')
|
||||
const qc = useQueryClient()
|
||||
const { toast } = useToast()
|
||||
const { modal } = App.useApp()
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'trash'],
|
||||
queryFn: () => api<{ items: NoteItem[]; total: number }>('/api/admin/trash'),
|
||||
@@ -15,45 +23,123 @@ export default function AdminTrash() {
|
||||
const restore = useMutation({
|
||||
mutationFn: (id: number) => api(`/api/admin/trash/${id}/restore`, { method: 'POST' }),
|
||||
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 (
|
||||
<div>
|
||||
<h1 className="mb-4 text-lg font-semibold">回收站({data?.total ?? 0})</h1>
|
||||
<p className="mb-4 text-sm text-zinc-400">软删除的笔记保留 30 天,到期由每日 gc 物理清除;恢复后图片引用天然保全。</p>
|
||||
{isLoading ? (
|
||||
<p className="py-12 text-center text-zinc-400">加载中…</p>
|
||||
) : !data || data.items.length === 0 ? (
|
||||
<p className="py-12 text-center text-zinc-400">回收站是空的</p>
|
||||
) : (
|
||||
<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) => (
|
||||
<li key={n.id} className="flex items-center gap-3 px-4 py-3">
|
||||
<span className="rounded bg-zinc-100 px-1.5 py-0.5 text-xs text-zinc-500 dark:bg-zinc-800">
|
||||
{n.status === 'public' ? '公开' : '私有'}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">{n.title}</p>
|
||||
<p className="truncate text-xs text-zinc-400">
|
||||
/{n.slug} · 删除于 {formatDateTime(n.deleted_at ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => restore.mutate(n.id)}
|
||||
disabled={restore.isPending}
|
||||
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"
|
||||
>
|
||||
<RotateCcw size={13} /> 恢复
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{restore.isError && <p className="mt-3 text-sm text-red-600">恢复失败,请重试</p>}
|
||||
</div>
|
||||
<>
|
||||
<AdminTopbar title="回收站">
|
||||
<Button
|
||||
danger
|
||||
icon={<Trash2 size={15} />}
|
||||
disabled={isLoading || items.length === 0 || clear.isPending}
|
||||
onClick={confirmEmpty}
|
||||
>
|
||||
清空回收站
|
||||
</Button>
|
||||
</AdminTopbar>
|
||||
|
||||
<div className="pn-admin-content">
|
||||
<Alert
|
||||
role="note"
|
||||
type="info"
|
||||
showIcon
|
||||
message="回收站中的笔记会在删除 30 天后永久删除,请及时恢复需要的内容。"
|
||||
/>
|
||||
|
||||
<Card className="pn-al-card min-w-0 flex-1 overflow-hidden" styles={{ body: { padding: 0 } }}>
|
||||
<Table
|
||||
className="pn-al-table"
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
pagination={false}
|
||||
scroll={{ x: 560 }}
|
||||
locale={{
|
||||
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="回收站是空的" />,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="pn-al-table-foot">
|
||||
<span className="pn-al-foot-note">共 {data?.total ?? 0} 篇已删除笔记</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+65
-93
@@ -1,127 +1,99 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router'
|
||||
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 type { NoteItem, NoteList, TagCount } from '../lib/api'
|
||||
import { formatDate } from '../lib/utils'
|
||||
import type { NoteItem, NoteList } from '../lib/api'
|
||||
import { useDocumentTitle } from '../features/site'
|
||||
import { formatDateISO } from '../lib/utils'
|
||||
|
||||
function useNotes(page: number, tag?: string) {
|
||||
function useNotes(page: number) {
|
||||
// 不传 page_size:服务端默认值即站点设置(管理员可改),首页随设置生效
|
||||
const qs = new URLSearchParams({ page: String(page) })
|
||||
if (tag) qs.set('tag', tag)
|
||||
return useQuery({
|
||||
queryKey: ['notes', page, tag ?? ''],
|
||||
queryFn: () => api<NoteList>(`/api/notes?${qs}`),
|
||||
queryKey: ['notes', page],
|
||||
queryFn: () => api<NoteList>(`/api/notes?page=${page}`),
|
||||
})
|
||||
}
|
||||
|
||||
function useTags() {
|
||||
return useQuery({
|
||||
queryKey: ['tags'],
|
||||
queryFn: () => api<{ tags: TagCount[] }>('/api/tags'),
|
||||
})
|
||||
}
|
||||
|
||||
function NoteCard({ note }: { note: NoteItem }) {
|
||||
function NotePost({ note }: { note: NoteItem }) {
|
||||
return (
|
||||
<article className="group border-b border-zinc-100 py-6 first:pt-0 dark:border-zinc-900">
|
||||
<div className="flex items-center gap-2">
|
||||
{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">
|
||||
<article className="pn-home-post">
|
||||
{note.cover && (
|
||||
<Link
|
||||
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>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [page, setPage] = useState(1)
|
||||
useDocumentTitle()
|
||||
const { data, isLoading } = useNotes(page)
|
||||
const { data: tagsData } = useTags()
|
||||
const totalPages = data ? Math.max(1, Math.ceil(data.total / data.page_size)) : 1
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 lg:flex-row-reverse">
|
||||
<aside className="lg:w-48 lg:shrink-0">
|
||||
{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">
|
||||
<div className="pn-home-content">
|
||||
<section aria-label="笔记列表">
|
||||
{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 ? (
|
||||
<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} />)
|
||||
)}
|
||||
{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>
|
||||
data.items.map((n) => <NotePost key={n.slug} note={n} />)
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
+81
-33
@@ -1,13 +1,17 @@
|
||||
import { useState } from 'react'
|
||||
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 { ApiError } from '../lib/api'
|
||||
import { useDocumentTitle } from '../features/site'
|
||||
|
||||
export default function Login() {
|
||||
useDocumentTitle('管理员登录')
|
||||
const { login, authenticated } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPw, setShowPw] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
@@ -24,7 +28,9 @@ export default function Login() {
|
||||
navigate('/admin', { replace: true })
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.status === 429 ? '尝试过于频繁,请稍后再试' : '密码错误')
|
||||
if (err.status === 429) setError('尝试过于频繁,请稍后再试')
|
||||
else if (err.status === 403) setError('请求被安全策略拦截,请刷新页面后重试')
|
||||
else setError('密码错误,请重试')
|
||||
} else {
|
||||
setError('登录失败,请重试')
|
||||
}
|
||||
@@ -34,38 +40,80 @@ export default function Login() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center px-4">
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="w-full max-w-sm rounded-xl border border-zinc-200 p-8 dark:border-zinc-800"
|
||||
<div className="pn-login-wrap">
|
||||
<div className="pn-login-brand">
|
||||
<span className="pn-login-eyebrow">ADMIN</span>
|
||||
<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} />
|
||||
<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>
|
||||
返回博客
|
||||
</Button>
|
||||
</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() {
|
||||
useDocumentTitle('页面不存在')
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<div className="flex min-h-[60vh] flex-col items-center justify-center">
|
||||
<p className="text-6xl font-bold text-zinc-200 dark:text-zinc-800">404</p>
|
||||
<p className="mt-4 text-zinc-500">页面不存在</p>
|
||||
<Link to="/" className="mt-6 text-sm text-blue-600 hover:underline">
|
||||
← 返回首页
|
||||
</Link>
|
||||
<div className="pn-404-page">
|
||||
<p className="pn-404-code">404</p>
|
||||
<h1 className="pn-404-title">页面不存在</h1>
|
||||
<p className="pn-404-desc">这篇笔记可能已被删除,或链接有误。</p>
|
||||
<div className="pn-404-actions">
|
||||
<Button type="primary" size="small" icon={<ArrowLeft size={16} />} onClick={() => navigate('/')}>
|
||||
返回首页
|
||||
</Button>
|
||||
</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 { 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 type { NoteDetail } from '../lib/api'
|
||||
import { useAuth } from '../lib/auth'
|
||||
import { useDocumentTitle } from '../features/site'
|
||||
import MarkdownViewer from '../components/MarkdownViewer'
|
||||
import { formatDate } from '../lib/utils'
|
||||
import { formatDateISO, readingMinutes } from '../lib/utils'
|
||||
|
||||
export default function Note() {
|
||||
const { slug = '' } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { authenticated } = useAuth()
|
||||
const { data: note, isLoading, error } = useQuery({
|
||||
queryKey: ['note', slug],
|
||||
queryFn: () => api<NoteDetail>(`/api/notes/${encodeURIComponent(slug)}`),
|
||||
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) {
|
||||
return (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-4xl font-bold text-zinc-300 dark:text-zinc-700">404</p>
|
||||
<p className="mt-2 text-zinc-500">笔记不存在或未公开</p>
|
||||
<Link to="/" className="mt-4 inline-block text-sm text-blue-600 hover:underline">
|
||||
← 返回首页
|
||||
</Link>
|
||||
</div>
|
||||
<article className="pn-note-article">
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="笔记不存在或未公开">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className="pn-note-back"
|
||||
icon={<ArrowLeft size={14} />}
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
返回首页
|
||||
</Button>
|
||||
</Empty>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
const privatePreview = note.status === 'private'
|
||||
|
||||
return (
|
||||
<article>
|
||||
<nav className="mb-4 text-xs text-zinc-400">
|
||||
<Link to="/" className="hover:text-zinc-600">首页</Link>
|
||||
<span className="mx-1.5">/</span>
|
||||
<span>{note.title}</span>
|
||||
</nav>
|
||||
<article className="pn-note-article">
|
||||
<Button type="text" size="small" className="pn-note-back" icon={<ArrowLeft size={14} />} onClick={() => navigate('/')}>
|
||||
返回首页
|
||||
</Button>
|
||||
|
||||
{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">
|
||||
<AlertTriangle size={16} />
|
||||
私有预览:此笔记未公开,仅管理员可见
|
||||
</div>
|
||||
<Alert
|
||||
role="note"
|
||||
type="info"
|
||||
showIcon={false}
|
||||
className="pn-note-preview-alert"
|
||||
message={
|
||||
<>
|
||||
<Eye size={15} />
|
||||
私有预览:此笔记未公开,仅管理员可见
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<header className="mb-8">
|
||||
<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">
|
||||
<time>{formatDate(note.created_at)}</time>
|
||||
{note.tags.map((t) => (
|
||||
<Link
|
||||
key={t}
|
||||
to={`/tags/${encodeURIComponent(t)}`}
|
||||
className="rounded-full bg-zinc-100 px-2 py-0.5 hover:bg-zinc-200 dark:bg-zinc-800 dark:hover:bg-zinc-700"
|
||||
>
|
||||
#{t}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
<h1 className="pn-note-title">{note.title}</h1>
|
||||
|
||||
<div className="pn-note-meta">
|
||||
<time className="pn-note-meta-date" dateTime={formatDateISO(note.published_at)}>
|
||||
{formatDateISO(note.published_at)}
|
||||
</time>
|
||||
<span className="pn-note-meta-sep">·</span>
|
||||
<span className="pn-note-meta-read"><Clock size={14} />约 {readingMinutes(note.content)} 分钟阅读</span>
|
||||
{note.tags.length > 0 && (
|
||||
<>
|
||||
<span className="pn-note-meta-sep">·</span>
|
||||
{note.tags.map((t) => (
|
||||
<Tag key={t}>
|
||||
<Link className="pn-tag-link" to={`/tags/${encodeURIComponent(t)}`}>{t}</Link>
|
||||
</Tag>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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 ? (
|
||||
<Link
|
||||
to={`/notes/${encodeURIComponent(note.prev.slug)}`}
|
||||
className="text-zinc-600 hover:text-blue-600 dark:text-zinc-400"
|
||||
>
|
||||
← {note.prev.title}
|
||||
<Link to={`/notes/${encodeURIComponent(note.prev.slug)}`} className="pn-note-nav-link">
|
||||
<Card className="pn-note-nav-card" styles={{ body: { padding: 0 } }}>
|
||||
<span className="pn-note-nav-label"><ArrowLeft size={14} />上一篇</span>
|
||||
<span className="pn-note-nav-title">{note.prev.title}</span>
|
||||
</Card>
|
||||
</Link>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{note.next ? (
|
||||
<Link
|
||||
to={`/notes/${encodeURIComponent(note.next.slug)}`}
|
||||
className="text-right text-zinc-600 hover:text-blue-600 dark:text-zinc-400"
|
||||
>
|
||||
{note.next.title} →
|
||||
<Link to={`/notes/${encodeURIComponent(note.next.slug)}`} className="pn-note-nav-link">
|
||||
<Card className="pn-note-nav-card pn-note-nav-next" styles={{ body: { padding: 0 } }}>
|
||||
<span className="pn-note-nav-label">下一篇<ArrowRight size={14} /></span>
|
||||
<span className="pn-note-nav-title">{note.next.title}</span>
|
||||
</Card>
|
||||
</Link>
|
||||
) : (
|
||||
<span />
|
||||
|
||||
+82
-36
@@ -1,47 +1,93 @@
|
||||
import { Link, useParams } from 'react-router'
|
||||
import { Link, useNavigate, useParams } from 'react-router'
|
||||
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 type { NoteList } from '../lib/api'
|
||||
import { formatDate } from '../lib/utils'
|
||||
import type { NoteList, TagCount } from '../lib/api'
|
||||
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() {
|
||||
const { tag = '' } = useParams()
|
||||
const { tag } = useParams()
|
||||
const navigate = useNavigate()
|
||||
useDocumentTitle(tag ? `标签:${tag}` : '标签')
|
||||
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`),
|
||||
queryKey: ['tag', tag ?? ''],
|
||||
queryFn: () =>
|
||||
api<NoteList>(`/api/notes?tag=${encodeURIComponent(tag ?? '')}&page=1&page_size=100`),
|
||||
enabled: tag !== undefined,
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<header className="mb-6">
|
||||
<nav className="mb-2 text-xs text-zinc-400">
|
||||
<Link to="/" className="hover:text-zinc-600">首页</Link>
|
||||
<span className="mx-1.5">/</span>
|
||||
<span>标签</span>
|
||||
</nav>
|
||||
<h1 className="text-2xl font-bold">#{tag}</h1>
|
||||
<p className="mt-1 text-sm text-zinc-400">{data?.total ?? 0} 篇</p>
|
||||
</header>
|
||||
{isLoading ? (
|
||||
<p className="py-12 text-center text-zinc-400">加载中…</p>
|
||||
) : error ? (
|
||||
<p className="py-12 text-center text-red-500">加载失败,请稍后重试</p>
|
||||
) : !data || data.items.length === 0 ? (
|
||||
<p className="py-12 text-center text-zinc-400">该标签下暂无公开笔记</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-zinc-100 dark:divide-zinc-900">
|
||||
{data.items.map((n) => (
|
||||
<li key={n.slug} className="py-4">
|
||||
<Link
|
||||
to={`/notes/${encodeURIComponent(n.slug)}`}
|
||||
className="font-medium hover:text-blue-600 dark:hover:text-blue-400"
|
||||
>
|
||||
{n.title}
|
||||
</Link>
|
||||
<time className="ml-3 text-xs text-zinc-400">{formatDate(n.updated_at)}</time>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="pn-tag-content">
|
||||
<section className="pn-tag-pagehead">
|
||||
<h1>标签</h1>
|
||||
<p>按主题浏览所有公开笔记</p>
|
||||
</section>
|
||||
|
||||
{/* 单选 CheckableTagGroup:选中态由组件承接;再次点击已选标签 = 取消(回全部) */}
|
||||
<AntTag.CheckableTagGroup
|
||||
multiple={false}
|
||||
className="pn-tag-cloud"
|
||||
value={tag ?? null}
|
||||
onChange={(v) => navigate(v === null ? '/tags' : `/tags/${encodeURIComponent(v)}`)}
|
||||
options={(tagsData?.tags ?? []).map((t) => ({
|
||||
value: t.name,
|
||||
className: 'pn-cloud-tag',
|
||||
label: (
|
||||
<>
|
||||
<Tag size={14} />
|
||||
<span>{t.name}</span>
|
||||
<span className="pn-cloud-count">{t.count}</span>
|
||||
</>
|
||||
),
|
||||
}))}
|
||||
aria-label="全部标签"
|
||||
/>
|
||||
{!tagsData && (
|
||||
<div className="pn-tag-cloud"><Spin size="small" /></div>
|
||||
)}
|
||||
{tagsData && tagsData.tags.length === 0 && (
|
||||
<div className="pn-tag-empty">
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="还没有标签" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tag === undefined && tagsData && (
|
||||
<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>
|
||||
)
|
||||
|
||||
@@ -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,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,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"types": ["vite/client"]
|
||||
"types": ["vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "tests"]
|
||||
}
|
||||
|
||||
+16
-13
@@ -1,3 +1,4 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
@@ -8,26 +9,28 @@ export default defineConfig({
|
||||
// /notes/assets/… 导致白屏(设计 §8.3-2,禁止修改)
|
||||
base: '/',
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
// shadcn 组件别名的运行时解析(components.json aliases)
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
// 同源是 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: {
|
||||
// 产物内容 hash 文件名(§8.3-5)
|
||||
assetsDir: 'assets',
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
// CodeMirror 体积过大,手动拆分避免单 chunk 超 500kB:
|
||||
// @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)/ },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
// 不做手动 codeSplitting 分组:分组 chunk 会被提升进入口静态依赖,
|
||||
// HTML 对其发出 modulepreload,导致 lazy() 路由的 CodeMirror 在
|
||||
// 所有页面被预加载(Lighthouse unused-javascript 549KiB 的根因)。
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
// 与 vite.config 一致:shadcn 组件的 @/ 别名
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
include: ['tests/**/*.test.?(c|m)[jt]s?(x)'],
|
||||
|
||||
Reference in New Issue
Block a user