Compare commits
11
Commits
87d9bab5dc
...
v0.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1ea89ba3d | ||
|
|
9b0f2785cf | ||
|
|
ccc858bfdd | ||
|
|
2be5d4d25b | ||
|
|
95cac4688d | ||
|
|
ad4a87fc9f | ||
|
|
92d89717c5 | ||
|
|
969d537aee | ||
|
|
614bfe2852 | ||
|
|
5bac272f6d | ||
|
|
5457aeaa33 |
@@ -0,0 +1,109 @@
|
|||||||
|
# 发版工作流:推送 v* 标签 → 构建前端并嵌入 → 交叉编译多平台二进制
|
||||||
|
# → 生成发布说明 → 通过 Gitea API 创建 Release 并上传产物。
|
||||||
|
#
|
||||||
|
# 发版操作:
|
||||||
|
# git tag -a v0.0.1 -m "v0.0.1"
|
||||||
|
# git push origin v0.0.1
|
||||||
|
#
|
||||||
|
# 实现说明:刻意不使用任何 JS 类 action(checkout/setup-*/release-*)——
|
||||||
|
# 自托管 runner 常把实例地址注册成 localhost,job 容器内不可达,
|
||||||
|
# 且默认容器镜像可能没有 node。此处全程纯 run 步骤 + 公网域名访问实例,
|
||||||
|
# 容器固定 node:24-bookworm(自带 node 与 git,npm 构建前端所需)。
|
||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
SITE: https://git.airnan.cn
|
||||||
|
REPO: wangairnan/pure-note
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: node:24-bookworm
|
||||||
|
env:
|
||||||
|
# 这台 runner 只保证 GITHUB_* 兼容层(GITEA_REF_NAME 等别名不可靠),
|
||||||
|
# 一律经表达式显式注入,不直接读 GITEA_* 环境变量
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN || secrets.GITEA_TOKEN || secrets.FORGEJO_TOKEN }}
|
||||||
|
steps:
|
||||||
|
# 完整历史:供 git log 生成上一个 tag 以来的变更日志
|
||||||
|
- name: 检出代码
|
||||||
|
run: |
|
||||||
|
git clone "$SITE/$REPO.git" .
|
||||||
|
git checkout --detach "$TAG"
|
||||||
|
git log --oneline -1
|
||||||
|
|
||||||
|
- name: 安装 Go(版本取自 go.mod)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
GOVER=$(awk '$1 == "go" {print $2}' go.mod)
|
||||||
|
echo "==> 安装 Go $GOVER"
|
||||||
|
# go.dev 在部分网络不可达,回退官方中国镜像
|
||||||
|
curl -fsSL "https://go.dev/dl/go${GOVER}.linux-amd64.tar.gz" -o /tmp/go.tgz \
|
||||||
|
|| curl -fsSL "https://golang.google.cn/dl/go${GOVER}.linux-amd64.tar.gz" -o /tmp/go.tgz
|
||||||
|
tar -C /usr/local -xzf /tmp/go.tgz
|
||||||
|
/usr/local/go/bin/go version
|
||||||
|
|
||||||
|
- name: 构建前端并嵌入(web/dist → internal/webui/dist)
|
||||||
|
run: make web sync-assets || (npm config set registry https://registry.npmmirror.com && make web sync-assets)
|
||||||
|
|
||||||
|
- name: 交叉编译发布产物
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
export PATH=/usr/local/go/bin:$PATH
|
||||||
|
# 模块代理双层回退:官方代理不可达时走 goproxy.cn
|
||||||
|
export GOPROXY=https://proxy.golang.org,https://goproxy.cn,direct
|
||||||
|
scripts/build-release.sh "$TAG"
|
||||||
|
|
||||||
|
# 首个 tag 无上一版本,变更日志段自动省略
|
||||||
|
- name: 生成发布说明
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
prev=$(git describe --tags --abbrev=0 "HEAD^" 2>/dev/null || true)
|
||||||
|
short=$(git rev-parse --short HEAD)
|
||||||
|
{
|
||||||
|
echo "## pn ${TAG}"
|
||||||
|
echo
|
||||||
|
echo "- commit: \`$short\`"
|
||||||
|
echo "- 构建时间: $(date -u '+%Y-%m-%dT%H:%M:%SZ')"
|
||||||
|
if [ -n "$prev" ]; then
|
||||||
|
echo
|
||||||
|
echo "### 自 ${prev} 以来的变更"
|
||||||
|
echo
|
||||||
|
git log --oneline --no-decorate "${prev}..HEAD" | sed 's/^/- /'
|
||||||
|
fi
|
||||||
|
} > release-notes.md
|
||||||
|
cat release-notes.md
|
||||||
|
|
||||||
|
# 重跑幂等:release 已存在时更新说明、清旧附件后重传
|
||||||
|
- name: 创建 Release 并上传产物
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
API="$SITE/api/v1/repos/$REPO"
|
||||||
|
AUTH="Authorization: token ${TOKEN}"
|
||||||
|
REQ=$(node -e 'const fs=require("fs");process.stdout.write(JSON.stringify({tag_name:process.env.TAG,name:"pn "+process.env.TAG,body:fs.readFileSync("release-notes.md","utf8")}))')
|
||||||
|
# 同 tag release 已存在(如失败重跑)时回退为查询
|
||||||
|
REL=$(curl -fsS -X POST "$API/releases" -H "$AUTH" -H 'Content-Type: application/json' -d "$REQ") \
|
||||||
|
|| REL=$(curl -fsS "$API/releases/tags/${TAG}" -H "$AUTH")
|
||||||
|
RID=$(REL="$REL" node -e 'process.stdout.write(String(JSON.parse(process.env.REL).id))')
|
||||||
|
echo "==> release id=$RID"
|
||||||
|
# 发布说明以本次构建为准(回退路径下 POST 已冲突,须补写)
|
||||||
|
curl -fsS -X PATCH "$API/releases/$RID" -H "$AUTH" -H 'Content-Type: application/json' -d "$REQ" >/dev/null
|
||||||
|
# 清除旧附件,避免重跑产生同名副本
|
||||||
|
ASSETS=$(curl -fsS "$API/releases/$RID/assets" -H "$AUTH")
|
||||||
|
ASSETS="$ASSETS" node -e 'JSON.parse(process.env.ASSETS).forEach(a=>console.log(a.id))' | while read -r aid; do
|
||||||
|
curl -fsS -X DELETE "$API/releases/$RID/assets/$aid" -H "$AUTH" >/dev/null
|
||||||
|
done
|
||||||
|
for f in dist/*; do
|
||||||
|
echo "==> 上传 $(basename "$f")"
|
||||||
|
curl -fsS -X POST "$API/releases/$RID/assets?name=$(basename "$f")" -H "$AUTH" -F "attachment=@$f" >/dev/null
|
||||||
|
done
|
||||||
|
echo "==> 完成:$SITE/$REPO/releases/tag/${TAG}"
|
||||||
@@ -13,5 +13,8 @@ internal/webui/dist/*
|
|||||||
/pn
|
/pn
|
||||||
/pn-linux-*
|
/pn-linux-*
|
||||||
|
|
||||||
|
# 发布产物(make dist / CI 发版构建输出)
|
||||||
|
/dist/
|
||||||
|
|
||||||
# 系统文件
|
# 系统文件
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -5,17 +5,21 @@
|
|||||||
# make sync-assets 拷贝 web/dist → internal/webui/dist(go:embed 约束,§8.3-1)
|
# make sync-assets 拷贝 web/dist → internal/webui/dist(go:embed 约束,§8.3-1)
|
||||||
# make build 全量构建(前端 + 单二进制 pn)
|
# make build 全量构建(前端 + 单二进制 pn)
|
||||||
# make linux 交叉编译 linux/amd64
|
# make linux 交叉编译 linux/amd64
|
||||||
|
# make dist 全平台发布产物 → dist/(VERSION ?= git describe,与 CI 发版共用脚本)
|
||||||
# make test Go 测试 + 前端测试
|
# make test Go 测试 + 前端测试
|
||||||
# make smoke 真实构建冒烟测试(§8.3-6)
|
# make smoke 真实构建冒烟测试(§8.3-6)
|
||||||
# make dev 双进程热重载(air + vite dev)
|
# make dev 双进程热重载(air + vite dev)
|
||||||
# make vulncheck govulncheck
|
# make vulncheck govulncheck
|
||||||
|
#
|
||||||
|
# 发版:git tag -a v0.0.1 -m "v0.0.1" && git push origin v0.0.1
|
||||||
|
# (推送 v* 标签触发 .gitea/workflows/release.yml 自动构建并创建 Release)
|
||||||
|
|
||||||
VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev)
|
VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev)
|
||||||
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none)
|
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none)
|
||||||
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildDate=$(DATE)
|
LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildDate=$(DATE)
|
||||||
|
|
||||||
.PHONY: web sync-assets build linux test smoke dev vulncheck clean
|
.PHONY: web sync-assets build linux dist test smoke dev vulncheck clean
|
||||||
|
|
||||||
web:
|
web:
|
||||||
cd web && npm ci && npm run build
|
cd web && npm ci && npm run build
|
||||||
@@ -33,6 +37,10 @@ linux: web sync-assets
|
|||||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="$(LDFLAGS)" \
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="$(LDFLAGS)" \
|
||||||
-o pn-linux-amd64 ./cmd/pn
|
-o pn-linux-amd64 ./cmd/pn
|
||||||
|
|
||||||
|
# 全平台发布产物(版本注入逻辑与 CI 发版共用 scripts/build-release.sh)
|
||||||
|
dist: web sync-assets
|
||||||
|
scripts/build-release.sh "$(VERSION)"
|
||||||
|
|
||||||
test:
|
test:
|
||||||
go vet ./...
|
go vet ./...
|
||||||
go test ./...
|
go test ./...
|
||||||
@@ -51,3 +59,4 @@ vulncheck:
|
|||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f pn pn-linux-amd64
|
rm -f pn pn-linux-amd64
|
||||||
|
rm -rf dist
|
||||||
|
|||||||
@@ -7,12 +7,12 @@
|
|||||||
|
|
||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
- **笔记**:Markdown CRUD、粘贴/拖拽图片上传(≤5MB、魔数校验、BLOB 入库去重)、公开/私有两态、置顶、标签
|
- **笔记**:Markdown CRUD、粘贴/拖拽图片上传(≤5MB、魔数校验、BLOB 入库去重)、公开/私有两态、置顶、标签、发布日期可自选
|
||||||
- **博客**:首页(置顶优先 + 分页 + 标签云)、详情(上一篇/下一篇)、标签页、RSS、sitemap、SEO meta 注入(仅公开内容)
|
- **博客**:首页(置顶优先 + 分页 + 标签云)、详情(上一篇/下一篇)、标签页、RSS、sitemap、SEO meta 注入(仅公开内容);站点名称前可显示自定义 Logo
|
||||||
- **管理**:单管理员口令登录(Argon2id)、会话 7 天滑动续期(CSRF 轮换保持不变)、防爆破限流(per-IP + per-账号)、改密
|
- **管理**:单管理员口令登录(Argon2id)、会话 7 天滑动续期(CSRF 轮换保持不变)、防爆破限流(per-IP + per-账号)、改密
|
||||||
- **回收站**:删除 = 软删除,30 天内可恢复,`gc` 到期物理清除
|
- **回收站**:删除 = 软删除,30 天内可恢复,可一键清空,`gc` 到期物理清除
|
||||||
- **界面**:9 页面响应式 SPA(React 19 + Tailwind 4),深色/浅色/跟随系统三态主题
|
- **界面**:9 页面响应式 SPA(React 19 + Tailwind 4 + Ant Design 6),深色/浅色/跟随系统三态主题
|
||||||
- **运维**:`init` / `passwd`(重设口令并吊销全部会话)/ `start` / `backup`(在线一致快照)/ `gc`(默认 dry-run)/ `version`(亦可 `-v`)
|
- **运维**:`init`(含 Markdown 示例文档与示例图片种子)/ `passwd`(重设口令并吊销全部会话)/ `start` / `backup`(在线一致快照)/ `gc`(默认 dry-run)/ `version`(亦可 `-v`)
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
# 1. 构建单二进制(前端 + 后端)
|
# 1. 构建单二进制(前端 + 后端)
|
||||||
make build
|
make build
|
||||||
|
|
||||||
# 2. 初始化(设置管理员口令与站点标题)
|
# 2. 初始化(设置管理员口令与站点标题,自动写入一篇 Markdown 示例文档)
|
||||||
./pn init
|
./pn init
|
||||||
# 非交互:PN_ADMIN_PASSWORD=xxx PN_SITE_TITLE=yyy ./pn init
|
# 非交互:PN_ADMIN_PASSWORD=xxx PN_SITE_TITLE=yyy ./pn init
|
||||||
|
|
||||||
@@ -78,6 +78,22 @@ sudo systemctl enable --now pn pn-maint.timer
|
|||||||
|
|
||||||
**恢复 SOP**:停服 → 删除 `data/pn.db-wal` 与 `-shm` → 以备份文件替换 `pn.db` → 起服。
|
**恢复 SOP**:停服 → 删除 `data/pn.db-wal` 与 `-shm` → 以备份文件替换 `pn.db` → 起服。
|
||||||
|
|
||||||
|
## 版本与发版
|
||||||
|
|
||||||
|
版本号以 git tag 为唯一来源(语义化版本,从 `v0.0.1` 开始),构建时经 ldflags 注入,
|
||||||
|
`./pn version` 可查看:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag -a v0.0.1 -m "v0.0.1"
|
||||||
|
git push origin v0.0.1 # 推送 v* 标签即触发自动发版
|
||||||
|
```
|
||||||
|
|
||||||
|
推送 `v*` 标签后,Gitea Actions([.gitea/workflows/release.yml](.gitea/workflows/release.yml))
|
||||||
|
自动构建前端并嵌入,交叉编译 linux/darwin(amd64 + arm64)共 4 个产物,
|
||||||
|
连同 SHA256SUMS 与上一版本以来的变更日志一起发布到仓库的 Release 页面。
|
||||||
|
|
||||||
|
本地验证发布产物:`make dist`(版本缺省 `git describe --tags --always`,输出到 `dist/`)。
|
||||||
|
|
||||||
## 架构
|
## 架构
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -226,6 +226,10 @@ func runInit(args []string) error {
|
|||||||
if err := st.SetSetting(store.KeySiteTitle, title); err != nil {
|
if err := st.SetSetting(store.KeySiteTitle, title); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// 种子内容:一篇 Markdown 语法示例文档(公开)+ 自动生成的示例图片
|
||||||
|
if _, err := seedWelcome(st); err != nil {
|
||||||
|
return fmt.Errorf("写入初始文档失败: %w", err)
|
||||||
|
}
|
||||||
fmt.Println("初始化完成。现在可以启动:pn start --data-dir", cfg.DataDir)
|
fmt.Println("初始化完成。现在可以启动:pn start --data-dir", cfg.DataDir)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+183
@@ -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[:])
|
||||||
|
}
|
||||||
+10
-8
@@ -58,8 +58,8 @@
|
|||||||
| F3 | 图片 | 编辑器内粘贴/拖拽上传(≤5MB);Markdown 中引用;图片可见性 = 引用它的笔记可见性的并集;去重存储 |
|
| F3 | 图片 | 编辑器内粘贴/拖拽上传(≤5MB);Markdown 中引用;图片可见性 = 引用它的笔记可见性的并集;去重存储 |
|
||||||
| F4 | 博客 | 匿名访问:首页(公开列表+分页+标签)、详情页、标签页;RSS Feed;sitemap.xml;SEO 基础(仅公开笔记注入 title/description/og meta) |
|
| F4 | 博客 | 匿名访问:首页(公开列表+分页+标签)、详情页、标签页;RSS Feed;sitemap.xml;SEO 基础(仅公开笔记注入 title/description/og meta) |
|
||||||
| F5 | 认证 | 单管理员密码登录/登出/**改密**;会话失效/续期;登录与改密防爆破 |
|
| F5 | 认证 | 单管理员密码登录/登出/**改密**;会话失效/续期;登录与改密防爆破 |
|
||||||
| F6 | 站点设置 | 站点标题、副标题、每页条数(白名单字段,绝不含口令哈希) |
|
| F6 | 站点设置 | 站点标题、副标题、每页条数(10/20/30/50)、站点 Logo(白名单字段,绝不含口令哈希) |
|
||||||
| F7 | 回收站 | 删除 = 软删除进入回收站;30 天内可恢复;`gc` 到期物理清除 |
|
| F7 | 回收站 | 删除 = 软删除进入回收站;30 天内可恢复;可一键清空;`gc` 到期物理清除 |
|
||||||
| F8 | 运维 | 单二进制启动;`--data-dir` 指定数据目录;在线备份子命令;`gc` 子命令(默认 dry-run);版本号 |
|
| F8 | 运维 | 单二进制启动;`--data-dir` 指定数据目录;在线备份子命令;`gc` 子命令(默认 dry-run);版本号 |
|
||||||
|
|
||||||
### 2.2 非功能需求
|
### 2.2 非功能需求
|
||||||
@@ -232,7 +232,8 @@ CREATE TABLE IF NOT EXISTS notes (
|
|||||||
pinned INTEGER NOT NULL DEFAULT 0,
|
pinned INTEGER NOT NULL DEFAULT 0,
|
||||||
deleted_at INTEGER, -- NULL=正常;非空=回收站(软删除)
|
deleted_at INTEGER, -- NULL=正常;非空=回收站(软删除)
|
||||||
created_at INTEGER NOT NULL, -- Unix 秒
|
created_at INTEGER NOT NULL, -- Unix 秒
|
||||||
updated_at INTEGER NOT NULL
|
updated_at INTEGER NOT NULL,
|
||||||
|
published_at INTEGER -- 发布日期(可自选);v2 迁移回填为 created_at
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_notes_public
|
CREATE INDEX IF NOT EXISTS idx_notes_public
|
||||||
ON notes (status, pinned, updated_at DESC);
|
ON notes (status, pinned, updated_at DESC);
|
||||||
@@ -267,7 +268,7 @@ CREATE TABLE IF NOT EXISTS settings (
|
|||||||
);
|
);
|
||||||
-- 键白名单(§7.1 SettingsDTO 同源):
|
-- 键白名单(§7.1 SettingsDTO 同源):
|
||||||
-- admin_password_hash : PHC 串 $argon2id$v=19$m=19456,t=2,p=1$<b64salt>$<b64hash>
|
-- admin_password_hash : PHC 串 $argon2id$v=19$m=19456,t=2,p=1$<b64salt>$<b64hash>
|
||||||
-- site_title / site_desc / page_size
|
-- site_title / site_desc / page_size / beian_no / site_logo(站内绝对路径)
|
||||||
-- admin_password_hash 永不进入任何 API 响应、不可经 settings 接口写入(§9.3)
|
-- admin_password_hash 永不进入任何 API 响应、不可经 settings 接口写入(§9.3)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -326,10 +327,11 @@ CREATE TABLE IF NOT EXISTS settings (
|
|||||||
| PUT | `/api/admin/notes/{id}` | 更新(事务内重建 image_refs);slug 冲突返回 409 + 字段级错误 |
|
| PUT | `/api/admin/notes/{id}` | 更新(事务内重建 image_refs);slug 冲突返回 409 + 字段级错误 |
|
||||||
| DELETE | `/api/admin/notes/{id}` | **软删除**:置 `deleted_at` 进入回收站 |
|
| DELETE | `/api/admin/notes/{id}` | **软删除**:置 `deleted_at` 进入回收站 |
|
||||||
| GET | `/api/admin/trash` | 回收站列表 |
|
| GET | `/api/admin/trash` | 回收站列表 |
|
||||||
|
| DELETE | `/api/admin/trash` | **清空回收站**:物理删除全部软删除笔记(refs 级联) |
|
||||||
| POST | `/api/admin/trash/{id}/restore` | 恢复:清空 `deleted_at`(slug 仍被自身占用,无冲突;外部占用则在 30 天内不可能,见 §6.2) |
|
| POST | `/api/admin/trash/{id}/restore` | 恢复:清空 `deleted_at`(slug 仍被自身占用,无冲突;外部占用则在 30 天内不可能,见 §6.2) |
|
||||||
| POST | `/api/admin/images` | multipart 上传 → `{id, url}`;≤5MB |
|
| POST | `/api/admin/images` | multipart 上传 → `{id, url}`;≤5MB |
|
||||||
| GET | `/api/admin/images?orphan=1` | 0 引用图片清单(删除前检视,实际清除由 gc 执行) |
|
| GET | `/api/admin/images?orphan=1` | 0 引用图片清单(删除前检视,实际清除由 gc 执行) |
|
||||||
| GET/PUT | `/api/admin/settings` | **SettingsDTO 白名单**(site_title/site_desc/page_size),读永不序列化 `admin_password_hash`、写只收白名单键 |
|
| GET/PUT | `/api/admin/settings` | **SettingsDTO 白名单**(site_title/site_desc/page_size/beian_no/site_logo;page_size 仅 10/20/30/50),读永不序列化 `admin_password_hash`、写只收白名单键 |
|
||||||
| POST | `/api/admin/password` | `{old_password, new_password}`;校验旧密码(常量时间)、新密码 ≥12 字符;复用登录限流 |
|
| POST | `/api/admin/password` | `{old_password, new_password}`;校验旧密码(常量时间)、新密码 ≥12 字符;复用登录限流 |
|
||||||
|
|
||||||
### 7.2 中间件链与服务器参数
|
### 7.2 中间件链与服务器参数
|
||||||
@@ -379,7 +381,7 @@ SecurityHeaders(含 HSTS,§9.2)
|
|||||||
| 子命令 / 开关 | 说明 |
|
| 子命令 / 开关 | 说明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `pn start` | 启动;`--addr :8080`、`--data-dir`(默认 `./data`)、`--log-level`、`--log-format text|json`、`--behind-proxy`(声明位于可信反代之后,启用 XFF 处理)、`--dev`(loopback-only,§7.3-8)、`--allow-newer`(跳过 schema 版本上界校验,§10.4);环境变量:`PN_ADMIN_PASSWORD` |
|
| `pn start` | 启动;`--addr :8080`、`--data-dir`(默认 `./data`)、`--log-level`、`--log-format text|json`、`--behind-proxy`(声明位于可信反代之后,启用 XFF 处理)、`--dev`(loopback-only,§7.3-8)、`--allow-newer`(跳过 schema 版本上界校验,§10.4);环境变量:`PN_ADMIN_PASSWORD` |
|
||||||
| `pn init` | 首次初始化:设口令(Argon2id+PHC)、站点标题 |
|
| `pn init` | 首次初始化:设口令(Argon2id+PHC)、站点标题;并写入一篇 Markdown 语法示例文档(公开,slug `welcome`)及程序生成的示例图片 |
|
||||||
| `pn passwd` | 重设管理员口令(覆盖旧哈希)并吊销全部会话;口令来源同 `init`(环境变量 `PN_ADMIN_PASSWORD` 或交互输入,不经 argv 避免 ps 泄露)。CLI 可达即具备服务器权限,允许直接重设 |
|
| `pn passwd` | 重设管理员口令(覆盖旧哈希)并吊销全部会话;口令来源同 `init`(环境变量 `PN_ADMIN_PASSWORD` 或交互输入,不经 argv 避免 ps 泄露)。CLI 可达即具备服务器权限,允许直接重设 |
|
||||||
| `pn backup [path]` | 在线备份:`VACUUM INTO`(一致快照,不停服);默认输出 0600 权限 |
|
| `pn backup [path]` | 在线备份:`VACUUM INTO`(一致快照,不停服);默认输出 0600 权限 |
|
||||||
| `pn gc` | 回收站过期清除 + 孤儿图片清除 + 会话清理;**默认 `--dry-run`**,`--commit` 才执行(§6.3) |
|
| `pn gc` | 回收站过期清除 + 孤儿图片清除 + 会话清理;**默认 `--dry-run`**,`--commit` 才执行(§6.3) |
|
||||||
@@ -398,8 +400,8 @@ SecurityHeaders(含 HSTS,§9.2)
|
|||||||
| `/tags/:tag` | 标签页 | 该标签下公开笔记 |
|
| `/tags/:tag` | 标签页 | 该标签下公开笔记 |
|
||||||
| `/admin/login` | 登录页 | 口令登录、防爆破提示 |
|
| `/admin/login` | 登录页 | 口令登录、防爆破提示 |
|
||||||
| `/admin` | 管理列表 | 全部笔记(公/私标签可见)、状态开关、新建、删除(入回收站) |
|
| `/admin` | 管理列表 | 全部笔记(公/私标签可见)、状态开关、新建、删除(入回收站) |
|
||||||
| `/admin/trash` | 回收站 | 已删列表、恢复 |
|
| `/admin/trash` | 回收站 | 已删列表、恢复、一键清空 |
|
||||||
| `/admin/notes/new` `/admin/notes/:id/edit` | 编辑器 | CodeMirror 源码编辑 + 实时预览(分屏/切换)、元信息侧栏(标题/slug/标签/摘要/公开开关/置顶)、图片粘贴上传 |
|
| `/admin/notes/new` `/admin/notes/:id/edit` | 编辑器 | CodeMirror 源码编辑 + 实时预览(分屏/切换)、元信息侧栏(标题/slug/标签选择器/发布日期/摘要/公开开关/置顶)、图片粘贴上传 |
|
||||||
| `*` | 404 | 统一兜底 |
|
| `*` | 404 | 统一兜底 |
|
||||||
|
|
||||||
### 8.2 核心技术要点
|
### 8.2 核心技术要点
|
||||||
|
|||||||
+56
-22
@@ -27,16 +27,17 @@ import (
|
|||||||
|
|
||||||
// adminNoteItem 管理列表项(不含全文)。
|
// adminNoteItem 管理列表项(不含全文)。
|
||||||
type adminNoteItem struct {
|
type adminNoteItem struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Summary string `json:"summary"`
|
Summary string `json:"summary"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
Pinned bool `json:"pinned"`
|
Pinned bool `json:"pinned"`
|
||||||
DeletedAt *int64 `json:"deleted_at,omitempty"`
|
DeletedAt *int64 `json:"deleted_at,omitempty"`
|
||||||
CreatedAt int64 `json:"created_at"`
|
CreatedAt int64 `json:"created_at"`
|
||||||
UpdatedAt int64 `json:"updated_at"`
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
|
PublishedAt int64 `json:"published_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func toAdminItem(n *store.Note) adminNoteItem {
|
func toAdminItem(n *store.Note) adminNoteItem {
|
||||||
@@ -44,6 +45,7 @@ func toAdminItem(n *store.Note) adminNoteItem {
|
|||||||
ID: n.ID, Slug: n.Slug, Title: n.Title, Summary: n.Summary,
|
ID: n.ID, Slug: n.Slug, Title: n.Title, Summary: n.Summary,
|
||||||
Status: n.Status, Tags: n.Tags, Pinned: n.Pinned,
|
Status: n.Status, Tags: n.Tags, Pinned: n.Pinned,
|
||||||
DeletedAt: n.DeletedAt, CreatedAt: n.CreatedAt, UpdatedAt: n.UpdatedAt,
|
DeletedAt: n.DeletedAt, CreatedAt: n.CreatedAt, UpdatedAt: n.UpdatedAt,
|
||||||
|
PublishedAt: n.PublishedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,15 +63,16 @@ func (s *Server) handleAdminNotes(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": len(items)})
|
writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": len(items)})
|
||||||
}
|
}
|
||||||
|
|
||||||
// notePayload 笔记写请求体。
|
// notePayload 笔记写请求体。PublishedAt 为发布日期(Unix 秒);nil = 创建取当前时刻 / 更新保持不变。
|
||||||
type notePayload struct {
|
type notePayload struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
Summary string `json:"summary"`
|
Summary string `json:"summary"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
Pinned bool `json:"pinned"`
|
Pinned bool `json:"pinned"`
|
||||||
|
PublishedAt *int64 `json:"published_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *notePayload) validate() (string, string) { // 返回 (错误码, 消息)
|
func (p *notePayload) validate() (string, string) { // 返回 (错误码, 消息)
|
||||||
@@ -106,10 +109,14 @@ func (s *Server) handleAdminNoteCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
if strings.TrimSpace(summary) == "" {
|
if strings.TrimSpace(summary) == "" {
|
||||||
summary = markdown.Summary(p.Content, 200)
|
summary = markdown.Summary(p.Content, 200)
|
||||||
}
|
}
|
||||||
|
publishedAt := now
|
||||||
|
if p.PublishedAt != nil && *p.PublishedAt > 0 {
|
||||||
|
publishedAt = *p.PublishedAt
|
||||||
|
}
|
||||||
n := &store.Note{
|
n := &store.Note{
|
||||||
Slug: slug, Title: strings.TrimSpace(p.Title), Summary: summary,
|
Slug: slug, Title: strings.TrimSpace(p.Title), Summary: summary,
|
||||||
Content: p.Content, Status: p.Status, Tags: store.NormalizeTags(p.Tags),
|
Content: p.Content, Status: p.Status, Tags: store.NormalizeTags(p.Tags),
|
||||||
Pinned: p.Pinned, CreatedAt: now, UpdatedAt: now,
|
Pinned: p.Pinned, PublishedAt: publishedAt, CreatedAt: now, UpdatedAt: now,
|
||||||
}
|
}
|
||||||
id, err := s.st.CreateNote(n)
|
id, err := s.st.CreateNote(n)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -203,6 +210,9 @@ func (s *Server) handleAdminNoteUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
n.Tags = store.NormalizeTags(p.Tags)
|
n.Tags = store.NormalizeTags(p.Tags)
|
||||||
n.Pinned = p.Pinned
|
n.Pinned = p.Pinned
|
||||||
n.Content = p.Content
|
n.Content = p.Content
|
||||||
|
if p.PublishedAt != nil && *p.PublishedAt > 0 {
|
||||||
|
n.PublishedAt = *p.PublishedAt
|
||||||
|
}
|
||||||
if strings.TrimSpace(p.Summary) == "" {
|
if strings.TrimSpace(p.Summary) == "" {
|
||||||
n.Summary = markdown.Summary(p.Content, 200)
|
n.Summary = markdown.Summary(p.Content, 200)
|
||||||
} else {
|
} else {
|
||||||
@@ -253,6 +263,17 @@ func (s *Server) handleAdminTrash(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": len(items)})
|
writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": len(items)})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleAdminTrashEmpty DELETE /api/admin/trash:清空回收站(物理删除全部,§6.2)。
|
||||||
|
func (s *Server) handleAdminTrashEmpty(w http.ResponseWriter, r *http.Request) {
|
||||||
|
n, err := s.st.EmptyTrash()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.log.Info("admin_action", "op", "trash.empty", "count", n)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "deleted": n})
|
||||||
|
}
|
||||||
|
|
||||||
// handleAdminTrashRestore POST /api/admin/trash/{id}/restore:恢复(清空 deleted_at)。
|
// handleAdminTrashRestore POST /api/admin/trash/{id}/restore:恢复(清空 deleted_at)。
|
||||||
func (s *Server) handleAdminTrashRestore(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAdminTrashRestore(w http.ResponseWriter, r *http.Request) {
|
||||||
id, ok := pathID(r)
|
id, ok := pathID(r)
|
||||||
@@ -397,6 +418,7 @@ type settingsDTO struct {
|
|||||||
SiteDesc *string `json:"site_desc"`
|
SiteDesc *string `json:"site_desc"`
|
||||||
PageSize *int `json:"page_size"`
|
PageSize *int `json:"page_size"`
|
||||||
BeianNo *string `json:"beian_no"`
|
BeianNo *string `json:"beian_no"`
|
||||||
|
SiteLogo *string `json:"site_logo"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleAdminSettingsGet GET /api/admin/settings:白名单三键;永不序列化 admin_password_hash。
|
// handleAdminSettingsGet GET /api/admin/settings:白名单三键;永不序列化 admin_password_hash。
|
||||||
@@ -415,7 +437,7 @@ func (s *Server) handleAdminSettingsPut(w http.ResponseWriter, r *http.Request)
|
|||||||
dec.DisallowUnknownFields()
|
dec.DisallowUnknownFields()
|
||||||
var dto settingsDTO
|
var dto settingsDTO
|
||||||
if err := dec.Decode(&dto); err != nil {
|
if err := dec.Decode(&dto); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "bad_request", "包含未知字段或类型不合法(白名单:site_title/site_desc/page_size/beian_no)")
|
writeError(w, http.StatusBadRequest, "bad_request", "包含未知字段或类型不合法(白名单:site_title/site_desc/page_size/beian_no/site_logo)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if dto.SiteTitle != nil {
|
if dto.SiteTitle != nil {
|
||||||
@@ -440,8 +462,8 @@ func (s *Server) handleAdminSettingsPut(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if dto.PageSize != nil {
|
if dto.PageSize != nil {
|
||||||
if *dto.PageSize < 1 || *dto.PageSize > 100 {
|
if !store.ValidPageSize(*dto.PageSize) {
|
||||||
writeError(w, http.StatusBadRequest, "bad_request", "page_size ∈ [1,100]")
|
writeError(w, http.StatusBadRequest, "bad_request", "page_size 仅支持 10/20/30/50")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.st.SetSetting(store.KeyPageSize, strconv.Itoa(*dto.PageSize)); err != nil {
|
if err := s.st.SetSetting(store.KeyPageSize, strconv.Itoa(*dto.PageSize)); err != nil {
|
||||||
@@ -460,6 +482,18 @@ func (s *Server) handleAdminSettingsPut(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if dto.SiteLogo != nil {
|
||||||
|
// 仅收站内绝对路径(如 /api/images/1):CSP img-src 'self',外链图片本来就无法展示
|
||||||
|
v := strings.TrimSpace(*dto.SiteLogo)
|
||||||
|
if v != "" && (len(v) > 500 || strings.ContainsAny(v, " \t\r\n\"'\\") || !strings.HasPrefix(v, "/")) {
|
||||||
|
writeError(w, http.StatusBadRequest, "bad_request", "站点 Logo 须为站内绝对路径(如 /api/images/1)或留空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.st.SetSetting(store.KeySiteLogo, v); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
// admin_password_hash 不可经此接口写入(§9.3):不在白名单结构体中,天然拒绝。
|
// admin_password_hash 不可经此接口写入(§9.3):不在白名单结构体中,天然拒绝。
|
||||||
s.log.Info("admin_action", "op", "settings.update")
|
s.log.Info("admin_action", "op", "settings.update")
|
||||||
ss, err := s.st.GetSiteSettings()
|
ss, err := s.st.GetSiteSettings()
|
||||||
|
|||||||
@@ -284,7 +284,7 @@ func TestSettingsWhitelist(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// PUT 合法键
|
// PUT 合法键
|
||||||
resp, _ = e.do(admin, http.MethodPut, "/api/admin/settings",
|
resp, _ = e.do(admin, http.MethodPut, "/api/admin/settings",
|
||||||
[]byte(`{"site_title":"新标题","site_desc":"描述","page_size":5,"beian_no":"京公网安备12345678901号"}`), e.adminHeaders())
|
[]byte(`{"site_title":"新标题","site_desc":"描述","page_size":20,"beian_no":"京公网安备12345678901号"}`), e.adminHeaders())
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
t.Errorf("合法 PUT 应 200: %d", resp.StatusCode)
|
t.Errorf("合法 PUT 应 200: %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
@@ -311,14 +311,16 @@ func TestSettingsWhitelist(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// page_size 生效为公开列表默认
|
// page_size 生效为公开列表默认
|
||||||
_, body = e.get(e.client(), "/api/notes")
|
_, body = e.get(e.client(), "/api/notes")
|
||||||
if !strings.Contains(string(body), `"page_size":5`) {
|
if !strings.Contains(string(body), `"page_size":20`) {
|
||||||
t.Errorf("page_size 设置应生效: %s", body)
|
t.Errorf("page_size 设置应生效: %s", body)
|
||||||
}
|
}
|
||||||
// page_size 越界
|
// page_size 越界/非候选项
|
||||||
resp, _ = e.do(admin, http.MethodPut, "/api/admin/settings",
|
for _, bad := range []string{"0", "5", "101"} {
|
||||||
[]byte(`{"page_size":0}`), e.adminHeaders())
|
resp, _ = e.do(admin, http.MethodPut, "/api/admin/settings",
|
||||||
if resp.StatusCode != http.StatusBadRequest {
|
[]byte(`{"page_size":`+bad+`}`), e.adminHeaders())
|
||||||
t.Errorf("page_size=0 应 400: %d", resp.StatusCode)
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Errorf("page_size=%s 应 400: %d", bad, resp.StatusCode)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ type rssItem struct {
|
|||||||
Title string `xml:"title"`
|
Title string `xml:"title"`
|
||||||
Link string `xml:"link"`
|
Link string `xml:"link"`
|
||||||
GUID string `xml:"guid"`
|
GUID string `xml:"guid"`
|
||||||
PubDate string `xml:"pubDate"` // RFC 822(RSS 2.0 规范)
|
PubDate string `xml:"pubDate"` // RFC 822(RSS 2.0 规范)
|
||||||
Description string `xml:"description"` // 已清洗的 HTML(经 encoding/xml 自动转义)
|
Description string `xml:"description"` // 已清洗的 HTML(经 encoding/xml 自动转义)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,8 +92,8 @@ func (s *Server) handleRSS(w http.ResponseWriter, r *http.Request) {
|
|||||||
// ---- sitemap(仅公开笔记)----
|
// ---- sitemap(仅公开笔记)----
|
||||||
|
|
||||||
type urlSet struct {
|
type urlSet struct {
|
||||||
XMLName xml.Name `xml:"urlset"`
|
XMLName xml.Name `xml:"urlset"`
|
||||||
XMLNS string `xml:"xmlns,attr"`
|
XMLNS string `xml:"xmlns,attr"`
|
||||||
URLs []siteURL `xml:"url"`
|
URLs []siteURL `xml:"url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-11
@@ -40,15 +40,17 @@ func (s *Server) handleSiteInfo(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, ss)
|
writeJSON(w, http.StatusOK, ss)
|
||||||
}
|
}
|
||||||
|
|
||||||
// publicNoteItem 公开列表项(元信息,不含全文)。
|
// publicNoteItem 公开列表项(元信息 + 封面图,不含全文)。
|
||||||
type publicNoteItem struct {
|
type publicNoteItem struct {
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Summary string `json:"summary"`
|
Summary string `json:"summary"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
Pinned bool `json:"pinned"`
|
Pinned bool `json:"pinned"`
|
||||||
CreatedAt int64 `json:"created_at"`
|
Cover string `json:"cover,omitempty"`
|
||||||
UpdatedAt int64 `json:"updated_at"`
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
|
PublishedAt int64 `json:"published_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handlePublicNotes GET /api/notes:公开笔记列表(可见性过滤在查询层,§9.1-T10)。
|
// handlePublicNotes GET /api/notes:公开笔记列表(可见性过滤在查询层,§9.1-T10)。
|
||||||
@@ -70,7 +72,8 @@ func (s *Server) handlePublicNotes(w http.ResponseWriter, r *http.Request) {
|
|||||||
items = append(items, publicNoteItem{
|
items = append(items, publicNoteItem{
|
||||||
Slug: n.Slug, Title: n.Title, Summary: n.Summary,
|
Slug: n.Slug, Title: n.Title, Summary: n.Summary,
|
||||||
Tags: n.Tags, Pinned: n.Pinned,
|
Tags: n.Tags, Pinned: n.Pinned,
|
||||||
CreatedAt: n.CreatedAt, UpdatedAt: n.UpdatedAt,
|
Cover: store.FirstImageRef(n.Content),
|
||||||
|
CreatedAt: n.CreatedAt, UpdatedAt: n.UpdatedAt, PublishedAt: n.PublishedAt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
@@ -119,8 +122,9 @@ func (s *Server) handlePublicNote(w http.ResponseWriter, r *http.Request) {
|
|||||||
"summary": note.Summary, "content": note.Content,
|
"summary": note.Summary, "content": note.Content,
|
||||||
"status": note.Status, "tags": note.Tags, "pinned": note.Pinned,
|
"status": note.Status, "tags": note.Tags, "pinned": note.Pinned,
|
||||||
"created_at": note.CreatedAt, "updated_at": note.UpdatedAt,
|
"created_at": note.CreatedAt, "updated_at": note.UpdatedAt,
|
||||||
"prev": siblingOrEmpty(prevSlug, prevTitle),
|
"published_at": note.PublishedAt,
|
||||||
"next": siblingOrEmpty(nextSlug, nextTitle),
|
"prev": siblingOrEmpty(prevSlug, prevTitle),
|
||||||
|
"next": siblingOrEmpty(nextSlug, nextTitle),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+22
-12
@@ -29,12 +29,12 @@ const (
|
|||||||
|
|
||||||
// Server HTTP 服务。
|
// Server HTTP 服务。
|
||||||
type Server struct {
|
type Server struct {
|
||||||
st *store.Store
|
st *store.Store
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
global *middleware.Limiter // 全局宽松限流(per-IP)
|
global *middleware.Limiter // 全局宽松限流(per-IP)
|
||||||
loginIP *middleware.Limiter // 登录/改密 per-IP:10 次/5 分钟
|
loginIP *middleware.Limiter // 登录/改密 per-IP:10 次/5 分钟
|
||||||
loginAcct *middleware.Limiter // 登录/改密 per-账号:5 次/10 分钟
|
loginAcct *middleware.Limiter // 登录/改密 per-账号:5 次/10 分钟
|
||||||
}
|
}
|
||||||
|
|
||||||
// New 构造 Server(生产限流参数,§7.2)。
|
// New 构造 Server(生产限流参数,§7.2)。
|
||||||
@@ -93,6 +93,7 @@ func (s *Server) Handler(ui http.Handler) http.Handler {
|
|||||||
adminMux.Handle("PUT /api/admin/notes/{id}", middleware.MaxBytes(maxNoteBody)(http.HandlerFunc(s.handleAdminNoteUpdate)))
|
adminMux.Handle("PUT /api/admin/notes/{id}", middleware.MaxBytes(maxNoteBody)(http.HandlerFunc(s.handleAdminNoteUpdate)))
|
||||||
adminMux.HandleFunc("DELETE /api/admin/notes/{id}", s.handleAdminNoteDelete)
|
adminMux.HandleFunc("DELETE /api/admin/notes/{id}", s.handleAdminNoteDelete)
|
||||||
adminMux.HandleFunc("GET /api/admin/trash", s.handleAdminTrash)
|
adminMux.HandleFunc("GET /api/admin/trash", s.handleAdminTrash)
|
||||||
|
adminMux.HandleFunc("DELETE /api/admin/trash", s.handleAdminTrashEmpty)
|
||||||
adminMux.HandleFunc("POST /api/admin/trash/{id}/restore", s.handleAdminTrashRestore)
|
adminMux.HandleFunc("POST /api/admin/trash/{id}/restore", s.handleAdminTrashRestore)
|
||||||
adminMux.Handle("POST /api/admin/images", middleware.MaxBytes(maxUploadBody)(http.HandlerFunc(s.handleAdminImageUpload)))
|
adminMux.Handle("POST /api/admin/images", middleware.MaxBytes(maxUploadBody)(http.HandlerFunc(s.handleAdminImageUpload)))
|
||||||
adminMux.HandleFunc("GET /api/admin/images", s.handleAdminImages)
|
adminMux.HandleFunc("GET /api/admin/images", s.handleAdminImages)
|
||||||
@@ -291,13 +292,14 @@ func (s *Server) ResolveMeta(r *http.Request) webui.Meta {
|
|||||||
ss = &store.SiteSettings{SiteTitle: store.DefaultSiteTitle}
|
ss = &store.SiteSettings{SiteTitle: store.DefaultSiteTitle}
|
||||||
}
|
}
|
||||||
m := webui.Meta{
|
m := webui.Meta{
|
||||||
Title: ss.SiteTitle,
|
Title: ss.SiteTitle,
|
||||||
Description: ss.SiteDesc,
|
Description: ss.SiteDesc,
|
||||||
OGTitle: ss.SiteTitle,
|
OGTitle: ss.SiteTitle,
|
||||||
OGDescription: ss.SiteDesc,
|
OGDescription: ss.SiteDesc,
|
||||||
OGType: "website",
|
OGType: "website",
|
||||||
SiteName: ss.SiteTitle,
|
SiteName: ss.SiteTitle,
|
||||||
OGURL: baseURL(r) + "/",
|
OGURL: baseURL(r) + "/",
|
||||||
|
Icon: favicon(ss.SiteLogo),
|
||||||
}
|
}
|
||||||
slug := metaSlug(r.URL.Path)
|
slug := metaSlug(r.URL.Path)
|
||||||
if slug == "" {
|
if slug == "" {
|
||||||
@@ -317,6 +319,14 @@ func (s *Server) ResolveMeta(r *http.Request) webui.Meta {
|
|||||||
return s.noteMeta(r, m, note)
|
return s.noteMeta(r, m, note)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// favicon favicon 地址:站点 Logo 优先,未设置回退内置图标。
|
||||||
|
func favicon(siteLogo string) string {
|
||||||
|
if siteLogo != "" {
|
||||||
|
return siteLogo
|
||||||
|
}
|
||||||
|
return "/favicon.svg"
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) noteMeta(r *http.Request, base webui.Meta, n *store.Note) webui.Meta {
|
func (s *Server) noteMeta(r *http.Request, base webui.Meta, n *store.Note) webui.Meta {
|
||||||
desc := n.Summary
|
desc := n.Summary
|
||||||
if desc == "" {
|
if desc == "" {
|
||||||
|
|||||||
@@ -154,12 +154,12 @@ func TestVisibilityMatrix(t *testing.T) {
|
|||||||
|
|
||||||
// ---- 图片出口(并集语义 + 缓存头分流,§6.2/§7.4)----
|
// ---- 图片出口(并集语义 + 缓存头分流,§6.2/§7.4)----
|
||||||
imageCases := []struct {
|
imageCases := []struct {
|
||||||
name string
|
name string
|
||||||
id int64
|
id int64
|
||||||
anonCode int
|
anonCode int
|
||||||
anonCache string
|
anonCache string
|
||||||
adminCode int
|
adminCode int
|
||||||
adminCache string
|
adminCache string
|
||||||
}{
|
}{
|
||||||
{"公开图", f.imgPub, 200, "public, max-age=31536000, immutable", 200, "public, max-age=31536000, immutable"},
|
{"公开图", f.imgPub, 200, "public, max-age=31536000, immutable", 200, "public, max-age=31536000, immutable"},
|
||||||
{"私有图", f.imgPriv, 404, "", 200, "private, no-store"},
|
{"私有图", f.imgPriv, 404, "", 200, "private, no-store"},
|
||||||
|
|||||||
@@ -64,6 +64,16 @@ func (s *Store) ImageIsPublic(id int64) (bool, error) {
|
|||||||
// imageRefRe 扫描 content 中对 /api/images/{id} 的引用(§6.2 引用维护)。
|
// imageRefRe 扫描 content 中对 /api/images/{id} 的引用(§6.2 引用维护)。
|
||||||
var imageRefRe = regexp.MustCompile(`/api/images/(\d+)`)
|
var imageRefRe = regexp.MustCompile(`/api/images/(\d+)`)
|
||||||
|
|
||||||
|
// FirstImageRef 返回 content 中按出现顺序的第一张站内图片路径(无则空串)。
|
||||||
|
// 供公开列表推导封面图使用。
|
||||||
|
func FirstImageRef(content string) string {
|
||||||
|
m := imageRefRe.FindStringSubmatch(content)
|
||||||
|
if len(m) < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return m[0]
|
||||||
|
}
|
||||||
|
|
||||||
// RebuildImageRefs 在**同一事务**内重建笔记的图片引用:
|
// RebuildImageRefs 在**同一事务**内重建笔记的图片引用:
|
||||||
// 删除该笔记全部 refs → 正则扫描 content → 重建(§6.2)。
|
// 删除该笔记全部 refs → 正则扫描 content → 重建(§6.2)。
|
||||||
func (s *Store) RebuildImageRefs(noteID int64, content string) error {
|
func (s *Store) RebuildImageRefs(noteID int64, content string) error {
|
||||||
@@ -101,11 +111,13 @@ func rebuildRefsTx(tx *sql.Tx, noteID int64, content string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ListOrphanImages 零引用图片清单(GET /api/admin/images?orphan=1 与 gc 检视用)。
|
// ListOrphanImages 零引用图片清单(GET /api/admin/images?orphan=1 与 gc 检视用)。
|
||||||
|
// 站点 Logo 引用的图片虽无笔记引用仍受保护,不视为孤儿。
|
||||||
func (s *Store) ListOrphanImages() ([]Image, error) {
|
func (s *Store) ListOrphanImages() ([]Image, error) {
|
||||||
rows, err := s.db.Query(`
|
rows, err := s.db.Query(`
|
||||||
SELECT i.id, i.sha256, i.mime, i.size, i.created_at FROM images i
|
SELECT i.id, i.sha256, i.mime, i.size, i.created_at FROM images i
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM image_refs r WHERE r.image_id = i.id)
|
WHERE NOT EXISTS (SELECT 1 FROM image_refs r WHERE r.image_id = i.id)
|
||||||
ORDER BY i.created_at DESC`)
|
AND i.id <> ?
|
||||||
|
ORDER BY i.created_at DESC`, s.logoImageID())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,12 +54,14 @@ func (s *Store) GC(now time.Time, dryRun bool) (*GCReport, error) {
|
|||||||
}
|
}
|
||||||
rows.Close()
|
rows.Close()
|
||||||
|
|
||||||
// 孤儿图片:0 引用且超过宽限期
|
// 孤儿图片:0 引用且超过宽限期;站点 Logo 引用的图片受保护
|
||||||
imageCutoff := now.Add(-OrphanGrace).Unix()
|
imageCutoff := now.Add(-OrphanGrace).Unix()
|
||||||
|
logoID := s.logoImageID()
|
||||||
orphanRows, err := s.db.Query(`
|
orphanRows, err := s.db.Query(`
|
||||||
SELECT i.id, i.sha256, i.mime, i.size, i.created_at FROM images i
|
SELECT i.id, i.sha256, i.mime, i.size, i.created_at FROM images i
|
||||||
WHERE i.created_at < ? AND NOT EXISTS (SELECT 1 FROM image_refs r WHERE r.image_id = i.id)
|
WHERE i.created_at < ? AND NOT EXISTS (SELECT 1 FROM image_refs r WHERE r.image_id = i.id)
|
||||||
ORDER BY i.created_at`, imageCutoff)
|
AND i.id <> ?
|
||||||
|
ORDER BY i.created_at`, imageCutoff, logoID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -101,9 +103,10 @@ func (s *Store) GC(now time.Time, dryRun bool) (*GCReport, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, img := range rep.OrphanImages {
|
for _, img := range rep.OrphanImages {
|
||||||
|
// 条件复查含 Logo 保护:SELECT 与 DELETE 之间 Logo 可能指向该图
|
||||||
res, err := s.db.Exec(
|
res, err := s.db.Exec(
|
||||||
`DELETE FROM images WHERE id=? AND NOT EXISTS (SELECT 1 FROM image_refs WHERE image_id=?)`,
|
`DELETE FROM images WHERE id=? AND NOT EXISTS (SELECT 1 FROM image_refs WHERE image_id=?) AND id<>?`,
|
||||||
img.ID, img.ID)
|
img.ID, img.ID, logoID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("删除孤儿图片 %d: %w", img.ID, err)
|
return nil, fmt.Errorf("删除孤儿图片 %d: %w", img.ID, err)
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-21
@@ -9,31 +9,34 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Note 笔记实体。DeletedAt 非 nil 表示处于回收站(软删除)。
|
// Note 笔记实体。DeletedAt 非 nil 表示处于回收站(软删除)。
|
||||||
|
// PublishedAt 为对外展示的发布日期(可自选,§8.2);排序仍按 updated_at。
|
||||||
type Note struct {
|
type Note struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Summary string `json:"summary"`
|
Summary string `json:"summary"`
|
||||||
Content string `json:"content,omitempty"`
|
Content string `json:"content,omitempty"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
Pinned bool `json:"pinned"`
|
Pinned bool `json:"pinned"`
|
||||||
DeletedAt *int64 `json:"deleted_at,omitempty"`
|
DeletedAt *int64 `json:"deleted_at,omitempty"`
|
||||||
CreatedAt int64 `json:"created_at"`
|
CreatedAt int64 `json:"created_at"`
|
||||||
UpdatedAt int64 `json:"updated_at"`
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
|
PublishedAt int64 `json:"published_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ErrNotFound 统一的「不存在」错误。
|
// ErrNotFound 统一的「不存在」错误。
|
||||||
var ErrNotFound = errors.New("not found")
|
var ErrNotFound = errors.New("not found")
|
||||||
|
|
||||||
const noteColumns = "id, slug, title, summary, content, status, tags, pinned, deleted_at, created_at, updated_at"
|
// published_at 经 v2 迁移回填,理论上恒非 NULL;COALESCE 兜底历史异常行。
|
||||||
|
const noteColumns = "id, slug, title, summary, content, status, tags, pinned, deleted_at, created_at, updated_at, COALESCE(published_at, created_at) AS published_at"
|
||||||
|
|
||||||
func scanNote(scan func(dest ...any) error) (*Note, error) {
|
func scanNote(scan func(dest ...any) error) (*Note, error) {
|
||||||
var n Note
|
var n Note
|
||||||
var tagsJSON string
|
var tagsJSON string
|
||||||
var pinned int
|
var pinned int
|
||||||
var content string
|
var content string
|
||||||
if err := scan(&n.ID, &n.Slug, &n.Title, &n.Summary, &content, &n.Status, &tagsJSON, &pinned, &n.DeletedAt, &n.CreatedAt, &n.UpdatedAt); err != nil {
|
if err := scan(&n.ID, &n.Slug, &n.Title, &n.Summary, &content, &n.Status, &tagsJSON, &pinned, &n.DeletedAt, &n.CreatedAt, &n.UpdatedAt, &n.PublishedAt); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
n.Content = content
|
n.Content = content
|
||||||
@@ -54,9 +57,9 @@ func (s *Store) CreateNote(n *Note) (int64, error) {
|
|||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
res, err := s.db.Exec(
|
res, err := s.db.Exec(
|
||||||
`INSERT INTO notes (slug, title, summary, content, status, tags, pinned, created_at, updated_at)
|
`INSERT INTO notes (slug, title, summary, content, status, tags, pinned, published_at, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
n.Slug, n.Title, n.Summary, n.Content, n.Status, tags, boolToInt(n.Pinned), n.CreatedAt, n.UpdatedAt)
|
n.Slug, n.Title, n.Summary, n.Content, n.Status, tags, boolToInt(n.Pinned), n.PublishedAt, n.CreatedAt, n.UpdatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -70,8 +73,8 @@ func (s *Store) UpdateNote(n *Note) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
res, err := s.db.Exec(
|
res, err := s.db.Exec(
|
||||||
`UPDATE notes SET title=?, summary=?, content=?, status=?, tags=?, pinned=?, updated_at=? WHERE id=? AND deleted_at IS NULL`,
|
`UPDATE notes SET title=?, summary=?, content=?, status=?, tags=?, pinned=?, published_at=?, updated_at=? WHERE id=? AND deleted_at IS NULL`,
|
||||||
n.Title, n.Summary, n.Content, n.Status, tags, boolToInt(n.Pinned), n.UpdatedAt, n.ID)
|
n.Title, n.Summary, n.Content, n.Status, tags, boolToInt(n.Pinned), n.PublishedAt, n.UpdatedAt, n.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -164,7 +167,8 @@ func (s *Store) ListPublicNotes(page, pageSize int, tag string) ([]Note, int, er
|
|||||||
if err := s.db.QueryRow(`SELECT COUNT(*) FROM notes n WHERE `+where, args...).Scan(&total); err != nil {
|
if err := s.db.QueryRow(`SELECT COUNT(*) FROM notes n WHERE `+where, args...).Scan(&total); err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
q := `SELECT n.id, n.slug, n.title, n.summary, '' AS content, n.status, n.tags, n.pinned, n.deleted_at, n.created_at, n.updated_at
|
// 含全文:列表页需从正文推导封面图(FirstImageRef),个人规模条数有限可接受
|
||||||
|
q := `SELECT n.id, n.slug, n.title, n.summary, n.content, n.status, n.tags, n.pinned, n.deleted_at, n.created_at, n.updated_at, COALESCE(n.published_at, n.created_at) AS published_at
|
||||||
FROM notes n WHERE ` + where + `
|
FROM notes n WHERE ` + where + `
|
||||||
ORDER BY n.pinned DESC, n.updated_at DESC, n.id DESC LIMIT ? OFFSET ?`
|
ORDER BY n.pinned DESC, n.updated_at DESC, n.id DESC LIMIT ? OFFSET ?`
|
||||||
rows, err := s.db.Query(q, append(args, pageSize, (page-1)*pageSize)...)
|
rows, err := s.db.Query(q, append(args, pageSize, (page-1)*pageSize)...)
|
||||||
@@ -234,7 +238,7 @@ func (s *Store) ListPublicNotesFull(limit int) ([]Note, error) {
|
|||||||
|
|
||||||
// ListAdminNotes 全部正常笔记(含私有,不含回收站),不含全文。
|
// ListAdminNotes 全部正常笔记(含私有,不含回收站),不含全文。
|
||||||
func (s *Store) ListAdminNotes() ([]Note, error) {
|
func (s *Store) ListAdminNotes() ([]Note, error) {
|
||||||
q := `SELECT id, slug, title, summary, '' AS content, status, tags, pinned, deleted_at, created_at, updated_at
|
q := `SELECT id, slug, title, summary, '' AS content, status, tags, pinned, deleted_at, created_at, updated_at, COALESCE(published_at, created_at) AS published_at
|
||||||
FROM notes WHERE deleted_at IS NULL
|
FROM notes WHERE deleted_at IS NULL
|
||||||
ORDER BY pinned DESC, updated_at DESC, id DESC`
|
ORDER BY pinned DESC, updated_at DESC, id DESC`
|
||||||
return s.queryNotes(q)
|
return s.queryNotes(q)
|
||||||
@@ -242,12 +246,21 @@ func (s *Store) ListAdminNotes() ([]Note, error) {
|
|||||||
|
|
||||||
// ListTrash 回收站列表(软删除中),不含全文。
|
// ListTrash 回收站列表(软删除中),不含全文。
|
||||||
func (s *Store) ListTrash() ([]Note, error) {
|
func (s *Store) ListTrash() ([]Note, error) {
|
||||||
q := `SELECT id, slug, title, summary, '' AS content, status, tags, pinned, deleted_at, created_at, updated_at
|
q := `SELECT id, slug, title, summary, '' AS content, status, tags, pinned, deleted_at, created_at, updated_at, COALESCE(published_at, created_at) AS published_at
|
||||||
FROM notes WHERE deleted_at IS NOT NULL
|
FROM notes WHERE deleted_at IS NOT NULL
|
||||||
ORDER BY deleted_at DESC`
|
ORDER BY deleted_at DESC`
|
||||||
return s.queryNotes(q)
|
return s.queryNotes(q)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EmptyTrash 物理删除回收站全部笔记(refs 级联,需 foreign_keys=1),返回删除数。
|
||||||
|
func (s *Store) EmptyTrash() (int64, error) {
|
||||||
|
res, err := s.db.Exec(`DELETE FROM notes WHERE deleted_at IS NOT NULL`)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
// ListExpiredTrash 过期待物理删除的笔记 id(gc 用)。
|
// ListExpiredTrash 过期待物理删除的笔记 id(gc 用)。
|
||||||
func (s *Store) ListExpiredTrash(before int64) ([]int64, error) {
|
func (s *Store) ListExpiredTrash(before int64) ([]int64, error) {
|
||||||
rows, err := s.db.Query(`SELECT id FROM notes WHERE deleted_at IS NOT NULL AND deleted_at < ?`, before)
|
rows, err := s.db.Query(`SELECT id FROM notes WHERE deleted_at IS NOT NULL AND deleted_at < ?`, before)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const (
|
|||||||
KeySiteDesc = "site_desc"
|
KeySiteDesc = "site_desc"
|
||||||
KeyPageSize = "page_size"
|
KeyPageSize = "page_size"
|
||||||
KeyBeianNo = "beian_no"
|
KeyBeianNo = "beian_no"
|
||||||
|
KeySiteLogo = "site_logo"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetSetting 读取单个设置。
|
// GetSetting 读取单个设置。
|
||||||
@@ -39,6 +40,7 @@ type SiteSettings struct {
|
|||||||
SiteDesc string `json:"site_desc"`
|
SiteDesc string `json:"site_desc"`
|
||||||
PageSize int `json:"page_size"`
|
PageSize int `json:"page_size"`
|
||||||
BeianNo string `json:"beian_no"`
|
BeianNo string `json:"beian_no"`
|
||||||
|
SiteLogo string `json:"site_logo"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Defaults,未初始化时兜底。
|
// Defaults,未初始化时兜底。
|
||||||
@@ -49,6 +51,15 @@ const (
|
|||||||
DefaultBeianNo = ""
|
DefaultBeianNo = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ValidPageSize 每页条数可选项(前端选择器同源:10/20/30/50)。
|
||||||
|
func ValidPageSize(n int) bool {
|
||||||
|
switch n {
|
||||||
|
case 10, 20, 30, 50:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// GetSiteSettings 读取站点设置(白名单键,带默认值)。
|
// GetSiteSettings 读取站点设置(白名单键,带默认值)。
|
||||||
func (s *Store) GetSiteSettings() (*SiteSettings, error) {
|
func (s *Store) GetSiteSettings() (*SiteSettings, error) {
|
||||||
ss := &SiteSettings{SiteTitle: DefaultSiteTitle, SiteDesc: DefaultSiteDesc, PageSize: DefaultPageSize, BeianNo: DefaultBeianNo}
|
ss := &SiteSettings{SiteTitle: DefaultSiteTitle, SiteDesc: DefaultSiteDesc, PageSize: DefaultPageSize, BeianNo: DefaultBeianNo}
|
||||||
@@ -65,8 +76,9 @@ func (s *Store) GetSiteSettings() (*SiteSettings, error) {
|
|||||||
if v, ok, err := s.GetSetting(KeyPageSize); err != nil {
|
if v, ok, err := s.GetSetting(KeyPageSize); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
} else if ok {
|
} else if ok {
|
||||||
// strconv.Atoi 全文解析:拒绝 "10abc" 类部分解析的脏值(评审 round2 P2-13)
|
// strconv.Atoi 全文解析:拒绝 "10abc" 类部分解析的脏值(评审 round2 P2-13);
|
||||||
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n >= 1 && n <= 100 {
|
// 脏值/越界值回退默认,而非落入任意 [1,100]
|
||||||
|
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && ValidPageSize(n) {
|
||||||
ss.PageSize = n
|
ss.PageSize = n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,5 +87,25 @@ func (s *Store) GetSiteSettings() (*SiteSettings, error) {
|
|||||||
} else if ok {
|
} else if ok {
|
||||||
ss.BeianNo = strings.TrimSpace(v)
|
ss.BeianNo = strings.TrimSpace(v)
|
||||||
}
|
}
|
||||||
|
if v, ok, err := s.GetSetting(KeySiteLogo); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if ok {
|
||||||
|
ss.SiteLogo = strings.TrimSpace(v)
|
||||||
|
}
|
||||||
return ss, nil
|
return ss, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// logoImageID 从 site_logo 设置解析受 GC 保护的图片 id(无 logo 或非本站图片路径返回 0)。
|
||||||
|
func (s *Store) logoImageID() int64 {
|
||||||
|
v, ok, err := s.GetSetting(KeySiteLogo)
|
||||||
|
if err != nil || !ok || v == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if m := imageRefRe.FindStringSubmatch(v); m != nil {
|
||||||
|
var id int64
|
||||||
|
if _, err := fmtSscanInt(m[1], &id); err == nil && id > 0 {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|||||||
@@ -69,6 +69,11 @@ CREATE TABLE IF NOT EXISTS settings (
|
|||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT NOT NULL
|
value TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
`,
|
||||||
|
// v2: 笔记发布日期(可自选);存量笔记回填为 created_at
|
||||||
|
`
|
||||||
|
ALTER TABLE notes ADD COLUMN published_at INTEGER;
|
||||||
|
UPDATE notes SET published_at = created_at WHERE published_at IS NULL;
|
||||||
`,
|
`,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -150,10 +150,10 @@ func TestAdjacentPublicNote(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mk("a", 100, false, "public") // id=1
|
mk("a", 100, false, "public") // id=1
|
||||||
mk("b", 100, false, "public") // id=2(与 a 同秒,列表中排在 a 之前)
|
mk("b", 100, false, "public") // id=2(与 a 同秒,列表中排在 a 之前)
|
||||||
mk("c", 80, false, "public") // id=3
|
mk("c", 80, false, "public") // id=3
|
||||||
mk("p", 50, true, "public") // id=4 置顶 → 列表首
|
mk("p", 50, true, "public") // id=4 置顶 → 列表首
|
||||||
mk("priv", 200, false, "private") // 更新的私有笔记:不得出现在邻接中
|
mk("priv", 200, false, "private") // 更新的私有笔记:不得出现在邻接中
|
||||||
|
|
||||||
get := func(slug string) *Note {
|
get := func(slug string) *Note {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ type Meta struct {
|
|||||||
OGURL string
|
OGURL string
|
||||||
OGImage string
|
OGImage string
|
||||||
SiteName string
|
SiteName string
|
||||||
|
Icon string // favicon:站点 Logo,未设置回退 /favicon.svg
|
||||||
}
|
}
|
||||||
|
|
||||||
// UI 静态资源服务。
|
// UI 静态资源服务。
|
||||||
|
|||||||
Executable
+52
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 发布产物构建:交叉编译全部目标平台 → dist/,打包并生成 SHA256SUMS。
|
||||||
|
# 本地 `make dist` 与 Gitea Actions 发版工作流共用本脚本,保证产物一致。
|
||||||
|
#
|
||||||
|
# 用法: scripts/build-release.sh [版本号]
|
||||||
|
# 版本号缺省取 `git describe --tags --always`(无 tag 时回退 dev)。
|
||||||
|
# 前置条件:internal/webui/dist 已就绪(make web sync-assets)。
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
VERSION="${1:-$(git describe --tags --always 2>/dev/null || echo dev)}"
|
||||||
|
COMMIT="$(git rev-parse --short HEAD 2>/dev/null || echo none)"
|
||||||
|
DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
LDFLAGS="-s -w -X main.version=$VERSION -X main.commit=$COMMIT -X main.buildDate=$DATE"
|
||||||
|
|
||||||
|
# 纯静态(CGO_ENABLED=0,见 Makefile),单机即可交叉编译全部目标。
|
||||||
|
# 不含 windows:internal/store 对 syscall.Umask 等 Unix API 有硬依赖(备份权限加固)。
|
||||||
|
TARGETS=(
|
||||||
|
linux/amd64
|
||||||
|
linux/arm64
|
||||||
|
darwin/amd64
|
||||||
|
darwin/arm64
|
||||||
|
)
|
||||||
|
|
||||||
|
rm -rf dist
|
||||||
|
mkdir -p dist
|
||||||
|
|
||||||
|
for t in "${TARGETS[@]}"; do
|
||||||
|
os="${t%/*}" arch="${t#*/}"
|
||||||
|
name="pn-${VERSION}-${os}-${arch}"
|
||||||
|
echo "==> 构建 $name"
|
||||||
|
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" \
|
||||||
|
go build -trimpath -ldflags="$LDFLAGS" -o "dist/$name" ./cmd/pn
|
||||||
|
# tar.gz 保留可执行位
|
||||||
|
(cd dist && tar czf "$name.tar.gz" "$name" && rm "$name")
|
||||||
|
done
|
||||||
|
|
||||||
|
# 生成校验和(Linux 为 sha256sum,macOS 回退 shasum)
|
||||||
|
echo "==> 生成 SHA256SUMS"
|
||||||
|
(
|
||||||
|
cd dist
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
sha256sum -- ./* > SHA256SUMS
|
||||||
|
else
|
||||||
|
shasum -a 256 -- ./* > SHA256SUMS
|
||||||
|
fi
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "==> 完成:dist/"
|
||||||
|
ls -lh dist/
|
||||||
+2
-1
@@ -2,7 +2,8 @@
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<!-- favicon 随站点 Logo(服务端注入);dev 环境由前端 features/site 兜底替换 -->
|
||||||
|
<link rel="icon" href="{{.Icon}}" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<!--
|
<!--
|
||||||
以下占位符由服务端 internal/webui 以 html/template 渲染并自动转义(§8.3-4)。
|
以下占位符由服务端 internal/webui 以 html/template 渲染并自动转义(§8.3-4)。
|
||||||
|
|||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
import { lazy, Suspense, useEffect, useState } from 'react'
|
import { lazy, Suspense, useEffect, useState } from 'react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { Navigate, Route, Routes } from 'react-router'
|
import { Navigate, Route, Routes } from 'react-router'
|
||||||
import { App as AntdApp, ConfigProvider } from 'antd'
|
import { App as AntdApp, ConfigProvider, Spin } from 'antd'
|
||||||
import zhCN from 'antd/locale/zh_CN'
|
import zhCN from 'antd/locale/zh_CN'
|
||||||
import { useAuth } from './lib/auth'
|
import { useAuth } from './lib/auth'
|
||||||
import { useTheme } from './lib/theme'
|
import { useTheme } from './lib/theme'
|
||||||
@@ -45,7 +45,7 @@ function AntdProvider({ children }: { children: ReactNode }) {
|
|||||||
function RequireAuth({ children }: { children: ReactNode }) {
|
function RequireAuth({ children }: { children: ReactNode }) {
|
||||||
const { authenticated, loading } = useAuth()
|
const { authenticated, loading } = useAuth()
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="pn-loading">加载中…</div>
|
return <div className="pn-loading"><Spin size="large" /></div>
|
||||||
}
|
}
|
||||||
if (!authenticated) {
|
if (!authenticated) {
|
||||||
return <Navigate to="/admin/login" replace />
|
return <Navigate to="/admin/login" replace />
|
||||||
@@ -56,7 +56,7 @@ function RequireAuth({ children }: { children: ReactNode }) {
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<AntdProvider>
|
<AntdProvider>
|
||||||
<Suspense fallback={<div className="pn-loading">加载中…</div>}>
|
<Suspense fallback={<div className="pn-loading"><Spin size="large" /></div>}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<BlogLayout />}>
|
<Route element={<BlogLayout />}>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router'
|
|||||||
import {
|
import {
|
||||||
Moon, Sun, Monitor, ExternalLink, House, PenLine, MoreHorizontal, Tags, Trash2, Settings, LogOut,
|
Moon, Sun, Monitor, ExternalLink, House, PenLine, MoreHorizontal, Tags, Trash2, Settings, LogOut,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Dropdown, Tooltip } from 'antd'
|
import { Button, Dropdown, Menu, Tooltip } from 'antd'
|
||||||
import type { MenuProps } from 'antd'
|
import type { MenuProps } from 'antd'
|
||||||
import { useSiteTitle } from '../features/site'
|
import { useSiteTitle } from '../features/site'
|
||||||
import { useTheme } from '../lib/theme'
|
import { useTheme } from '../lib/theme'
|
||||||
@@ -20,9 +20,7 @@ function MoreMenu({ triggerClassName, triggerLabel, items }: {
|
|||||||
// 曾致桌面导航整体横移 4px)
|
// 曾致桌面导航整体横移 4px)
|
||||||
<div className="pn-moremenu">
|
<div className="pn-moremenu">
|
||||||
<Dropdown trigger={['click']} placement="bottomRight" menu={{ items }}>
|
<Dropdown trigger={['click']} placement="bottomRight" menu={{ items }}>
|
||||||
<button type="button" className={triggerClassName} aria-label={triggerLabel}>
|
<Button type="text" className={triggerClassName} aria-label={triggerLabel} icon={<MoreHorizontal size={15} />} />
|
||||||
<MoreHorizontal size={15} />
|
|
||||||
</button>
|
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -49,27 +47,30 @@ function ThemeButton({ className = 'pn-site-navlink' }: { className?: string })
|
|||||||
const next = mode === 'light' ? 'dark' : mode === 'dark' ? 'system' : 'light'
|
const next = mode === 'light' ? 'dark' : mode === 'dark' ? 'system' : 'light'
|
||||||
const title = `主题:${mode === 'light' ? '浅色' : mode === 'dark' ? '深色' : '跟随系统'}(点击切换)`
|
const title = `主题:${mode === 'light' ? '浅色' : mode === 'dark' ? '深色' : '跟随系统'}(点击切换)`
|
||||||
return (
|
return (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="text"
|
||||||
className={className}
|
className={className}
|
||||||
title={title}
|
title={title}
|
||||||
aria-label={title}
|
aria-label={title}
|
||||||
|
icon={mode === 'light' ? <Sun size={14} /> : mode === 'dark' ? <Moon size={14} /> : <Monitor size={14} />}
|
||||||
onClick={() => setMode(next)}
|
onClick={() => setMode(next)}
|
||||||
>
|
/>
|
||||||
{mode === 'light' ? <Sun size={14} /> : mode === 'dark' ? <Moon size={14} /> : <Monitor size={14} />}
|
|
||||||
</button>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BlogLayout() {
|
export function BlogLayout() {
|
||||||
const { data } = useSiteTitle()
|
const { data } = useSiteTitle()
|
||||||
const title = data?.site_title ?? 'Pure Note'
|
const title = data?.site_title ?? 'Pure Note'
|
||||||
|
const logo = data?.site_logo ?? ''
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
return (
|
return (
|
||||||
<div className="pn-site-page">
|
<div className="pn-site-page">
|
||||||
<header className="pn-site-header">
|
<header className="pn-site-header">
|
||||||
<div className="pn-site-header-inner">
|
<div className="pn-site-header-inner">
|
||||||
<Link to="/" className="pn-site-wordmark">{title}</Link>
|
<Link to="/" className="pn-site-wordmark">
|
||||||
|
{logo && <img className="pn-site-logo" src={logo} alt="" aria-hidden />}
|
||||||
|
{title}
|
||||||
|
</Link>
|
||||||
<nav className="pn-site-nav" aria-label="站点导航">
|
<nav className="pn-site-nav" aria-label="站点导航">
|
||||||
<NavLink to="/" end className="pn-site-navlink hide-sm">
|
<NavLink to="/" end className="pn-site-navlink hide-sm">
|
||||||
<House size={14} />首页
|
<House size={14} />首页
|
||||||
@@ -128,14 +129,13 @@ function AdminThemeButton() {
|
|||||||
const label = `主题:${mode === 'light' ? '浅色' : mode === 'dark' ? '深色' : '跟随系统'}(点击切换)`
|
const label = `主题:${mode === 'light' ? '浅色' : mode === 'dark' ? '深色' : '跟随系统'}(点击切换)`
|
||||||
return (
|
return (
|
||||||
<Tooltip title={label} placement="bottom">
|
<Tooltip title={label} placement="bottom">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="text"
|
||||||
className="pn-admin-iconbtn pn-admin-desktop-act"
|
className="pn-admin-iconbtn pn-admin-desktop-act"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
|
icon={mode === 'light' ? <Sun size={14} /> : mode === 'dark' ? <Moon size={14} /> : <Monitor size={14} />}
|
||||||
onClick={() => setMode(next)}
|
onClick={() => setMode(next)}
|
||||||
>
|
/>
|
||||||
{mode === 'light' ? <Sun size={14} /> : mode === 'dark' ? <Moon size={14} /> : <Monitor size={14} />}
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -192,18 +192,34 @@ export function AdminTopbar({ title, children }: { title: string; children?: Rea
|
|||||||
}
|
}
|
||||||
|
|
||||||
const adminNav = [
|
const adminNav = [
|
||||||
{ to: '/admin', label: '笔记管理', icon: House, end: true, match: 'exact' as const },
|
|
||||||
{ to: '/admin/notes/new', label: '新建笔记', icon: PenLine, end: false, match: 'prefix' as const },
|
{ to: '/admin/notes/new', label: '新建笔记', icon: PenLine, end: false, match: 'prefix' as const },
|
||||||
|
{ to: '/admin', label: '笔记管理', icon: House, end: true, match: 'exact' as const },
|
||||||
{ to: '/admin/trash', label: '回收站', icon: Trash2, end: true, match: 'exact' as const },
|
{ to: '/admin/trash', label: '回收站', icon: Trash2, end: true, match: 'exact' as const },
|
||||||
{ to: '/admin/settings', label: '设置', icon: Settings, end: true, match: 'exact' as const },
|
{ to: '/admin/settings', label: '设置', icon: Settings, end: true, match: 'exact' as const },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const adminMenuPathByKey: Record<string, string> = {
|
||||||
|
notes: '/admin',
|
||||||
|
new: '/admin/notes/new',
|
||||||
|
trash: '/admin/trash',
|
||||||
|
settings: '/admin/settings',
|
||||||
|
}
|
||||||
|
|
||||||
function AdminSidebar() {
|
function AdminSidebar() {
|
||||||
const { logout } = useAuth()
|
const { logout } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
// 新建/编辑共用「新建笔记」高亮(设计稿:编辑页高亮新建入口)
|
// 新建/编辑共用「新建笔记」高亮(设计稿:编辑页高亮新建入口)
|
||||||
const editing = location.pathname.startsWith('/admin/notes')
|
const editing = location.pathname.startsWith('/admin/notes')
|
||||||
|
const selectedKey = editing
|
||||||
|
? 'new'
|
||||||
|
: location.pathname === '/admin'
|
||||||
|
? 'notes'
|
||||||
|
: location.pathname.startsWith('/admin/trash')
|
||||||
|
? 'trash'
|
||||||
|
: location.pathname.startsWith('/admin/settings')
|
||||||
|
? 'settings'
|
||||||
|
: ''
|
||||||
return (
|
return (
|
||||||
<aside className="pn-admin-sidebar">
|
<aside className="pn-admin-sidebar">
|
||||||
<Link to="/admin" className="pn-admin-brand">
|
<Link to="/admin" className="pn-admin-brand">
|
||||||
@@ -214,54 +230,59 @@ function AdminSidebar() {
|
|||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="pn-admin-section">内容</div>
|
<Menu
|
||||||
<nav className="pn-admin-nav" aria-label="内容">
|
mode="inline"
|
||||||
<NavLink to="/admin" end className="pn-admin-item">
|
className="pn-admin-menu"
|
||||||
<House size={15} /><span>笔记管理</span>
|
selectedKeys={selectedKey ? [selectedKey] : []}
|
||||||
</NavLink>
|
onClick={({ key }) => navigate(adminMenuPathByKey[key])}
|
||||||
<Link to="/admin/notes/new" className={`pn-admin-item${editing ? ' active' : ''}`} aria-current={editing ? 'page' : undefined}>
|
items={[
|
||||||
<PenLine size={15} /><span>新建笔记</span>
|
{
|
||||||
</Link>
|
key: 'g-content',
|
||||||
</nav>
|
type: 'group',
|
||||||
|
label: '内容',
|
||||||
<div className="pn-admin-section">系统</div>
|
children: [
|
||||||
<nav className="pn-admin-nav" aria-label="系统">
|
{ key: 'new', icon: <PenLine size={15} />, label: '新建笔记' },
|
||||||
<NavLink to="/admin/trash" className="pn-admin-item">
|
{ key: 'notes', icon: <House size={15} />, label: '笔记管理' },
|
||||||
<Trash2 size={15} /><span>回收站</span>
|
],
|
||||||
</NavLink>
|
},
|
||||||
<NavLink to="/admin/settings" className="pn-admin-item">
|
{
|
||||||
<Settings size={15} /><span>设置</span>
|
key: 'g-system',
|
||||||
</NavLink>
|
type: 'group',
|
||||||
</nav>
|
label: '系统',
|
||||||
|
children: [
|
||||||
|
{ key: 'trash', icon: <Trash2 size={15} />, label: '回收站' },
|
||||||
|
{ key: 'settings', icon: <Settings size={15} />, label: '设置' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="pn-admin-foot">
|
<div className="pn-admin-foot">
|
||||||
<div className="pn-admin-user">
|
<div className="pn-admin-user">
|
||||||
<span className="pn-admin-avatar">管</span>
|
<span className="pn-admin-avatar">管</span>
|
||||||
<span className="pn-admin-user-meta">
|
<span className="pn-admin-user-meta">
|
||||||
<span className="pn-admin-user-name">管理员</span>
|
<span className="pn-admin-user-name">管理员</span>
|
||||||
<span className="pn-admin-user-role">站点所有者</span>
|
|
||||||
</span>
|
</span>
|
||||||
<span className="pn-admin-user-actions">
|
<span className="pn-admin-user-actions">
|
||||||
<Tooltip title="返回博客" placement="bottom">
|
<Tooltip title="返回博客" placement="bottom">
|
||||||
<a
|
<Button
|
||||||
|
type="text"
|
||||||
|
className="pn-admin-iconbtn"
|
||||||
href="/"
|
href="/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="pn-admin-iconbtn"
|
|
||||||
aria-label="返回博客(新窗口打开)"
|
aria-label="返回博客(新窗口打开)"
|
||||||
>
|
icon={<ExternalLink size={15} />}
|
||||||
<ExternalLink size={15} />
|
/>
|
||||||
</a>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title="退出登录" placement="bottom">
|
<Tooltip title="退出登录" placement="bottom">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="text"
|
||||||
className="pn-admin-iconbtn"
|
className="pn-admin-iconbtn"
|
||||||
aria-label="退出登录"
|
aria-label="退出登录"
|
||||||
|
icon={<LogOut size={15} />}
|
||||||
onClick={() => void logout().then(() => navigate('/'))}
|
onClick={() => void logout().then(() => navigate('/'))}
|
||||||
>
|
/>
|
||||||
<LogOut size={15} />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { memo } from 'react'
|
import { memo, useState } from 'react'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import rehypeHighlight from 'rehype-highlight'
|
import rehypeHighlight from 'rehype-highlight'
|
||||||
|
import { Button } from 'antd'
|
||||||
import { sanitizePlugin } from '../lib/sanitize'
|
import { sanitizePlugin } from '../lib/sanitize'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,18 +26,29 @@ function CodeBlock(props: React.HTMLAttributes<HTMLPreElement>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function CopyButton() {
|
function CopyButton() {
|
||||||
// 复制按钮在点击时读取最近兄弟 <pre> 的文本;不注入任何 HTML
|
// 复制按钮在点击时读取最近兄弟 <pre> 的文本;不注入任何 HTML。
|
||||||
|
// MarkdownViewer 会在无 App 上下文的单测中渲染,反馈用本地状态而非 message。
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
return (
|
return (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
size="small"
|
||||||
className="pn-copy-btn"
|
className={copied ? 'pn-copy-btn is-copied' : 'pn-copy-btn'}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
const pre = (e.currentTarget.parentElement as HTMLElement).querySelector('pre')
|
const pre = (e.currentTarget.parentElement as HTMLElement).querySelector('pre')
|
||||||
if (pre) void navigator.clipboard.writeText(pre.textContent ?? '')
|
const text = pre?.textContent ?? ''
|
||||||
|
if (!text) return
|
||||||
|
try {
|
||||||
|
void navigator.clipboard.writeText(text).then(() => {
|
||||||
|
setCopied(true)
|
||||||
|
window.setTimeout(() => setCopied(false), 1600)
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
/* clipboard 不可用(非安全上下文等)时静默 */
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
复制
|
{copied ? '已复制' : '复制'}
|
||||||
</button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,20 @@ import { api } from '../lib/api'
|
|||||||
import type { SiteSettings } from '../lib/api'
|
import type { SiteSettings } from '../lib/api'
|
||||||
|
|
||||||
export function useSiteTitle() {
|
export function useSiteTitle() {
|
||||||
return useQuery({
|
const { data } = useQuery({
|
||||||
queryKey: ['site'],
|
queryKey: ['site'],
|
||||||
queryFn: () => api<SiteSettings>('/api/site'),
|
queryFn: () => api<SiteSettings>('/api/site'),
|
||||||
staleTime: 5 * 60_000,
|
staleTime: 5 * 60_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// favicon 随站点 Logo:生产由服务端注入 index.html,此处兜底(dev 模板占位符
|
||||||
|
// 不会被渲染)并保证设置变更后 SPA 内即时生效
|
||||||
|
useEffect(() => {
|
||||||
|
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]')
|
||||||
|
if (link) link.href = data?.site_logo || '/favicon.svg'
|
||||||
|
}, [data?.site_logo])
|
||||||
|
|
||||||
|
return { data }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+195
-301
@@ -213,17 +213,6 @@ input.ant-input::placeholder,
|
|||||||
textarea.ant-input::placeholder {
|
textarea.ant-input::placeholder {
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
/* antd 按钮的 danger-outline 形态(默认描边 + 危险色文字) */
|
|
||||||
button.ant-btn.pn-btn-danger-outline {
|
|
||||||
color: var(--destructive-text);
|
|
||||||
border-color: var(--destructive-border);
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
button.ant-btn.pn-btn-danger-outline:hover:not(:disabled) {
|
|
||||||
color: var(--destructive-text) !important;
|
|
||||||
border-color: var(--destructive-border) !important;
|
|
||||||
background: var(--destructive-subtle) !important;
|
|
||||||
}
|
|
||||||
/* Dropdown 内路由项:激活态与旧 .pn-moremenu-item.active 一致 */
|
/* Dropdown 内路由项:激活态与旧 .pn-moremenu-item.active 一致 */
|
||||||
.pn-dd-link {
|
.pn-dd-link {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -242,16 +231,16 @@ li.ant-dropdown-menu-item.pn-dd-item-active span {
|
|||||||
li.ant-dropdown-menu-item.pn-dd-item-active svg {
|
li.ant-dropdown-menu-item.pn-dd-item-active svg {
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
/* 标签云(antd CheckableTag):计数用等宽字体,选中态反白 */
|
/* 标签云(antd CheckableTagGroup 单选):计数用等宽字体,选中态反白。
|
||||||
.pn-cloud-tag {
|
antd 会把 label 包进单个块级 span,行内排版由内层选择器承接 */
|
||||||
display: inline-flex;
|
.pn-cloud-tag > span {
|
||||||
}
|
|
||||||
.pn-cloud-tag a {
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
color: inherit;
|
}
|
||||||
text-decoration: none;
|
.pn-cloud-tag svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
}
|
}
|
||||||
.pn-cloud-count {
|
.pn-cloud-count {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
@@ -358,25 +347,7 @@ button.pn-login-back:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* 表格卡空态(无数据/加载中占位) */
|
/* 加载/空态(Spin/Skeleton/Empty)已由 antd 承接 */
|
||||||
.pn-empty {
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
min-height: 132px;
|
|
||||||
padding: 12px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
background: var(--muted);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 状态点 ── */
|
|
||||||
.dot {
|
|
||||||
width: 7px;
|
|
||||||
height: 7px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: currentColor;
|
|
||||||
flex: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
博客端共享 shell —— 居中单栏 720px(设计稿博客系列)
|
博客端共享 shell —— 居中单栏 720px(设计稿博客系列)
|
||||||
@@ -408,6 +379,9 @@ button.pn-login-back:hover {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.pn-site-wordmark {
|
.pn-site-wordmark {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
font-family: var(--font-serif);
|
font-family: var(--font-serif);
|
||||||
font-size: 21px;
|
font-size: 21px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
@@ -419,6 +393,14 @@ button.pn-login-back:hover {
|
|||||||
.pn-site-wordmark:hover {
|
.pn-site-wordmark:hover {
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
|
/* 站点 Logo(站点设置自定义,留空不渲染) */
|
||||||
|
.pn-site-logo {
|
||||||
|
flex: none;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
.pn-site-nav {
|
.pn-site-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -452,9 +434,25 @@ button.pn-login-back:hover {
|
|||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
}
|
}
|
||||||
|
/* 同类的 antd Button 形态(主题切换按钮/溢出菜单触发器):
|
||||||
|
antd cssinjs 无 layer,需更高特异度对齐导航 pill 规格 */
|
||||||
|
button.ant-btn.pn-site-navlink {
|
||||||
|
height: auto;
|
||||||
|
padding: 7px 12px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
button.ant-btn.pn-site-navlink:hover {
|
||||||
|
background: var(--muted);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
/* 博客端溢出菜单触发按钮:<640px 显示(基础规则须在 640px MQ 之前,
|
/* 博客端溢出菜单触发按钮:<640px 显示(基础规则须在 640px MQ 之前,
|
||||||
否则同特异性下源码顺序靠后者覆盖 MQ 内的 display:inline-flex) */
|
否则同特异性下源码顺序靠后者覆盖 MQ 内的 display:inline-flex) */
|
||||||
.pn-site-menu-btn {
|
button.ant-btn.pn-site-menu-btn {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.pn-site-main {
|
.pn-site-main {
|
||||||
@@ -515,7 +513,7 @@ button.pn-login-back:hover {
|
|||||||
.pn-site-navlink.hide-sm {
|
.pn-site-navlink.hide-sm {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.pn-site-menu-btn {
|
button.ant-btn.pn-site-menu-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -527,16 +525,36 @@ button.pn-login-back:hover {
|
|||||||
padding-top: 56px;
|
padding-top: 56px;
|
||||||
padding-bottom: 24px;
|
padding-bottom: 24px;
|
||||||
}
|
}
|
||||||
.pn-home-filter {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
.pn-home-post {
|
.pn-home-post {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 18px;
|
||||||
padding: 30px 0;
|
padding: 30px 0;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.pn-home-post:last-child {
|
.pn-home-post:last-child {
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
}
|
}
|
||||||
|
/* 封面缩略图(正文第一张图,无图不渲染) */
|
||||||
|
.pn-home-cover {
|
||||||
|
flex: none;
|
||||||
|
width: 104px;
|
||||||
|
height: 104px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
.pn-home-cover img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.pn-home-post-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
.pn-home-date {
|
.pn-home-date {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
@@ -614,54 +632,40 @@ button.pn-login-back:hover {
|
|||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
/* 首页分页(对应设计稿 table-foot + pager) */
|
/* 首页分页(对应设计稿 table-foot + pager)。
|
||||||
|
总数说明由 Pagination showTotal 承接:total 文本居左、页码居右 */
|
||||||
.pn-home-pager {
|
.pn-home-pager {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: flex-end;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-top: 40px;
|
margin-top: 40px;
|
||||||
padding-top: 20px;
|
padding-top: 20px;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.pn-home-pager-note {
|
.pn-home-pager ul.ant-pagination {
|
||||||
font-size: 12.5px;
|
flex: 1;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.pn-home-pager .ant-pagination-total-text {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
/* 首页标签筛选条:链接型分段导航(配色走无 layer 类规则,
|
|
||||||
避免 antd 的 <a> 重置压过 utilities 层) */
|
|
||||||
.pn-home-filter a {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 32px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border-radius: calc(var(--radius-md) - 3px);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
white-space: nowrap;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
transition: color 0.15s ease;
|
|
||||||
}
|
|
||||||
.pn-home-filter a:hover {
|
|
||||||
color: var(--foreground);
|
|
||||||
}
|
|
||||||
.pn-home-filter a[aria-current='page'] {
|
|
||||||
background: var(--primary);
|
|
||||||
color: var(--primary-foreground);
|
|
||||||
}
|
|
||||||
.pn-home-filter a:focus-visible {
|
|
||||||
outline: 2px solid var(--primary);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
/* 首页分页器已由 antd Pagination 承接(.pn-home-pager 仅保留布局行) */
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.pn-home-content {
|
.pn-home-content {
|
||||||
padding-top: 40px;
|
padding-top: 40px;
|
||||||
}
|
}
|
||||||
.pn-home-post {
|
.pn-home-post {
|
||||||
padding: 26px 0;
|
padding: 26px 0;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.pn-home-cover {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
.pn-home-post-title {
|
.pn-home-post-title {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
@@ -946,29 +950,23 @@ button.pn-note-back:hover {
|
|||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
/* 代码块复制按钮 */
|
/* 代码块复制按钮(antd Button size=small;悬浮于所属包装 div 时显示) */
|
||||||
.pn-copy-btn {
|
.pn-note-body div:has(> .pn-copy-btn) {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.pn-note-body .pn-copy-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 8px;
|
right: 8px;
|
||||||
top: 8px;
|
top: 8px;
|
||||||
padding: 2px 10px;
|
z-index: 1;
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--card);
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.6;
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.15s ease, color 0.15s ease;
|
transition: opacity 0.15s ease;
|
||||||
}
|
}
|
||||||
.pn-note-body pre:hover .pn-copy-btn,
|
.pn-note-body div:has(> .pn-copy-btn):hover .pn-copy-btn,
|
||||||
.pn-copy-btn:focus-visible {
|
.pn-note-body .pn-copy-btn:focus-visible,
|
||||||
|
.pn-note-body .pn-copy-btn.is-copied {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
.pn-copy-btn:hover {
|
|
||||||
color: var(--foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
标签归档(标签归档.html)
|
标签归档(标签归档.html)
|
||||||
@@ -998,6 +996,9 @@ button.pn-note-back:hover {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
.pn-tag-empty {
|
||||||
|
margin-top: 32px;
|
||||||
|
}
|
||||||
.pn-tag-groups {
|
.pn-tag-groups {
|
||||||
margin-top: 48px;
|
margin-top: 48px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1184,19 +1185,6 @@ button.pn-note-back:hover {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
.pn-login-sub {
|
|
||||||
margin: 0 0 20px;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
}
|
|
||||||
.pn-login-hint {
|
|
||||||
margin: 16px 0 0;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
}
|
|
||||||
/* 登录卡片(antd Card):内边距由本类承接(body padding 已置 0) */
|
/* 登录卡片(antd Card):内边距由本类承接(body padding 已置 0) */
|
||||||
.pn-login-card {
|
.pn-login-card {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -1219,7 +1207,7 @@ button.pn-note-back:hover {
|
|||||||
============================================================ */
|
============================================================ */
|
||||||
.pn-admin-shell {
|
.pn-admin-shell {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 256px minmax(0, 1fr);
|
grid-template-columns: 208px minmax(0, 1fr);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
@@ -1287,59 +1275,48 @@ button.pn-note-back:hover {
|
|||||||
letter-spacing: var(--tracking-tight);
|
letter-spacing: var(--tracking-tight);
|
||||||
color: var(--sidebar-foreground);
|
color: var(--sidebar-foreground);
|
||||||
}
|
}
|
||||||
.pn-admin-section {
|
/* 侧栏导航(antd Menu inline):融入 sidebar 底色,沿用窄侧栏密度 */
|
||||||
padding: 0 10px;
|
.pn-admin-menu {
|
||||||
margin-bottom: -6px;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
background: transparent;
|
||||||
|
border-inline-end: 0 !important;
|
||||||
|
}
|
||||||
|
.pn-admin-menu .ant-menu-item-group-title {
|
||||||
|
padding: 8px 10px 4px;
|
||||||
font-size: 10.5px;
|
font-size: 10.5px;
|
||||||
|
line-height: 1;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
.pn-admin-nav {
|
.pn-admin-menu .ant-menu-item {
|
||||||
display: flex;
|
margin-inline: 6px;
|
||||||
flex-direction: column;
|
width: auto;
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
.pn-admin-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 34px;
|
min-height: 34px;
|
||||||
padding: 0 10px;
|
line-height: 34px;
|
||||||
border: 0;
|
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: transparent;
|
|
||||||
color: var(--sidebar-foreground);
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-align: left;
|
color: var(--sidebar-foreground);
|
||||||
text-decoration: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.15s ease, color 0.15s ease;
|
|
||||||
}
|
}
|
||||||
.pn-admin-item:hover {
|
.pn-admin-menu .ant-menu-item svg {
|
||||||
background: var(--sidebar-accent);
|
|
||||||
color: var(--sidebar-accent-foreground);
|
|
||||||
}
|
|
||||||
.pn-admin-item.active {
|
|
||||||
background: var(--sidebar-primary);
|
|
||||||
color: var(--sidebar-primary-foreground);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.pn-admin-item svg {
|
|
||||||
width: 15px;
|
width: 15px;
|
||||||
height: 15px;
|
height: 15px;
|
||||||
flex: none;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
transition: color 0.15s ease;
|
|
||||||
}
|
}
|
||||||
.pn-admin-item:hover svg {
|
/* 选中项沿用侧栏 token 的黑底反白(colorPrimary 近黑/近白,antd 默认的
|
||||||
color: var(--sidebar-accent-foreground);
|
primary 淡色底在明暗模式下都与侧栏底色拉不开) */
|
||||||
}
|
.pn-admin-menu li.ant-menu-item-selected {
|
||||||
.pn-admin-item.active svg {
|
background: var(--sidebar-primary);
|
||||||
color: var(--sidebar-primary-foreground);
|
color: var(--sidebar-primary-foreground);
|
||||||
}
|
}
|
||||||
|
.pn-admin-menu li.ant-menu-item-selected svg {
|
||||||
|
color: var(--sidebar-primary-foreground);
|
||||||
|
}
|
||||||
|
.pn-admin-menu li.ant-menu-item:hover:not(.ant-menu-item-selected) {
|
||||||
|
background: var(--sidebar-accent);
|
||||||
|
color: var(--sidebar-accent-foreground);
|
||||||
|
}
|
||||||
.pn-admin-foot {
|
.pn-admin-foot {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1360,32 +1337,23 @@ button.pn-note-back:hover {
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
.pn-admin-iconbtn {
|
/* 侧栏/topbar 图标按钮(antd Button type=text):28px 方形、muted 前景,
|
||||||
position: relative;
|
hover 沿用 sidebar accent;antd cssinjs 无 layer,需提权 */
|
||||||
display: inline-flex;
|
button.ant-btn.pn-admin-iconbtn,
|
||||||
align-items: center;
|
a.ant-btn.pn-admin-iconbtn {
|
||||||
justify-content: center;
|
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: transparent;
|
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
text-decoration: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.15s ease, color 0.15s ease;
|
|
||||||
}
|
}
|
||||||
.pn-admin-iconbtn:hover {
|
button.ant-btn.pn-admin-iconbtn:hover:not(:disabled),
|
||||||
|
a.ant-btn.pn-admin-iconbtn:hover {
|
||||||
background: var(--sidebar-accent);
|
background: var(--sidebar-accent);
|
||||||
color: var(--sidebar-accent-foreground);
|
color: var(--sidebar-accent-foreground);
|
||||||
}
|
}
|
||||||
.pn-admin-iconbtn svg {
|
|
||||||
width: 15px;
|
|
||||||
height: 15px;
|
|
||||||
}
|
|
||||||
/* 悬浮提示由 antd Tooltip 承接;侧栏隐藏时的移动端兜底按钮(<768px 显示) */
|
/* 悬浮提示由 antd Tooltip 承接;侧栏隐藏时的移动端兜底按钮(<768px 显示) */
|
||||||
.pn-admin-mobile-act {
|
button.ant-btn.pn-admin-mobile-act {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
/* 溢出菜单挂载点(面板由 antd Dropdown 渲染,wrapper 仅保留占位语义) */
|
/* 溢出菜单挂载点(面板由 antd Dropdown 渲染,wrapper 仅保留占位语义) */
|
||||||
@@ -1419,10 +1387,6 @@ button.pn-note-back:hover {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--sidebar-foreground);
|
color: var(--sidebar-foreground);
|
||||||
}
|
}
|
||||||
.pn-admin-user-role {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
}
|
|
||||||
/* 主区 / topbar */
|
/* 主区 / topbar */
|
||||||
.pn-admin-main {
|
.pn-admin-main {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -1438,6 +1402,9 @@ button.pn-note-back:hover {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 14px 24px;
|
padding: 14px 24px;
|
||||||
|
/* 各页 actions 高度不一(有的页无按钮):固定高度,切换页面不抖动 */
|
||||||
|
min-height: 62px;
|
||||||
|
box-sizing: border-box;
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
@@ -1461,39 +1428,15 @@ button.pn-note-back:hover {
|
|||||||
gap: 20px;
|
gap: 20px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
/* 搜索框(topbar 内) */
|
/* 页面级加载占位(antd Spin 居中) */
|
||||||
.pn-admin-search {
|
.pn-admin-loading {
|
||||||
display: flex;
|
display: grid;
|
||||||
align-items: center;
|
place-items: center;
|
||||||
gap: 8px;
|
min-height: 320px;
|
||||||
height: 34px;
|
}
|
||||||
|
/* 搜索框(topbar 内,antd Input + 前缀图标;<768px 隐藏) */
|
||||||
|
.pn-admin-actions .pn-al-search {
|
||||||
width: 248px;
|
width: 248px;
|
||||||
padding: 0 10px;
|
|
||||||
border: 1px solid var(--input);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--card);
|
|
||||||
transition: border-color 0.15s ease;
|
|
||||||
}
|
|
||||||
.pn-admin-search:focus-within {
|
|
||||||
border-color: var(--ring);
|
|
||||||
outline: 2px solid var(--ring);
|
|
||||||
outline-offset: 1px;
|
|
||||||
}
|
|
||||||
.pn-admin-search svg {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
flex: none;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
}
|
|
||||||
.pn-admin-search input {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
outline: 0;
|
|
||||||
background: transparent;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--foreground);
|
|
||||||
}
|
}
|
||||||
/* 移动端 tabbar(<768px) */
|
/* 移动端 tabbar(<768px) */
|
||||||
.pn-admin-tabbar {
|
.pn-admin-tabbar {
|
||||||
@@ -1502,7 +1445,7 @@ button.pn-note-back:hover {
|
|||||||
/* 响应式 */
|
/* 响应式 */
|
||||||
@media (max-width: 1023px) {
|
@media (max-width: 1023px) {
|
||||||
.pn-admin-shell {
|
.pn-admin-shell {
|
||||||
grid-template-columns: 224px minmax(0, 1fr);
|
grid-template-columns: 184px minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
.pn-admin-content {
|
.pn-admin-content {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
@@ -1517,6 +1460,7 @@ button.pn-note-back:hover {
|
|||||||
}
|
}
|
||||||
.pn-admin-topbar {
|
.pn-admin-topbar {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
|
min-height: 58px;
|
||||||
}
|
}
|
||||||
.pn-admin-topbar h1 {
|
.pn-admin-topbar h1 {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
@@ -1533,13 +1477,13 @@ button.pn-note-back:hover {
|
|||||||
padding-left: 10px;
|
padding-left: 10px;
|
||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
}
|
}
|
||||||
.pn-admin-mobile-act {
|
button.ant-btn.pn-admin-mobile-act {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
.pn-admin-desktop-act {
|
button.ant-btn.pn-admin-desktop-act {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.pn-admin-search {
|
.pn-admin-actions .pn-al-search {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.pn-admin-content {
|
.pn-admin-content {
|
||||||
@@ -1601,11 +1545,14 @@ button.pn-note-back:hover {
|
|||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.pn-al-tab-num {
|
/* Segmented 选项内计数徽标(antd Badge count,弱化为 muted 灰) */
|
||||||
|
.pn-al-tab-badge {
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
.pn-al-tab-badge .ant-badge-count {
|
||||||
|
background: var(--muted-foreground);
|
||||||
|
color: var(--background);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
|
||||||
margin-left: 5px;
|
|
||||||
opacity: 0.65;
|
|
||||||
}
|
}
|
||||||
.pn-al-table-note {
|
.pn-al-table-note {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -1653,27 +1600,6 @@ button.pn-note-back:hover {
|
|||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.pn-al-status {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 7px;
|
|
||||||
font-size: 12.5px;
|
|
||||||
font-weight: 500;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.pn-al-status.pn-al-is-public {
|
|
||||||
color: var(--success-text);
|
|
||||||
}
|
|
||||||
.pn-al-status.pn-al-is-public .dot {
|
|
||||||
background: var(--success);
|
|
||||||
}
|
|
||||||
.pn-al-status.pn-al-is-draft {
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
}
|
|
||||||
.pn-al-status.pn-al-is-draft .dot {
|
|
||||||
background: var(--muted-foreground);
|
|
||||||
opacity: 0.55;
|
|
||||||
}
|
|
||||||
.pn-al-actions-cell {
|
.pn-al-actions-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1721,19 +1647,16 @@ button.pn-note-back:hover {
|
|||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
/* 编辑卡与发布卡的 Card 基础样式由 ui/card 承接(gap-0/p-6 等) */
|
/* 编辑卡与发布卡的 Card 基础样式由 ui/card 承接(gap-0/p-6 等) */
|
||||||
/* 提权至 (0,1,1)+:压过 antd .ant-input 的同名字段(cssinjs 无 layer 且后注入) */
|
/* 提权至 (0,1,1)+:压过 antd .ant-input 的同名字段(cssinjs 无 layer 且后注入)。
|
||||||
|
variant=filled:底色/圆角/内边距交给 antd,这里只覆盖字体排版 */
|
||||||
input.pn-ae-title-input {
|
input.pn-ae-title-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0 0 14px;
|
margin: 0 0 14px;
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
font-family: var(--font-serif);
|
font-family: var(--font-serif);
|
||||||
font-size: 30px;
|
font-size: 30px;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
letter-spacing: var(--tracking-tight);
|
letter-spacing: var(--tracking-tight);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
outline: none;
|
|
||||||
}
|
}
|
||||||
input.pn-ae-title-input::placeholder {
|
input.pn-ae-title-input::placeholder {
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
@@ -1772,58 +1695,10 @@ input.pn-ae-title-input::placeholder {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
.pn-ae-tag-x {
|
/* 标签选择器(mode=tags:既可勾选既有标签,也可输入新建) */
|
||||||
display: inline-flex;
|
.pn-ae-tags-select {
|
||||||
align-items: center;
|
min-width: 220px;
|
||||||
justify-content: center;
|
max-width: 100%;
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.15s ease, color 0.15s ease;
|
|
||||||
}
|
|
||||||
.pn-ae-tag-x:hover {
|
|
||||||
background: var(--muted);
|
|
||||||
color: var(--foreground);
|
|
||||||
}
|
|
||||||
.pn-ae-tag-x svg {
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
}
|
|
||||||
button.pn-ae-add-tag {
|
|
||||||
height: 26px;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 0 10px;
|
|
||||||
border: 1px dashed var(--border-strong);
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
button.pn-ae-add-tag:hover {
|
|
||||||
background: var(--muted);
|
|
||||||
color: var(--foreground);
|
|
||||||
}
|
|
||||||
button.pn-ae-add-tag svg {
|
|
||||||
width: 13px;
|
|
||||||
height: 13px;
|
|
||||||
}
|
|
||||||
input.pn-ae-tag-input {
|
|
||||||
height: 26px;
|
|
||||||
width: 110px;
|
|
||||||
padding: 0 10px;
|
|
||||||
border: 1px solid var(--input);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--card);
|
|
||||||
color: var(--foreground);
|
|
||||||
font-size: 12px;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
input.pn-ae-tag-input:focus {
|
|
||||||
border-color: var(--ring);
|
|
||||||
}
|
}
|
||||||
.pn-ae-toolbar {
|
.pn-ae-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1868,10 +1743,8 @@ button.pn-ae-tool svg {
|
|||||||
.pn-ae-preview .pn-note-body {
|
.pn-ae-preview .pn-note-body {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
.pn-ae-upload-msg {
|
.pn-ae-upload-alert {
|
||||||
margin: 10px 0 0;
|
margin-top: 10px;
|
||||||
font-size: 12px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
}
|
}
|
||||||
/* 发布设置侧卡 */
|
/* 发布设置侧卡 */
|
||||||
.pn-ae-side {
|
.pn-ae-side {
|
||||||
@@ -1892,22 +1765,14 @@ button.pn-ae-tool svg {
|
|||||||
}
|
}
|
||||||
.pn-ae-plabel {
|
.pn-ae-plabel {
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.pn-ae-pvalue {
|
/* 发布日期选择器(size=small) */
|
||||||
display: inline-flex;
|
.pn-ae-datepicker {
|
||||||
align-items: center;
|
flex: none;
|
||||||
gap: 7px;
|
width: 168px;
|
||||||
color: var(--foreground);
|
|
||||||
}
|
}
|
||||||
.pn-ae-pvalue.is-muted {
|
.pn-ae-datepicker .ant-input {
|
||||||
color: var(--muted-foreground);
|
|
||||||
}
|
|
||||||
.pn-ae-pvalue svg {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
}
|
|
||||||
.pn-ae-mono {
|
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
@@ -1978,9 +1843,6 @@ button.pn-ae-tool svg {
|
|||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.pn-at-days-urgent {
|
|
||||||
color: var(--destructive-text);
|
|
||||||
}
|
|
||||||
.pn-at-actions {
|
.pn-at-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1992,7 +1854,39 @@ button.pn-ae-tool svg {
|
|||||||
站点设置(站点设置.html)
|
站点设置(站点设置.html)
|
||||||
============================================================ */
|
============================================================ */
|
||||||
.pn-as-content {
|
.pn-as-content {
|
||||||
max-width: 640px;
|
max-width: 1100px;
|
||||||
|
}
|
||||||
|
/* 宽屏两卡左右分布,窄屏回落上下堆叠(.pn-admin-content 的 flex 在此处被覆盖为 grid) */
|
||||||
|
@media (min-width: 1100px) {
|
||||||
|
.pn-as-content {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pn-as-logo-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.pn-as-logo-preview {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--card);
|
||||||
|
}
|
||||||
|
.pn-as-logo-empty {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px dashed var(--border-strong);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
.pn-as-card-head {
|
.pn-as-card-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export interface SiteSettings {
|
|||||||
site_desc: string
|
site_desc: string
|
||||||
page_size: number
|
page_size: number
|
||||||
beian_no: string
|
beian_no: string
|
||||||
|
site_logo: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NoteItem {
|
export interface NoteItem {
|
||||||
@@ -98,9 +99,12 @@ export interface NoteItem {
|
|||||||
status?: string
|
status?: string
|
||||||
tags: string[]
|
tags: string[]
|
||||||
pinned: boolean
|
pinned: boolean
|
||||||
|
/** 公开列表项的封面图(正文第一张图;无图缺省) */
|
||||||
|
cover?: string
|
||||||
deleted_at?: number | null
|
deleted_at?: number | null
|
||||||
created_at: number
|
created_at: number
|
||||||
updated_at: number
|
updated_at: number
|
||||||
|
published_at: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NoteDetail extends NoteItem {
|
export interface NoteDetail extends NoteItem {
|
||||||
|
|||||||
+68
-76
@@ -1,11 +1,12 @@
|
|||||||
import { lazy, Suspense, useEffect, useRef, useState } from 'react'
|
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router'
|
import { useNavigate, useParams } from 'react-router'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
import {
|
import {
|
||||||
Bold, Calendar, CirclePlus, Code, Eye, EyeOff, Italic, Link2, List, Quote,
|
Bold, Code, Eye, EyeOff, Italic, Link2, List, Quote,
|
||||||
Save, Table, Trash2, X,
|
Save, Table, Trash2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Button, Card, Divider, Input, Switch, Tag } from 'antd'
|
import { Alert, Badge, Button, Card, DatePicker, Divider, Input, Select, Spin, Switch } from 'antd'
|
||||||
import { api, ApiError } from '../lib/api'
|
import { api, ApiError } from '../lib/api'
|
||||||
import type { NoteItem } from '../lib/api'
|
import type { NoteItem } from '../lib/api'
|
||||||
import { invalidatePublic } from '../lib/invalidate'
|
import { invalidatePublic } from '../lib/invalidate'
|
||||||
@@ -13,7 +14,7 @@ import { useDocumentTitle } from '../features/site'
|
|||||||
import MarkdownViewer from '../components/MarkdownViewer'
|
import MarkdownViewer from '../components/MarkdownViewer'
|
||||||
import { AdminTopbar } from '../components/Layout'
|
import { AdminTopbar } from '../components/Layout'
|
||||||
import { useToast } from '@/hooks/use-toast'
|
import { useToast } from '@/hooks/use-toast'
|
||||||
import { formatDateISO, slugPreview } from '../lib/utils'
|
import { slugPreview } from '../lib/utils'
|
||||||
|
|
||||||
// CodeMirror 体积大,单独拆 chunk,编辑器在首帧后异步加载
|
// CodeMirror 体积大,单独拆 chunk,编辑器在首帧后异步加载
|
||||||
const Editor = lazy(() => import('../components/Editor'))
|
const Editor = lazy(() => import('../components/Editor'))
|
||||||
@@ -32,9 +33,10 @@ type Form = {
|
|||||||
status: 'public' | 'private'
|
status: 'public' | 'private'
|
||||||
tags: string[]
|
tags: string[]
|
||||||
pinned: boolean
|
pinned: boolean
|
||||||
|
publishedAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyForm: Form = {
|
const emptyForm = (): Form => ({
|
||||||
title: '',
|
title: '',
|
||||||
slug: '',
|
slug: '',
|
||||||
summary: '',
|
summary: '',
|
||||||
@@ -42,7 +44,8 @@ const emptyForm: Form = {
|
|||||||
status: 'private',
|
status: 'private',
|
||||||
tags: [],
|
tags: [],
|
||||||
pinned: false,
|
pinned: false,
|
||||||
}
|
publishedAt: Math.floor(Date.now() / 1000),
|
||||||
|
})
|
||||||
|
|
||||||
export default function AdminEdit() {
|
export default function AdminEdit() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
@@ -59,17 +62,28 @@ export default function AdminEdit() {
|
|||||||
enabled: !isNew,
|
enabled: !isNew,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 既有标签(含私有笔记)作为选择器候选项
|
||||||
|
const { data: allNotes } = useQuery({
|
||||||
|
queryKey: ['admin', 'notes'],
|
||||||
|
queryFn: () => api<{ items: NoteItem[] }>('/api/admin/notes'),
|
||||||
|
})
|
||||||
|
|
||||||
const [form, setForm] = useState<Form>(emptyForm)
|
const [form, setForm] = useState<Form>(emptyForm)
|
||||||
const [savedAt, setSavedAt] = useState<number | null>(null)
|
|
||||||
const [errorMsg, setErrorMsg] = useState<string | null>(null)
|
const [errorMsg, setErrorMsg] = useState<string | null>(null)
|
||||||
const [slugFieldError, setSlugFieldError] = useState<string | null>(null)
|
const [slugFieldError, setSlugFieldError] = useState<string | null>(null)
|
||||||
const [showPreview, setShowPreview] = useState(false)
|
const [showPreview, setShowPreview] = useState(false)
|
||||||
const [uploadMsg, setUploadMsg] = useState<string | null>(null)
|
const [uploadMsg, setUploadMsg] = useState<{ kind: 'busy' | 'error'; text: string } | null>(null)
|
||||||
const [tagInput, setTagInput] = useState<string | null>(null)
|
|
||||||
const dirtyRef = useRef(false)
|
const dirtyRef = useRef(false)
|
||||||
const slugTouchedRef = useRef(false)
|
const slugTouchedRef = useRef(false)
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
|
const tagOptions = useMemo(() => {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (const n of allNotes?.items ?? []) for (const t of n.tags) seen.add(t)
|
||||||
|
for (const t of form.tags) seen.add(t)
|
||||||
|
return [...seen].map((t) => ({ value: t, label: t }))
|
||||||
|
}, [allNotes, form.tags])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (existing) {
|
if (existing) {
|
||||||
setForm({
|
setForm({
|
||||||
@@ -80,6 +94,7 @@ export default function AdminEdit() {
|
|||||||
status: existing.status === 'public' ? 'public' : 'private',
|
status: existing.status === 'public' ? 'public' : 'private',
|
||||||
tags: existing.tags,
|
tags: existing.tags,
|
||||||
pinned: existing.pinned,
|
pinned: existing.pinned,
|
||||||
|
publishedAt: existing.published_at || existing.created_at,
|
||||||
})
|
})
|
||||||
slugTouchedRef.current = false
|
slugTouchedRef.current = false
|
||||||
dirtyRef.current = false
|
dirtyRef.current = false
|
||||||
@@ -104,6 +119,7 @@ export default function AdminEdit() {
|
|||||||
status: f.status,
|
status: f.status,
|
||||||
tags: f.tags,
|
tags: f.tags,
|
||||||
pinned: f.pinned,
|
pinned: f.pinned,
|
||||||
|
published_at: f.publishedAt,
|
||||||
}
|
}
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
// 新建:slug 由服务端从标题自动生成(首次保存即定稿,§8.2)
|
// 新建:slug 由服务端从标题自动生成(首次保存即定稿,§8.2)
|
||||||
@@ -118,7 +134,6 @@ export default function AdminEdit() {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
onSuccess: (saved, vars) => {
|
onSuccess: (saved, vars) => {
|
||||||
setSavedAt(Date.now())
|
|
||||||
setSlugFieldError(null)
|
setSlugFieldError(null)
|
||||||
setErrorMsg(null)
|
setErrorMsg(null)
|
||||||
dirtyRef.current = false
|
dirtyRef.current = false
|
||||||
@@ -171,19 +186,11 @@ export default function AdminEdit() {
|
|||||||
|
|
||||||
const insert = (snippet: string) => set('content', form.content + snippet)
|
const insert = (snippet: string) => set('content', form.content + snippet)
|
||||||
|
|
||||||
const addTag = () => {
|
|
||||||
const t = (tagInput ?? '').trim()
|
|
||||||
if (t && !form.tags.includes(t)) {
|
|
||||||
set('tags', [...form.tags, t])
|
|
||||||
}
|
|
||||||
setTagInput(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isNew && isLoading) {
|
if (!isNew && isLoading) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AdminTopbar title="编辑笔记" />
|
<AdminTopbar title="编辑笔记" />
|
||||||
<div className="pn-admin-content"><div className="pn-empty">加载中…</div></div>
|
<div className="pn-admin-content"><div className="pn-admin-loading"><Spin /></div></div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -214,7 +221,7 @@ export default function AdminEdit() {
|
|||||||
<div className="pn-ae-grid">
|
<div className="pn-ae-grid">
|
||||||
<Card className="pn-ae-main min-w-0">
|
<Card className="pn-ae-main min-w-0">
|
||||||
<Input
|
<Input
|
||||||
variant="borderless"
|
variant="filled"
|
||||||
className="pn-ae-title-input"
|
className="pn-ae-title-input"
|
||||||
type="text"
|
type="text"
|
||||||
value={form.title}
|
value={form.title}
|
||||||
@@ -229,43 +236,16 @@ export default function AdminEdit() {
|
|||||||
<span>/notes/{slugDisplay}</span>
|
<span>/notes/{slugDisplay}</span>
|
||||||
</span>
|
</span>
|
||||||
<div className="pn-ae-tags">
|
<div className="pn-ae-tags">
|
||||||
{form.tags.map((t) => (
|
<Select
|
||||||
<Tag
|
mode="tags"
|
||||||
key={t}
|
className="pn-ae-tags-select"
|
||||||
closable
|
value={form.tags}
|
||||||
closeIcon={<X size={12} />}
|
options={tagOptions}
|
||||||
onClose={(e) => {
|
placeholder="添加或选择标签"
|
||||||
e.preventDefault()
|
aria-label="标签"
|
||||||
set('tags', form.tags.filter((x) => x !== t))
|
tokenSeparators={[',']}
|
||||||
}}
|
onChange={(v) => set('tags', v)}
|
||||||
aria-label={`标签:${t}`}
|
/>
|
||||||
>
|
|
||||||
{t}
|
|
||||||
</Tag>
|
|
||||||
))}
|
|
||||||
{tagInput === null ? (
|
|
||||||
<Button type="text" className="pn-ae-add-tag" onClick={() => setTagInput('')}>
|
|
||||||
<CirclePlus size={13} /><span>添加标签</span>
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Input
|
|
||||||
variant="borderless"
|
|
||||||
className="pn-ae-tag-input"
|
|
||||||
autoFocus
|
|
||||||
value={tagInput}
|
|
||||||
placeholder="标签名"
|
|
||||||
aria-label="新标签名"
|
|
||||||
onChange={(e) => setTagInput(e.target.value)}
|
|
||||||
onBlur={addTag}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault()
|
|
||||||
addTag()
|
|
||||||
}
|
|
||||||
if (e.key === 'Escape') setTagInput(null)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -281,16 +261,23 @@ export default function AdminEdit() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pn-ae-body-wrap">
|
<div className="pn-ae-body-wrap">
|
||||||
<Suspense fallback={<div className="pn-empty" style={{ minHeight: 420 }}>编辑器加载中…</div>}>
|
<Suspense fallback={<div className="pn-admin-loading" style={{ minHeight: 420 }}><Spin /></div>}>
|
||||||
<Editor
|
<Editor
|
||||||
value={form.content}
|
value={form.content}
|
||||||
onChange={(v) => set('content', v)}
|
onChange={(v) => set('content', v)}
|
||||||
onUploadStart={(name) => setUploadMsg(`上传中:${name}…`)}
|
onUploadStart={(name) => setUploadMsg({ kind: 'busy', text: `上传中:${name}…` })}
|
||||||
onUploadEnd={(err) => setUploadMsg(err ?? null)}
|
onUploadEnd={(err) => setUploadMsg(err ? { kind: 'error', text: err } : null)}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
{uploadMsg && <p className="pn-ae-upload-msg">{uploadMsg}</p>}
|
{uploadMsg && (
|
||||||
|
<Alert
|
||||||
|
className="pn-ae-upload-alert"
|
||||||
|
type={uploadMsg.kind === 'busy' ? 'info' : 'error'}
|
||||||
|
showIcon
|
||||||
|
message={uploadMsg.text}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{showPreview && (
|
{showPreview && (
|
||||||
<div className="pn-ae-preview">
|
<div className="pn-ae-preview">
|
||||||
@@ -305,12 +292,10 @@ export default function AdminEdit() {
|
|||||||
|
|
||||||
<div className="pn-ae-prow">
|
<div className="pn-ae-prow">
|
||||||
<span className="pn-ae-plabel">状态</span>
|
<span className="pn-ae-plabel">状态</span>
|
||||||
<span
|
<Badge
|
||||||
className={`pn-ae-pvalue pn-ae-mono ${form.status === 'public' ? '' : 'is-muted'}`}
|
status={form.status === 'public' ? 'success' : 'default'}
|
||||||
style={form.status === 'public' ? { color: 'var(--success-text)' } : undefined}
|
text={form.status === 'public' ? '公开' : '私有'}
|
||||||
>
|
/>
|
||||||
<span className="dot" />{form.status === 'public' ? '公开' : '私有'}
|
|
||||||
</span>
|
|
||||||
<Switch
|
<Switch
|
||||||
checked={form.status === 'public'}
|
checked={form.status === 'public'}
|
||||||
onChange={(checked) => set('status', checked ? 'public' : 'private')}
|
onChange={(checked) => set('status', checked ? 'public' : 'private')}
|
||||||
@@ -328,10 +313,17 @@ export default function AdminEdit() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pn-ae-prow">
|
<div className="pn-ae-prow">
|
||||||
<span className="pn-ae-plabel">日期</span>
|
<span className="pn-ae-plabel">发布日期</span>
|
||||||
<span className="pn-ae-pvalue pn-ae-mono">
|
<DatePicker
|
||||||
<Calendar size={14} />{formatDateISO(savedAt ? Math.floor(savedAt / 1000) : (existing?.updated_at ?? Math.floor(Date.now() / 1000)))}
|
className="pn-ae-datepicker"
|
||||||
</span>
|
size="small"
|
||||||
|
value={dayjs.unix(form.publishedAt)}
|
||||||
|
onChange={(d) => d && set('publishedAt', d.unix())}
|
||||||
|
allowClear={false}
|
||||||
|
showTime={{ format: 'HH:mm' }}
|
||||||
|
format="YYYY-MM-DD HH:mm"
|
||||||
|
aria-label="发布日期"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pn-ae-pfield">
|
<div className="pn-ae-pfield">
|
||||||
@@ -368,13 +360,13 @@ export default function AdminEdit() {
|
|||||||
<>
|
<>
|
||||||
<Divider className="pn-ae-divider" />
|
<Divider className="pn-ae-divider" />
|
||||||
<Button
|
<Button
|
||||||
className="pn-btn-danger-outline"
|
danger
|
||||||
style={{ width: '100%' }}
|
block
|
||||||
|
icon={<Trash2 size={15} />}
|
||||||
disabled={trashMutation.isPending}
|
disabled={trashMutation.isPending}
|
||||||
onClick={() => trashMutation.mutate()}
|
onClick={() => trashMutation.mutate()}
|
||||||
>
|
>
|
||||||
<Trash2 size={15} />
|
{trashMutation.isPending ? '删除中…' : '移至回收站'}
|
||||||
<span>{trashMutation.isPending ? '删除中…' : '移至回收站'}</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+35
-41
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react'
|
|||||||
import { Link, useNavigate } from 'react-router'
|
import { Link, useNavigate } from 'react-router'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { CirclePlus, Eye, EyeOff, Pencil, Pin, Search, Trash2 } from 'lucide-react'
|
import { CirclePlus, Eye, EyeOff, Pencil, Pin, Search, Trash2 } from 'lucide-react'
|
||||||
import { Button, Card, Input, Modal, Segmented, Table, Tag } from 'antd'
|
import { Badge, Button, Card, Empty, Input, Modal, Segmented, Table, Tag } from 'antd'
|
||||||
import type { TableProps } from 'antd'
|
import type { TableProps } from 'antd'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import type { NoteItem } from '../lib/api'
|
import type { NoteItem } from '../lib/api'
|
||||||
@@ -122,14 +122,9 @@ export default function AdminList() {
|
|||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
key: 'status',
|
key: 'status',
|
||||||
render: (_, n) => {
|
render: (_, n) => (
|
||||||
const isPublic = n.status === 'public'
|
<Badge status={n.status === 'public' ? 'success' : 'default'} text={n.status === 'public' ? '已发布' : '私有'} />
|
||||||
return (
|
),
|
||||||
<span className={`pn-al-status ${isPublic ? 'pn-al-is-public' : 'pn-al-is-draft'}`}>
|
|
||||||
<span className="dot" />{isPublic ? '已发布' : '私有'}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
@@ -156,7 +151,7 @@ export default function AdminList() {
|
|||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
className="pn-btn-danger-outline"
|
danger
|
||||||
icon={<Trash2 size={15} />}
|
icon={<Trash2 size={15} />}
|
||||||
title="移至回收站"
|
title="移至回收站"
|
||||||
aria-label={`移至回收站:${n.title}`}
|
aria-label={`移至回收站:${n.title}`}
|
||||||
@@ -171,17 +166,15 @@ export default function AdminList() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AdminTopbar title="笔记管理">
|
<AdminTopbar title="笔记管理">
|
||||||
<label className="pn-admin-search">
|
<Input
|
||||||
<Search size={14} aria-hidden />
|
className="pn-al-search"
|
||||||
<Input
|
allowClear
|
||||||
variant="borderless"
|
placeholder="搜索笔记…"
|
||||||
type="search"
|
aria-label="搜索笔记"
|
||||||
placeholder="搜索笔记…"
|
value={query}
|
||||||
aria-label="搜索笔记"
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
value={query}
|
prefix={<Search size={14} aria-hidden />}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
/>
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<CirclePlus size={15} />}
|
icon={<CirclePlus size={15} />}
|
||||||
@@ -229,31 +222,32 @@ export default function AdminList() {
|
|||||||
onChange={(v) => setFilter(v as StatusFilter)}
|
onChange={(v) => setFilter(v as StatusFilter)}
|
||||||
className="min-w-0"
|
className="min-w-0"
|
||||||
options={[
|
options={[
|
||||||
{ value: 'all', label: <>全部<span className="pn-al-tab-num">{items.length}</span></> },
|
{ value: 'all', label: <>全部<Badge count={items.length} showZero size="small" className="pn-al-tab-badge" /></> },
|
||||||
{ value: 'public', label: <>已发布<span className="pn-al-tab-num">{publicCount}</span></> },
|
{ value: 'public', label: <>已发布<Badge count={publicCount} showZero size="small" className="pn-al-tab-badge" /></> },
|
||||||
{ value: 'private', label: <>私有<span className="pn-al-tab-num">{privateCount}</span></> },
|
{ value: 'private', label: <>私有<Badge count={privateCount} showZero size="small" className="pn-al-tab-badge" /></> },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<span className="pn-al-table-note">共 {visible.length} 篇笔记</span>
|
<span className="pn-al-table-note">共 {visible.length} 篇笔记</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
<Table
|
||||||
<div className="pn-empty">加载中…</div>
|
className="pn-al-table"
|
||||||
) : visible.length === 0 ? (
|
columns={columns}
|
||||||
<div className="pn-empty">
|
dataSource={visible}
|
||||||
{items.length === 0 ? '还没有笔记,点击「新建笔记」开始' : '没有符合筛选条件的笔记'}
|
rowKey="id"
|
||||||
</div>
|
loading={isLoading}
|
||||||
) : (
|
pagination={false}
|
||||||
<Table
|
scroll={{ x: 600 }}
|
||||||
className="pn-al-table"
|
rowClassName={(n) => (n.status === 'public' ? '' : 'pn-al-is-draft')}
|
||||||
columns={columns}
|
locale={{
|
||||||
dataSource={visible}
|
emptyText: (
|
||||||
rowKey="id"
|
<Empty
|
||||||
pagination={false}
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
scroll={{ x: 600 }}
|
description={items.length === 0 ? '还没有笔记,点击「新建笔记」开始' : '没有符合筛选条件的笔记'}
|
||||||
rowClassName={(n) => (n.status === 'public' ? '' : 'pn-al-is-draft')}
|
/>
|
||||||
/>
|
),
|
||||||
)}
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="pn-al-table-foot">
|
<div className="pn-al-table-foot">
|
||||||
<span className="pn-al-foot-note">共 {data?.total ?? 0} 篇 · 显示 {visible.length} 篇</span>
|
<span className="pn-al-foot-note">共 {data?.total ?? 0} 篇 · 显示 {visible.length} 篇</span>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Eye, EyeOff, KeyRound, Lock, Save } from 'lucide-react'
|
import { Eye, EyeOff, ImageUp, KeyRound, Lock, RotateCcw, Save } from 'lucide-react'
|
||||||
import { Button, Card, Divider, Input, InputNumber } from 'antd'
|
import { Button, Card, Divider, Input, Select, Upload } from 'antd'
|
||||||
import { api, ApiError } from '../lib/api'
|
import { api, ApiError } from '../lib/api'
|
||||||
import type { SiteSettings } from '../lib/api'
|
import type { SiteSettings } from '../lib/api'
|
||||||
import { invalidatePublic } from '../lib/invalidate'
|
import { invalidatePublic } from '../lib/invalidate'
|
||||||
@@ -9,6 +9,8 @@ import { useDocumentTitle } from '../features/site'
|
|||||||
import { AdminTopbar } from '../components/Layout'
|
import { AdminTopbar } from '../components/Layout'
|
||||||
import { useToast } from '@/hooks/use-toast'
|
import { useToast } from '@/hooks/use-toast'
|
||||||
|
|
||||||
|
const PAGE_SIZE_OPTIONS = [10, 20, 30, 50].map((n) => ({ value: n, label: `${n} 篇` }))
|
||||||
|
|
||||||
export default function AdminSettings() {
|
export default function AdminSettings() {
|
||||||
useDocumentTitle('设置')
|
useDocumentTitle('设置')
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
@@ -22,6 +24,7 @@ export default function AdminSettings() {
|
|||||||
const [desc, setDesc] = useState('')
|
const [desc, setDesc] = useState('')
|
||||||
const [beian, setBeian] = useState('')
|
const [beian, setBeian] = useState('')
|
||||||
const [pageSize, setPageSize] = useState(10)
|
const [pageSize, setPageSize] = useState(10)
|
||||||
|
const [logo, setLogo] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
@@ -29,6 +32,7 @@ export default function AdminSettings() {
|
|||||||
setDesc(data.site_desc)
|
setDesc(data.site_desc)
|
||||||
setBeian(data.beian_no)
|
setBeian(data.beian_no)
|
||||||
setPageSize(data.page_size)
|
setPageSize(data.page_size)
|
||||||
|
setLogo(data.site_logo ?? '')
|
||||||
}
|
}
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
@@ -36,7 +40,7 @@ export default function AdminSettings() {
|
|||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
api('/api/admin/settings', {
|
api('/api/admin/settings', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ site_title: title, site_desc: desc, beian_no: beian, page_size: pageSize }),
|
body: JSON.stringify({ site_title: title, site_desc: desc, beian_no: beian, page_size: pageSize, site_logo: logo }),
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({ description: '设置已保存' })
|
toast({ description: '设置已保存' })
|
||||||
@@ -46,6 +50,25 @@ export default function AdminSettings() {
|
|||||||
onError: (e) => toast({ description: e instanceof ApiError ? e.message : '保存失败', variant: 'error' }),
|
onError: (e) => toast({ description: e instanceof ApiError ? e.message : '保存失败', variant: 'error' }),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// antd Upload customRequest 的最小结构化类型(避免引入 rc-upload 内部类型路径)
|
||||||
|
const uploadLogo = async ({ file, onSuccess, onError }: {
|
||||||
|
file: unknown
|
||||||
|
onSuccess?: (ret?: unknown, xhr?: XMLHttpRequest) => void
|
||||||
|
onError?: (event: Error, body?: unknown) => void
|
||||||
|
}) => {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file as File)
|
||||||
|
try {
|
||||||
|
const res = await api<{ url: string }>('/api/admin/images', { method: 'POST', body: fd })
|
||||||
|
setLogo(res.url)
|
||||||
|
onSuccess?.(res)
|
||||||
|
toast({ description: 'Logo 已上传,记得保存更改' })
|
||||||
|
} catch (e) {
|
||||||
|
onError?.(e instanceof Error ? e : new Error('上传失败'))
|
||||||
|
toast({ description: e instanceof ApiError ? e.message : '上传失败', variant: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AdminTopbar title="站点设置">
|
<AdminTopbar title="站点设置">
|
||||||
@@ -96,16 +119,41 @@ export default function AdminSettings() {
|
|||||||
<p className="pn-as-hint">显示在博客页脚并链接至公安部备案官网;留空则不显示。</p>
|
<p className="pn-as-hint">显示在博客页脚并链接至公安部备案官网;留空则不显示。</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="pn-as-field">
|
<div className="pn-as-field">
|
||||||
<label htmlFor="page-size" className="pn-field-label">首页每页条数(1–100)</label>
|
<label htmlFor="page-size" className="pn-field-label">首页每页条数</label>
|
||||||
<InputNumber
|
<Select
|
||||||
id="page-size"
|
id="page-size"
|
||||||
min={1}
|
|
||||||
max={100}
|
|
||||||
style={{ width: 140 }}
|
style={{ width: 140 }}
|
||||||
|
options={PAGE_SIZE_OPTIONS}
|
||||||
value={pageSize}
|
value={pageSize}
|
||||||
onChange={(v) => setPageSize(Number(v ?? 10))}
|
onChange={(v) => setPageSize(v)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="pn-as-field">
|
||||||
|
<span className="pn-field-label">站点 Logo</span>
|
||||||
|
<div className="pn-as-logo-row">
|
||||||
|
{logo ? (
|
||||||
|
<img className="pn-as-logo-preview" src={logo} alt="站点 Logo 预览" />
|
||||||
|
) : (
|
||||||
|
<span className="pn-as-logo-empty">未设置</span>
|
||||||
|
)}
|
||||||
|
<Upload
|
||||||
|
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||||
|
showUploadList={false}
|
||||||
|
customRequest={uploadLogo}
|
||||||
|
>
|
||||||
|
<Button icon={<ImageUp size={15} />}>{logo ? '更换图片' : '上传图片'}</Button>
|
||||||
|
</Upload>
|
||||||
|
{logo && (
|
||||||
|
<Button
|
||||||
|
icon={<RotateCcw size={15} />}
|
||||||
|
onClick={() => setLogo('')}
|
||||||
|
>
|
||||||
|
重置默认
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="pn-as-hint">显示在博客顶部站点名称前,并用作站点 favicon,建议正方形图片;「重置默认」恢复内置图标。修改后需点击「保存更改」。</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -194,7 +242,6 @@ function ChangePassword({
|
|||||||
</div>
|
</div>
|
||||||
<div className="pn-as-card-foot">
|
<div className="pn-as-card-foot">
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
|
||||||
disabled={change.isPending || oldPw.length === 0 || newPw.length === 0}
|
disabled={change.isPending || oldPw.length === 0 || newPw.length === 0}
|
||||||
onClick={() => change.mutate()}
|
onClick={() => change.mutate()}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { RotateCcw } from 'lucide-react'
|
import { RotateCcw, Trash2 } from 'lucide-react'
|
||||||
import { Alert, Button, Card, Table } from 'antd'
|
import { Alert, App, Button, Card, Empty, Table, Tag } from 'antd'
|
||||||
import type { TableProps } from 'antd'
|
import type { TableProps } from 'antd'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import type { NoteItem } from '../lib/api'
|
import type { NoteItem } from '../lib/api'
|
||||||
@@ -14,6 +14,7 @@ export default function AdminTrash() {
|
|||||||
useDocumentTitle('回收站')
|
useDocumentTitle('回收站')
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
const { modal } = App.useApp()
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['admin', 'trash'],
|
queryKey: ['admin', 'trash'],
|
||||||
queryFn: () => api<{ items: NoteItem[]; total: number }>('/api/admin/trash'),
|
queryFn: () => api<{ items: NoteItem[]; total: number }>('/api/admin/trash'),
|
||||||
@@ -29,6 +30,27 @@ export default function AdminTrash() {
|
|||||||
onError: () => toast({ description: '恢复失败,请重试', variant: 'error' }),
|
onError: () => toast({ description: '恢复失败,请重试', variant: 'error' }),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const clear = useMutation({
|
||||||
|
mutationFn: () => api<{ deleted: number }>('/api/admin/trash', { method: 'DELETE' }),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
toast({ description: `已清空回收站(${res.deleted} 篇)` })
|
||||||
|
void qc.invalidateQueries({ queryKey: ['admin'] })
|
||||||
|
invalidatePublic(qc)
|
||||||
|
},
|
||||||
|
onError: () => toast({ description: '操作失败,请重试', variant: 'error' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const confirmEmpty = () => {
|
||||||
|
modal.confirm({
|
||||||
|
title: '清空回收站?',
|
||||||
|
content: `将永久删除回收站中的 ${items.length} 篇笔记,此操作不可恢复。`,
|
||||||
|
okText: '清空',
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: () => clear.mutateAsync(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const items = data?.items ?? []
|
const items = data?.items ?? []
|
||||||
|
|
||||||
const columns: TableProps<NoteItem>['columns'] = [
|
const columns: TableProps<NoteItem>['columns'] = [
|
||||||
@@ -55,7 +77,7 @@ export default function AdminTrash() {
|
|||||||
key: 'days',
|
key: 'days',
|
||||||
render: (_, n) => {
|
render: (_, n) => {
|
||||||
const left = n.deleted_at ? daysRemaining(n.deleted_at) : 0
|
const left = n.deleted_at ? daysRemaining(n.deleted_at) : 0
|
||||||
return <span className={`pn-at-days${left <= 3 ? ' pn-at-days-urgent' : ''}`}>{left} 天</span>
|
return left <= 3 ? <Tag color="red">{left} 天</Tag> : <span className="pn-at-days">{left} 天</span>
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -80,7 +102,16 @@ export default function AdminTrash() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AdminTopbar title="回收站" />
|
<AdminTopbar title="回收站">
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
icon={<Trash2 size={15} />}
|
||||||
|
disabled={isLoading || items.length === 0 || clear.isPending}
|
||||||
|
onClick={confirmEmpty}
|
||||||
|
>
|
||||||
|
清空回收站
|
||||||
|
</Button>
|
||||||
|
</AdminTopbar>
|
||||||
|
|
||||||
<div className="pn-admin-content">
|
<div className="pn-admin-content">
|
||||||
<Alert
|
<Alert
|
||||||
@@ -91,20 +122,18 @@ export default function AdminTrash() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Card className="pn-al-card min-w-0 flex-1 overflow-hidden" styles={{ body: { padding: 0 } }}>
|
<Card className="pn-al-card min-w-0 flex-1 overflow-hidden" styles={{ body: { padding: 0 } }}>
|
||||||
{isLoading ? (
|
<Table
|
||||||
<div className="pn-empty">加载中…</div>
|
className="pn-al-table"
|
||||||
) : items.length === 0 ? (
|
columns={columns}
|
||||||
<div className="pn-empty">回收站是空的</div>
|
dataSource={items}
|
||||||
) : (
|
rowKey="id"
|
||||||
<Table
|
loading={isLoading}
|
||||||
className="pn-al-table"
|
pagination={false}
|
||||||
columns={columns}
|
scroll={{ x: 560 }}
|
||||||
dataSource={items}
|
locale={{
|
||||||
rowKey="id"
|
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="回收站是空的" />,
|
||||||
pagination={false}
|
}}
|
||||||
scroll={{ x: 560 }}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="pn-al-table-foot">
|
<div className="pn-al-table-foot">
|
||||||
<span className="pn-al-foot-note">共 {data?.total ?? 0} 篇已删除笔记</span>
|
<span className="pn-al-foot-note">共 {data?.total ?? 0} 篇已删除笔记</span>
|
||||||
|
|||||||
+43
-56
@@ -2,9 +2,9 @@ import { useState } from 'react'
|
|||||||
import { Link } from 'react-router'
|
import { Link } from 'react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { ArrowRight, Pin } from 'lucide-react'
|
import { ArrowRight, Pin } from 'lucide-react'
|
||||||
import { Pagination, Tag } from 'antd'
|
import { Empty, Flex, Pagination, Skeleton, Tag } from 'antd'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import type { NoteItem, NoteList, TagCount } from '../lib/api'
|
import type { NoteItem, NoteList } from '../lib/api'
|
||||||
import { useDocumentTitle } from '../features/site'
|
import { useDocumentTitle } from '../features/site'
|
||||||
import { formatDateISO } from '../lib/utils'
|
import { formatDateISO } from '../lib/utils'
|
||||||
|
|
||||||
@@ -16,77 +16,66 @@ function useNotes(page: number) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function useTags() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['tags'],
|
|
||||||
queryFn: () => api<{ tags: TagCount[] }>('/api/tags'),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function NotePost({ note }: { note: NoteItem }) {
|
function NotePost({ note }: { note: NoteItem }) {
|
||||||
return (
|
return (
|
||||||
<article className="pn-home-post">
|
<article className="pn-home-post">
|
||||||
<time className="pn-home-date" dateTime={formatDateISO(note.updated_at)}>
|
{note.cover && (
|
||||||
{note.pinned && (
|
<Link
|
||||||
<span className="pn-home-pin">
|
to={`/notes/${encodeURIComponent(note.slug)}`}
|
||||||
<Pin size={11} aria-hidden />置顶
|
className="pn-home-cover"
|
||||||
</span>
|
tabIndex={-1}
|
||||||
)}
|
aria-hidden
|
||||||
{formatDateISO(note.updated_at)}
|
>
|
||||||
</time>
|
<img src={note.cover} alt="" loading="lazy" />
|
||||||
<h2 className="pn-home-post-title">
|
|
||||||
<Link to={`/notes/${encodeURIComponent(note.slug)}`}>
|
|
||||||
{note.title}
|
|
||||||
<ArrowRight className="pn-home-arrow" size={17} aria-hidden />
|
|
||||||
</Link>
|
</Link>
|
||||||
</h2>
|
|
||||||
{note.summary && <p className="pn-home-summary">{note.summary}</p>}
|
|
||||||
{note.tags.length > 0 && (
|
|
||||||
<div className="pn-home-tags">
|
|
||||||
{note.tags.map((t) => (
|
|
||||||
<Tag key={t}>
|
|
||||||
<Link className="pn-tag-link" to={`/tags/${encodeURIComponent(t)}`}>{t}</Link>
|
|
||||||
</Tag>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
<div className="pn-home-post-main">
|
||||||
|
<time className="pn-home-date" dateTime={formatDateISO(note.published_at)}>
|
||||||
|
{note.pinned && (
|
||||||
|
<span className="pn-home-pin">
|
||||||
|
<Pin size={11} aria-hidden />置顶
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{formatDateISO(note.published_at)}
|
||||||
|
</time>
|
||||||
|
<h2 className="pn-home-post-title">
|
||||||
|
<Link to={`/notes/${encodeURIComponent(note.slug)}`}>
|
||||||
|
{note.title}
|
||||||
|
<ArrowRight className="pn-home-arrow" size={17} aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</h2>
|
||||||
|
{note.summary && <p className="pn-home-summary">{note.summary}</p>}
|
||||||
|
{note.tags.length > 0 && (
|
||||||
|
<div className="pn-home-tags">
|
||||||
|
{note.tags.map((t) => (
|
||||||
|
<Tag key={t}>
|
||||||
|
<Link className="pn-tag-link" to={`/tags/${encodeURIComponent(t)}`}>{t}</Link>
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 首页标签筛选:链接型分段导航(路由跳转而非受控切换,故用原生导航标记;
|
|
||||||
// 链接配色由 .pn-home-filter a 的无 layer 规则承接,见 index.css)
|
|
||||||
const filterStripCls =
|
|
||||||
'inline-flex min-w-0 items-center gap-1 rounded-[var(--radius-md)] border border-[var(--border)] bg-muted p-1'
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
useDocumentTitle()
|
useDocumentTitle()
|
||||||
const { data, isLoading } = useNotes(page)
|
const { data, isLoading } = useNotes(page)
|
||||||
const { data: tagsData } = useTags()
|
|
||||||
const totalPages = data ? Math.max(1, Math.ceil(data.total / data.page_size)) : 1
|
const totalPages = data ? Math.max(1, Math.ceil(data.total / data.page_size)) : 1
|
||||||
const topTags = (tagsData?.tags ?? []).slice(0, 4)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pn-home-content">
|
<div className="pn-home-content">
|
||||||
{topTags.length > 0 && (
|
|
||||||
<nav className={`pn-home-filter ${filterStripCls}`} aria-label="按标签筛选笔记">
|
|
||||||
<Link to="/" aria-current={page === 1 ? 'page' : undefined}>
|
|
||||||
全部
|
|
||||||
</Link>
|
|
||||||
{topTags.map((t) => (
|
|
||||||
<Link key={t.name} to={`/tags/${encodeURIComponent(t.name)}`}>
|
|
||||||
{t.name}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<section aria-label="笔记列表">
|
<section aria-label="笔记列表">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<p className="pn-home-list-empty">加载中…</p>
|
<Flex vertical gap={44} className="pn-home-skeleton">
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<Skeleton key={i} active title paragraph={{ rows: 2 }} />
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
) : !data || data.items.length === 0 ? (
|
) : !data || data.items.length === 0 ? (
|
||||||
<p className="pn-home-list-empty">还没有公开的笔记</p>
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="还没有公开的笔记" />
|
||||||
) : (
|
) : (
|
||||||
data.items.map((n) => <NotePost key={n.slug} note={n} />)
|
data.items.map((n) => <NotePost key={n.slug} note={n} />)
|
||||||
)}
|
)}
|
||||||
@@ -94,15 +83,13 @@ export default function Home() {
|
|||||||
|
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<nav className="pn-home-pager" aria-label="分页">
|
<nav className="pn-home-pager" aria-label="分页">
|
||||||
<span className="pn-home-pager-note">
|
|
||||||
共 {data?.total ?? 0} 篇 · 第 {page} 页 / 共 {totalPages} 页
|
|
||||||
</span>
|
|
||||||
<Pagination
|
<Pagination
|
||||||
size="small"
|
size="small"
|
||||||
current={page}
|
current={page}
|
||||||
pageSize={data?.page_size ?? 10}
|
pageSize={data?.page_size ?? 10}
|
||||||
total={data?.total ?? 0}
|
total={data?.total ?? 0}
|
||||||
showSizeChanger={false}
|
showSizeChanger={false}
|
||||||
|
showTotal={() => `共 ${data?.total ?? 0} 篇 · 第 ${page} 页 / 共 ${totalPages} 页`}
|
||||||
onChange={(p) => setPage(p)}
|
onChange={(p) => setPage(p)}
|
||||||
/>
|
/>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ export default function Login() {
|
|||||||
<Card className="pn-login-card" styles={{ body: { padding: 0 } }}>
|
<Card className="pn-login-card" styles={{ body: { padding: 0 } }}>
|
||||||
<form onSubmit={onSubmit}>
|
<form onSubmit={onSubmit}>
|
||||||
<h1 className="pn-login-title">管理员登录</h1>
|
<h1 className="pn-login-title">管理员登录</h1>
|
||||||
<p className="pn-login-sub">请输入管理密码以继续</p>
|
|
||||||
|
|
||||||
<label htmlFor="pn-login-username" className="sr-only">用户名</label>
|
<label htmlFor="pn-login-username" className="sr-only">用户名</label>
|
||||||
<input
|
<input
|
||||||
@@ -103,8 +102,6 @@ export default function Login() {
|
|||||||
<span>{submitting ? '登录中…' : '登录'}</span>
|
<span>{submitting ? '登录中…' : '登录'}</span>
|
||||||
<ArrowRight size={16} />
|
<ArrowRight size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<p className="pn-login-hint">连续失败将被暂时锁定</p>
|
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
+21
-10
@@ -1,7 +1,7 @@
|
|||||||
import { Link, useNavigate, useParams } from 'react-router'
|
import { Link, useNavigate, useParams } from 'react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { ArrowLeft, ArrowRight, Clock, Eye } from 'lucide-react'
|
import { ArrowLeft, ArrowRight, Clock, Eye } from 'lucide-react'
|
||||||
import { Alert, Button, Card, Tag } from 'antd'
|
import { Alert, Button, Card, Empty, Skeleton, Tag } from 'antd'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import type { NoteDetail } from '../lib/api'
|
import type { NoteDetail } from '../lib/api'
|
||||||
import { useAuth } from '../lib/auth'
|
import { useAuth } from '../lib/auth'
|
||||||
@@ -20,17 +20,28 @@ export default function Note() {
|
|||||||
})
|
})
|
||||||
useDocumentTitle(note?.title)
|
useDocumentTitle(note?.title)
|
||||||
|
|
||||||
if (isLoading) return <p className="pn-home-list-empty">加载中…</p>
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<article className="pn-note-article">
|
||||||
|
<Skeleton active title paragraph={{ rows: 6 }} />
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
if (error || !note) {
|
if (error || !note) {
|
||||||
return (
|
return (
|
||||||
<div className="pn-note-article">
|
<article className="pn-note-article">
|
||||||
<p className="pn-home-list-empty">笔记不存在或未公开</p>
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="笔记不存在或未公开">
|
||||||
<p style={{ textAlign: 'center', marginTop: -40 }}>
|
<Button
|
||||||
<Button type="text" size="small" className="pn-note-back" icon={<ArrowLeft size={14} />} onClick={() => navigate('/')}>
|
type="text"
|
||||||
|
size="small"
|
||||||
|
className="pn-note-back"
|
||||||
|
icon={<ArrowLeft size={14} />}
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
>
|
||||||
返回首页
|
返回首页
|
||||||
</Button>
|
</Button>
|
||||||
</p>
|
</Empty>
|
||||||
</div>
|
</article>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,8 +71,8 @@ export default function Note() {
|
|||||||
<h1 className="pn-note-title">{note.title}</h1>
|
<h1 className="pn-note-title">{note.title}</h1>
|
||||||
|
|
||||||
<div className="pn-note-meta">
|
<div className="pn-note-meta">
|
||||||
<time className="pn-note-meta-date" dateTime={formatDateISO(note.created_at)}>
|
<time className="pn-note-meta-date" dateTime={formatDateISO(note.published_at)}>
|
||||||
{formatDateISO(note.created_at)}
|
{formatDateISO(note.published_at)}
|
||||||
</time>
|
</time>
|
||||||
<span className="pn-note-meta-sep">·</span>
|
<span className="pn-note-meta-sep">·</span>
|
||||||
<span className="pn-note-meta-read"><Clock size={14} />约 {readingMinutes(note.content)} 分钟阅读</span>
|
<span className="pn-note-meta-read"><Clock size={14} />约 {readingMinutes(note.content)} 分钟阅读</span>
|
||||||
|
|||||||
+29
-21
@@ -1,7 +1,7 @@
|
|||||||
import { Link, useParams } from 'react-router'
|
import { Link, useNavigate, useParams } from 'react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { ArrowRight, Tag } from 'lucide-react'
|
import { ArrowRight, Tag } from 'lucide-react'
|
||||||
import { Tag as AntTag } from 'antd'
|
import { Empty, Skeleton, Spin, Tag as AntTag } from 'antd'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import type { NoteList, TagCount } from '../lib/api'
|
import type { NoteList, TagCount } from '../lib/api'
|
||||||
import { useDocumentTitle } from '../features/site'
|
import { useDocumentTitle } from '../features/site'
|
||||||
@@ -16,6 +16,7 @@ function useTags() {
|
|||||||
|
|
||||||
export default function TagPage() {
|
export default function TagPage() {
|
||||||
const { tag } = useParams()
|
const { tag } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
useDocumentTitle(tag ? `标签:${tag}` : '标签')
|
useDocumentTitle(tag ? `标签:${tag}` : '标签')
|
||||||
const { data: tagsData } = useTags()
|
const { data: tagsData } = useTags()
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
@@ -32,26 +33,33 @@ export default function TagPage() {
|
|||||||
<p>按主题浏览所有公开笔记</p>
|
<p>按主题浏览所有公开笔记</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<nav className="pn-tag-cloud" aria-label="全部标签">
|
{/* 单选 CheckableTagGroup:选中态由组件承接;再次点击已选标签 = 取消(回全部) */}
|
||||||
{tagsData?.tags.map((t) => (
|
<AntTag.CheckableTagGroup
|
||||||
<AntTag.CheckableTag key={t.name} checked={t.name === tag} className="pn-cloud-tag">
|
multiple={false}
|
||||||
<Link
|
className="pn-tag-cloud"
|
||||||
to={`/tags/${encodeURIComponent(t.name)}`}
|
value={tag ?? null}
|
||||||
aria-current={t.name === tag ? 'true' : undefined}
|
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} />
|
<Tag size={14} />
|
||||||
<span>{t.name}</span>
|
<span>{t.name}</span>
|
||||||
<span className="pn-cloud-count">{t.count}</span>
|
<span className="pn-cloud-count">{t.count}</span>
|
||||||
</Link>
|
</>
|
||||||
</AntTag.CheckableTag>
|
),
|
||||||
))}
|
}))}
|
||||||
{!tagsData && (
|
aria-label="全部标签"
|
||||||
<span className="p-2 font-mono text-xs text-muted-foreground">加载中…</span>
|
/>
|
||||||
)}
|
{!tagsData && (
|
||||||
{tagsData && tagsData.tags.length === 0 && (
|
<div className="pn-tag-cloud"><Spin size="small" /></div>
|
||||||
<span className="p-2 font-mono text-xs text-muted-foreground">还没有标签</span>
|
)}
|
||||||
)}
|
{tagsData && tagsData.tags.length === 0 && (
|
||||||
</nav>
|
<div className="pn-tag-empty">
|
||||||
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="还没有标签" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{tag === undefined && tagsData && (
|
{tag === undefined && tagsData && (
|
||||||
<p className="pn-home-list-empty" style={{ marginTop: 48 }}>选择一个标签,查看对应笔记</p>
|
<p className="pn-home-list-empty" style={{ marginTop: 48 }}>选择一个标签,查看对应笔记</p>
|
||||||
@@ -63,11 +71,11 @@ export default function TagPage() {
|
|||||||
<h3 className="pn-tag-group-title"><Tag size={16} />{tag}</h3>
|
<h3 className="pn-tag-group-title"><Tag size={16} />{tag}</h3>
|
||||||
<div className="pn-tag-rows">
|
<div className="pn-tag-rows">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<p className="pn-home-list-empty">加载中…</p>
|
<Skeleton active title paragraph={{ rows: 3 }} />
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<p className="pn-home-list-empty">加载失败,请稍后重试</p>
|
<p className="pn-home-list-empty">加载失败,请稍后重试</p>
|
||||||
) : !data || data.items.length === 0 ? (
|
) : !data || data.items.length === 0 ? (
|
||||||
<p className="pn-home-list-empty">该标签下暂无公开笔记</p>
|
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="该标签下暂无公开笔记" />
|
||||||
) : (
|
) : (
|
||||||
data.items.map((n) => (
|
data.items.map((n) => (
|
||||||
<Link key={n.slug} to={`/notes/${encodeURIComponent(n.slug)}`} className="pn-tag-row">
|
<Link key={n.slug} to={`/notes/${encodeURIComponent(n.slug)}`} className="pn-tag-row">
|
||||||
|
|||||||
@@ -340,7 +340,8 @@ const useShadcnTheme = (dark: boolean) => {
|
|||||||
(dark ? styles.buttonPrimaryDark : styles.buttonPrimaryLight),
|
(dark ? styles.buttonPrimaryDark : styles.buttonPrimaryLight),
|
||||||
(props as { type?: string }).type === 'default' &&
|
(props as { type?: string }).type === 'default' &&
|
||||||
(dark ? styles.buttonDefaultDark : styles.buttonDefaultLight),
|
(dark ? styles.buttonDefaultDark : styles.buttonDefaultLight),
|
||||||
(props as { danger?: boolean }).danger &&
|
(props as { danger?: boolean; type?: string }).danger &&
|
||||||
|
(props as { type?: string }).type !== 'text' &&
|
||||||
(dark ? styles.buttonDangerDark : styles.buttonDangerLight),
|
(dark ? styles.buttonDangerDark : styles.buttonDangerLight),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user