feat: 新增发布日期自选、站点 Logo、回收站清空等十项改进
- 编辑页标题改 filled 变体;标签改 tags 选择器(可勾选既有/输入新建) - 笔记发布日期可自选(schema v2 新增 published_at 并回填),前台展示发布日期 - 登录页移除「请输入管理密码以继续」「连续失败将被暂时锁定」文案 - 站点设置每页条数收敛为 10/20/30/50 选择器,后端白名单同源校验 - 站点设置两卡片宽屏左右/窄屏上下;修改密码按钮改常规大小 - 后台各页顶栏固定高度,切换页面不再抖动 - 站点 Logo:settings 新增 site_logo(站内路径),gc/孤儿清单豁免 Logo 图片, 博客 header 站点名前展示,设置页支持上传/更换/清除 - 回收站右上角一键清空(DELETE /api/admin/trash,含确认弹窗) - pn init 写入 Markdown 语法示例文档(slug welcome,公开)与程序生成的示例图片
This commit is contained in:
@@ -7,12 +7,12 @@
|
||||
|
||||
## 功能
|
||||
|
||||
- **笔记**:Markdown CRUD、粘贴/拖拽图片上传(≤5MB、魔数校验、BLOB 入库去重)、公开/私有两态、置顶、标签
|
||||
- **博客**:首页(置顶优先 + 分页 + 标签云)、详情(上一篇/下一篇)、标签页、RSS、sitemap、SEO meta 注入(仅公开内容)
|
||||
- **笔记**:Markdown CRUD、粘贴/拖拽图片上传(≤5MB、魔数校验、BLOB 入库去重)、公开/私有两态、置顶、标签、发布日期可自选
|
||||
- **博客**:首页(置顶优先 + 分页 + 标签云)、详情(上一篇/下一篇)、标签页、RSS、sitemap、SEO meta 注入(仅公开内容);站点名称前可显示自定义 Logo
|
||||
- **管理**:单管理员口令登录(Argon2id)、会话 7 天滑动续期(CSRF 轮换保持不变)、防爆破限流(per-IP + per-账号)、改密
|
||||
- **回收站**:删除 = 软删除,30 天内可恢复,`gc` 到期物理清除
|
||||
- **界面**:9 页面响应式 SPA(React 19 + Tailwind 4),深色/浅色/跟随系统三态主题
|
||||
- **运维**:`init` / `passwd`(重设口令并吊销全部会话)/ `start` / `backup`(在线一致快照)/ `gc`(默认 dry-run)/ `version`(亦可 `-v`)
|
||||
- **回收站**:删除 = 软删除,30 天内可恢复,可一键清空,`gc` 到期物理清除
|
||||
- **界面**:9 页面响应式 SPA(React 19 + Tailwind 4 + Ant Design 6),深色/浅色/跟随系统三态主题
|
||||
- **运维**:`init`(含 Markdown 示例文档与示例图片种子)/ `passwd`(重设口令并吊销全部会话)/ `start` / `backup`(在线一致快照)/ `gc`(默认 dry-run)/ `version`(亦可 `-v`)
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
# 1. 构建单二进制(前端 + 后端)
|
||||
make build
|
||||
|
||||
# 2. 初始化(设置管理员口令与站点标题)
|
||||
# 2. 初始化(设置管理员口令与站点标题,自动写入一篇 Markdown 示例文档)
|
||||
./pn init
|
||||
# 非交互:PN_ADMIN_PASSWORD=xxx PN_SITE_TITLE=yyy ./pn init
|
||||
|
||||
|
||||
@@ -226,6 +226,10 @@ func runInit(args []string) error {
|
||||
if err := st.SetSetting(store.KeySiteTitle, title); err != nil {
|
||||
return err
|
||||
}
|
||||
// 种子内容:一篇 Markdown 语法示例文档(公开)+ 自动生成的示例图片
|
||||
if _, err := seedWelcome(st); err != nil {
|
||||
return fmt.Errorf("写入初始文档失败: %w", err)
|
||||
}
|
||||
fmt.Println("初始化完成。现在可以启动:pn start --data-dir", cfg.DataDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
// init 种子内容:一篇展示常用 Markdown 语法的初始文档 + 一张程序生成的示例图片。
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"time"
|
||||
|
||||
"pure-note/internal/store"
|
||||
)
|
||||
|
||||
// welcomeSlug 固定 slug,便于用户识别与重装后覆盖判断(init 幂等由口令守卫保证)。
|
||||
const welcomeSlug = "welcome"
|
||||
|
||||
// seedWelcome 写入初始文档(公开)与其引用的示例图片;返回写入的笔记 id。
|
||||
func seedWelcome(st *store.Store) (int64, error) {
|
||||
imgID, err := seedWelcomeImage(st)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("写入示例图片: %w", err)
|
||||
}
|
||||
content := welcomeMarkdown(imgID)
|
||||
now := time.Now().Unix()
|
||||
n := &store.Note{
|
||||
Slug: welcomeSlug,
|
||||
Title: "欢迎使用 Pure Note:Markdown 速览",
|
||||
Summary: "一篇入门笔记,覆盖日常写作会用到的全部 Markdown 语法:标题、列表、引用、代码、表格与图片。",
|
||||
Content: content,
|
||||
Status: "public",
|
||||
Tags: []string{"Markdown", "入门"},
|
||||
PublishedAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
id, err := st.CreateNote(n)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := st.RebuildImageRefs(id, content); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// welcomeMarkdown 初始文档正文:覆盖常用语法,并引用示例图片。
|
||||
func welcomeMarkdown(imgID int64) string {
|
||||
return fmt.Sprintf(`这是一篇初始化时自动创建的示例笔记,覆盖日常写作会用到的绝大部分 Markdown 语法。随时可以编辑或删除它。
|
||||
|
||||
## 基础排版
|
||||
|
||||
普通段落之间用一个空行分隔。行内语法有:**加粗**、*斜体*、***粗斜体***、~~删除线~~,以及行内代码 `+"`pn start`"+`。也可以直接粘贴链接:<https://github.com>
|
||||
|
||||
## 标题层级
|
||||
|
||||
### 三级标题
|
||||
|
||||
#### 四级标题
|
||||
|
||||
标题会进入文章大纲,正文建议从二级标题用起。
|
||||
|
||||
## 列表
|
||||
|
||||
无序列表:
|
||||
|
||||
- 支持嵌套
|
||||
- 子项缩进两个空格
|
||||
- 快捷键 `+"`Ctrl/⌘ + S`"+` 可手动保存
|
||||
|
||||
有序列表:
|
||||
|
||||
1. 在编辑器里写 Markdown
|
||||
2. 粘贴图片直接上传
|
||||
3. 点击发布即可访问
|
||||
|
||||
任务列表:
|
||||
|
||||
- [x] 初始化站点
|
||||
- [x] 阅读本文档
|
||||
- [ ] 写第一篇笔记
|
||||
|
||||
## 引用
|
||||
|
||||
> 简单,可靠,只有一个数据文件。
|
||||
> —— Pure Note 设计哲学
|
||||
|
||||
## 代码块
|
||||
|
||||
代码块支持语法高亮:
|
||||
|
||||
`+"```go"+`
|
||||
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func main() {
|
||||
fmt.Println("Hello, Pure Note!")
|
||||
}
|
||||
|
||||
`+"```"+`
|
||||
|
||||
`+"```"+`javascript
|
||||
const note = { title: "你好", tags: ["Markdown"] };
|
||||
console.log(`+"`开始写作:${note.title}`"+`);
|
||||
`+"```"+`
|
||||
|
||||
## 表格
|
||||
|
||||
| 语法 | 用途 | 示例 |
|
||||
| --- | --- | --- |
|
||||
| **文本** | 加粗 | **重要** |
|
||||
| `+"`代码`"+` | 行内代码 | `+"`npm run dev`"+` |
|
||||
| [文本](url) | 超链接 | [首页](/) |
|
||||
|
||||
## 图片
|
||||
|
||||
在编辑器里直接粘贴或拖入图片即可上传(≤5MB),下面这张就是初始化时自动生成的示例图:
|
||||
|
||||

|
||||
|
||||
## 其他
|
||||
|
||||
---
|
||||
|
||||
私有笔记仅自己可见,公开笔记会出现在首页与 RSS 中;删除的笔记进入回收站保留 30 天。祝写作愉快。
|
||||
`, imgID)
|
||||
}
|
||||
|
||||
// seedWelcomeImage 生成一张对角渐变 PNG(zinc 色系),入库后返回图片 id。
|
||||
func seedWelcomeImage(st *store.Store) (int64, error) {
|
||||
data, err := gradientPNG()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return st.UpsertImage(sha256Hex(data), "image/png", len(data), data, time.Now().Unix())
|
||||
}
|
||||
|
||||
// gradientPNG 绘制 960×360 对角渐变:zinc-950 → zinc-400,叠加一条高光斜带。
|
||||
func gradientPNG() ([]byte, error) {
|
||||
const w, h = 960, 360
|
||||
img := image.NewNRGBA(image.Rect(0, 0, w, h))
|
||||
from := color.RGBA{R: 24, G: 24, B: 27, A: 255} // #18181b
|
||||
to := color.RGBA{R: 161, G: 161, B: 170, A: 255} // #a1a1aa
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
t := (float64(x)/float64(w-1) + float64(y)/float64(h-1)) / 2
|
||||
// 高光斜带:在 t≈0.55 附近提亮,形成柔和的光泽过渡
|
||||
band := 0.16 * float64(1) / (1 + pow2((t-0.55)*9))
|
||||
c := color.RGBA{
|
||||
R: lerp(from.R, to.R, t+band),
|
||||
G: lerp(from.G, to.G, t+band),
|
||||
B: lerp(from.B, to.B, t+band),
|
||||
A: 255,
|
||||
}
|
||||
img.Set(x, y, c)
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func lerp(a, b uint8, t float64) uint8 {
|
||||
if t < 0 {
|
||||
t = 0
|
||||
} else if t > 1 {
|
||||
t = 1
|
||||
}
|
||||
return uint8(float64(a) + (float64(b)-float64(a))*t)
|
||||
}
|
||||
|
||||
func pow2(x float64) float64 { return x * x }
|
||||
|
||||
func sha256Hex(b []byte) string {
|
||||
sum := sha256.Sum256(b)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
+10
-8
@@ -58,8 +58,8 @@
|
||||
| F3 | 图片 | 编辑器内粘贴/拖拽上传(≤5MB);Markdown 中引用;图片可见性 = 引用它的笔记可见性的并集;去重存储 |
|
||||
| F4 | 博客 | 匿名访问:首页(公开列表+分页+标签)、详情页、标签页;RSS Feed;sitemap.xml;SEO 基础(仅公开笔记注入 title/description/og meta) |
|
||||
| F5 | 认证 | 单管理员密码登录/登出/**改密**;会话失效/续期;登录与改密防爆破 |
|
||||
| F6 | 站点设置 | 站点标题、副标题、每页条数(白名单字段,绝不含口令哈希) |
|
||||
| F7 | 回收站 | 删除 = 软删除进入回收站;30 天内可恢复;`gc` 到期物理清除 |
|
||||
| F6 | 站点设置 | 站点标题、副标题、每页条数(10/20/30/50)、站点 Logo(白名单字段,绝不含口令哈希) |
|
||||
| F7 | 回收站 | 删除 = 软删除进入回收站;30 天内可恢复;可一键清空;`gc` 到期物理清除 |
|
||||
| F8 | 运维 | 单二进制启动;`--data-dir` 指定数据目录;在线备份子命令;`gc` 子命令(默认 dry-run);版本号 |
|
||||
|
||||
### 2.2 非功能需求
|
||||
@@ -232,7 +232,8 @@ CREATE TABLE IF NOT EXISTS notes (
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
deleted_at INTEGER, -- NULL=正常;非空=回收站(软删除)
|
||||
created_at INTEGER NOT NULL, -- Unix 秒
|
||||
updated_at INTEGER NOT NULL
|
||||
updated_at INTEGER NOT NULL,
|
||||
published_at INTEGER -- 发布日期(可自选);v2 迁移回填为 created_at
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_public
|
||||
ON notes (status, pinned, updated_at DESC);
|
||||
@@ -267,7 +268,7 @@ CREATE TABLE IF NOT EXISTS settings (
|
||||
);
|
||||
-- 键白名单(§7.1 SettingsDTO 同源):
|
||||
-- admin_password_hash : PHC 串 $argon2id$v=19$m=19456,t=2,p=1$<b64salt>$<b64hash>
|
||||
-- site_title / site_desc / page_size
|
||||
-- site_title / site_desc / page_size / beian_no / site_logo(站内绝对路径)
|
||||
-- admin_password_hash 永不进入任何 API 响应、不可经 settings 接口写入(§9.3)
|
||||
```
|
||||
|
||||
@@ -326,10 +327,11 @@ CREATE TABLE IF NOT EXISTS settings (
|
||||
| PUT | `/api/admin/notes/{id}` | 更新(事务内重建 image_refs);slug 冲突返回 409 + 字段级错误 |
|
||||
| DELETE | `/api/admin/notes/{id}` | **软删除**:置 `deleted_at` 进入回收站 |
|
||||
| GET | `/api/admin/trash` | 回收站列表 |
|
||||
| DELETE | `/api/admin/trash` | **清空回收站**:物理删除全部软删除笔记(refs 级联) |
|
||||
| POST | `/api/admin/trash/{id}/restore` | 恢复:清空 `deleted_at`(slug 仍被自身占用,无冲突;外部占用则在 30 天内不可能,见 §6.2) |
|
||||
| POST | `/api/admin/images` | multipart 上传 → `{id, url}`;≤5MB |
|
||||
| GET | `/api/admin/images?orphan=1` | 0 引用图片清单(删除前检视,实际清除由 gc 执行) |
|
||||
| GET/PUT | `/api/admin/settings` | **SettingsDTO 白名单**(site_title/site_desc/page_size),读永不序列化 `admin_password_hash`、写只收白名单键 |
|
||||
| GET/PUT | `/api/admin/settings` | **SettingsDTO 白名单**(site_title/site_desc/page_size/beian_no/site_logo;page_size 仅 10/20/30/50),读永不序列化 `admin_password_hash`、写只收白名单键 |
|
||||
| POST | `/api/admin/password` | `{old_password, new_password}`;校验旧密码(常量时间)、新密码 ≥12 字符;复用登录限流 |
|
||||
|
||||
### 7.2 中间件链与服务器参数
|
||||
@@ -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 init` | 首次初始化:设口令(Argon2id+PHC)、站点标题 |
|
||||
| `pn init` | 首次初始化:设口令(Argon2id+PHC)、站点标题;并写入一篇 Markdown 语法示例文档(公开,slug `welcome`)及程序生成的示例图片 |
|
||||
| `pn passwd` | 重设管理员口令(覆盖旧哈希)并吊销全部会话;口令来源同 `init`(环境变量 `PN_ADMIN_PASSWORD` 或交互输入,不经 argv 避免 ps 泄露)。CLI 可达即具备服务器权限,允许直接重设 |
|
||||
| `pn backup [path]` | 在线备份:`VACUUM INTO`(一致快照,不停服);默认输出 0600 权限 |
|
||||
| `pn gc` | 回收站过期清除 + 孤儿图片清除 + 会话清理;**默认 `--dry-run`**,`--commit` 才执行(§6.3) |
|
||||
@@ -398,8 +400,8 @@ SecurityHeaders(含 HSTS,§9.2)
|
||||
| `/tags/:tag` | 标签页 | 该标签下公开笔记 |
|
||||
| `/admin/login` | 登录页 | 口令登录、防爆破提示 |
|
||||
| `/admin` | 管理列表 | 全部笔记(公/私标签可见)、状态开关、新建、删除(入回收站) |
|
||||
| `/admin/trash` | 回收站 | 已删列表、恢复 |
|
||||
| `/admin/notes/new` `/admin/notes/:id/edit` | 编辑器 | CodeMirror 源码编辑 + 实时预览(分屏/切换)、元信息侧栏(标题/slug/标签/摘要/公开开关/置顶)、图片粘贴上传 |
|
||||
| `/admin/trash` | 回收站 | 已删列表、恢复、一键清空 |
|
||||
| `/admin/notes/new` `/admin/notes/:id/edit` | 编辑器 | CodeMirror 源码编辑 + 实时预览(分屏/切换)、元信息侧栏(标题/slug/标签选择器/发布日期/摘要/公开开关/置顶)、图片粘贴上传 |
|
||||
| `*` | 404 | 统一兜底 |
|
||||
|
||||
### 8.2 核心技术要点
|
||||
|
||||
@@ -37,6 +37,7 @@ type adminNoteItem struct {
|
||||
DeletedAt *int64 `json:"deleted_at,omitempty"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
PublishedAt int64 `json:"published_at"`
|
||||
}
|
||||
|
||||
func toAdminItem(n *store.Note) adminNoteItem {
|
||||
@@ -44,6 +45,7 @@ func toAdminItem(n *store.Note) adminNoteItem {
|
||||
ID: n.ID, Slug: n.Slug, Title: n.Title, Summary: n.Summary,
|
||||
Status: n.Status, Tags: n.Tags, Pinned: n.Pinned,
|
||||
DeletedAt: n.DeletedAt, CreatedAt: n.CreatedAt, UpdatedAt: n.UpdatedAt,
|
||||
PublishedAt: n.PublishedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +63,7 @@ func (s *Server) handleAdminNotes(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": len(items)})
|
||||
}
|
||||
|
||||
// notePayload 笔记写请求体。
|
||||
// notePayload 笔记写请求体。PublishedAt 为发布日期(Unix 秒);nil = 创建取当前时刻 / 更新保持不变。
|
||||
type notePayload struct {
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
@@ -70,6 +72,7 @@ type notePayload struct {
|
||||
Status string `json:"status"`
|
||||
Tags []string `json:"tags"`
|
||||
Pinned bool `json:"pinned"`
|
||||
PublishedAt *int64 `json:"published_at"`
|
||||
}
|
||||
|
||||
func (p *notePayload) validate() (string, string) { // 返回 (错误码, 消息)
|
||||
@@ -106,10 +109,14 @@ func (s *Server) handleAdminNoteCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.TrimSpace(summary) == "" {
|
||||
summary = markdown.Summary(p.Content, 200)
|
||||
}
|
||||
publishedAt := now
|
||||
if p.PublishedAt != nil && *p.PublishedAt > 0 {
|
||||
publishedAt = *p.PublishedAt
|
||||
}
|
||||
n := &store.Note{
|
||||
Slug: slug, Title: strings.TrimSpace(p.Title), Summary: summary,
|
||||
Content: p.Content, Status: p.Status, Tags: store.NormalizeTags(p.Tags),
|
||||
Pinned: p.Pinned, CreatedAt: now, UpdatedAt: now,
|
||||
Pinned: p.Pinned, PublishedAt: publishedAt, CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
id, err := s.st.CreateNote(n)
|
||||
if err != nil {
|
||||
@@ -203,6 +210,9 @@ func (s *Server) handleAdminNoteUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
n.Tags = store.NormalizeTags(p.Tags)
|
||||
n.Pinned = p.Pinned
|
||||
n.Content = p.Content
|
||||
if p.PublishedAt != nil && *p.PublishedAt > 0 {
|
||||
n.PublishedAt = *p.PublishedAt
|
||||
}
|
||||
if strings.TrimSpace(p.Summary) == "" {
|
||||
n.Summary = markdown.Summary(p.Content, 200)
|
||||
} else {
|
||||
@@ -253,6 +263,17 @@ func (s *Server) handleAdminTrash(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": len(items)})
|
||||
}
|
||||
|
||||
// handleAdminTrashEmpty DELETE /api/admin/trash:清空回收站(物理删除全部,§6.2)。
|
||||
func (s *Server) handleAdminTrashEmpty(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := s.st.EmptyTrash()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||
return
|
||||
}
|
||||
s.log.Info("admin_action", "op", "trash.empty", "count", n)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "deleted": n})
|
||||
}
|
||||
|
||||
// handleAdminTrashRestore POST /api/admin/trash/{id}/restore:恢复(清空 deleted_at)。
|
||||
func (s *Server) handleAdminTrashRestore(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathID(r)
|
||||
@@ -397,6 +418,7 @@ type settingsDTO struct {
|
||||
SiteDesc *string `json:"site_desc"`
|
||||
PageSize *int `json:"page_size"`
|
||||
BeianNo *string `json:"beian_no"`
|
||||
SiteLogo *string `json:"site_logo"`
|
||||
}
|
||||
|
||||
// handleAdminSettingsGet GET /api/admin/settings:白名单三键;永不序列化 admin_password_hash。
|
||||
@@ -415,7 +437,7 @@ func (s *Server) handleAdminSettingsPut(w http.ResponseWriter, r *http.Request)
|
||||
dec.DisallowUnknownFields()
|
||||
var dto settingsDTO
|
||||
if err := dec.Decode(&dto); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "包含未知字段或类型不合法(白名单:site_title/site_desc/page_size/beian_no)")
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "包含未知字段或类型不合法(白名单:site_title/site_desc/page_size/beian_no/site_logo)")
|
||||
return
|
||||
}
|
||||
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 < 1 || *dto.PageSize > 100 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "page_size ∈ [1,100]")
|
||||
if !store.ValidPageSize(*dto.PageSize) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "page_size 仅支持 10/20/30/50")
|
||||
return
|
||||
}
|
||||
if err := s.st.SetSetting(store.KeyPageSize, strconv.Itoa(*dto.PageSize)); err != nil {
|
||||
@@ -460,6 +482,18 @@ func (s *Server) handleAdminSettingsPut(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
}
|
||||
if dto.SiteLogo != nil {
|
||||
// 仅收站内绝对路径(如 /api/images/1):CSP img-src 'self',外链图片本来就无法展示
|
||||
v := strings.TrimSpace(*dto.SiteLogo)
|
||||
if v != "" && (len(v) > 500 || strings.ContainsAny(v, " \t\r\n\"'\\") || !strings.HasPrefix(v, "/")) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "站点 Logo 须为站内绝对路径(如 /api/images/1)或留空")
|
||||
return
|
||||
}
|
||||
if err := s.st.SetSetting(store.KeySiteLogo, v); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
|
||||
return
|
||||
}
|
||||
}
|
||||
// admin_password_hash 不可经此接口写入(§9.3):不在白名单结构体中,天然拒绝。
|
||||
s.log.Info("admin_action", "op", "settings.update")
|
||||
ss, err := s.st.GetSiteSettings()
|
||||
|
||||
@@ -284,7 +284,7 @@ func TestSettingsWhitelist(t *testing.T) {
|
||||
}
|
||||
// PUT 合法键
|
||||
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 {
|
||||
t.Errorf("合法 PUT 应 200: %d", resp.StatusCode)
|
||||
}
|
||||
@@ -311,14 +311,16 @@ func TestSettingsWhitelist(t *testing.T) {
|
||||
}
|
||||
// page_size 生效为公开列表默认
|
||||
_, body = e.get(e.client(), "/api/notes")
|
||||
if !strings.Contains(string(body), `"page_size":5`) {
|
||||
if !strings.Contains(string(body), `"page_size":20`) {
|
||||
t.Errorf("page_size 设置应生效: %s", body)
|
||||
}
|
||||
// page_size 越界
|
||||
// page_size 越界/非候选项
|
||||
for _, bad := range []string{"0", "5", "101"} {
|
||||
resp, _ = e.do(admin, http.MethodPut, "/api/admin/settings",
|
||||
[]byte(`{"page_size":0}`), e.adminHeaders())
|
||||
[]byte(`{"page_size":`+bad+`}`), e.adminHeaders())
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("page_size=0 应 400: %d", resp.StatusCode)
|
||||
t.Errorf("page_size=%s 应 400: %d", bad, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ type publicNoteItem struct {
|
||||
Pinned bool `json:"pinned"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
PublishedAt int64 `json:"published_at"`
|
||||
}
|
||||
|
||||
// handlePublicNotes GET /api/notes:公开笔记列表(可见性过滤在查询层,§9.1-T10)。
|
||||
@@ -70,7 +71,7 @@ func (s *Server) handlePublicNotes(w http.ResponseWriter, r *http.Request) {
|
||||
items = append(items, publicNoteItem{
|
||||
Slug: n.Slug, Title: n.Title, Summary: n.Summary,
|
||||
Tags: n.Tags, Pinned: n.Pinned,
|
||||
CreatedAt: n.CreatedAt, UpdatedAt: n.UpdatedAt,
|
||||
CreatedAt: n.CreatedAt, UpdatedAt: n.UpdatedAt, PublishedAt: n.PublishedAt,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
@@ -119,6 +120,7 @@ func (s *Server) handlePublicNote(w http.ResponseWriter, r *http.Request) {
|
||||
"summary": note.Summary, "content": note.Content,
|
||||
"status": note.Status, "tags": note.Tags, "pinned": note.Pinned,
|
||||
"created_at": note.CreatedAt, "updated_at": note.UpdatedAt,
|
||||
"published_at": note.PublishedAt,
|
||||
"prev": siblingOrEmpty(prevSlug, prevTitle),
|
||||
"next": siblingOrEmpty(nextSlug, nextTitle),
|
||||
})
|
||||
|
||||
@@ -93,6 +93,7 @@ func (s *Server) Handler(ui http.Handler) http.Handler {
|
||||
adminMux.Handle("PUT /api/admin/notes/{id}", middleware.MaxBytes(maxNoteBody)(http.HandlerFunc(s.handleAdminNoteUpdate)))
|
||||
adminMux.HandleFunc("DELETE /api/admin/notes/{id}", s.handleAdminNoteDelete)
|
||||
adminMux.HandleFunc("GET /api/admin/trash", s.handleAdminTrash)
|
||||
adminMux.HandleFunc("DELETE /api/admin/trash", s.handleAdminTrashEmpty)
|
||||
adminMux.HandleFunc("POST /api/admin/trash/{id}/restore", s.handleAdminTrashRestore)
|
||||
adminMux.Handle("POST /api/admin/images", middleware.MaxBytes(maxUploadBody)(http.HandlerFunc(s.handleAdminImageUpload)))
|
||||
adminMux.HandleFunc("GET /api/admin/images", s.handleAdminImages)
|
||||
|
||||
@@ -101,11 +101,13 @@ func rebuildRefsTx(tx *sql.Tx, noteID int64, content string) error {
|
||||
}
|
||||
|
||||
// ListOrphanImages 零引用图片清单(GET /api/admin/images?orphan=1 与 gc 检视用)。
|
||||
// 站点 Logo 引用的图片虽无笔记引用仍受保护,不视为孤儿。
|
||||
func (s *Store) ListOrphanImages() ([]Image, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT i.id, i.sha256, i.mime, i.size, i.created_at FROM images i
|
||||
WHERE NOT EXISTS (SELECT 1 FROM image_refs r WHERE r.image_id = i.id)
|
||||
ORDER BY i.created_at DESC`)
|
||||
AND i.id <> ?
|
||||
ORDER BY i.created_at DESC`, s.logoImageID())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -54,12 +54,14 @@ func (s *Store) GC(now time.Time, dryRun bool) (*GCReport, error) {
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
// 孤儿图片:0 引用且超过宽限期
|
||||
// 孤儿图片:0 引用且超过宽限期;站点 Logo 引用的图片受保护
|
||||
imageCutoff := now.Add(-OrphanGrace).Unix()
|
||||
logoID := s.logoImageID()
|
||||
orphanRows, err := s.db.Query(`
|
||||
SELECT i.id, i.sha256, i.mime, i.size, i.created_at FROM images i
|
||||
WHERE i.created_at < ? AND NOT EXISTS (SELECT 1 FROM image_refs r WHERE r.image_id = i.id)
|
||||
ORDER BY i.created_at`, imageCutoff)
|
||||
AND i.id <> ?
|
||||
ORDER BY i.created_at`, imageCutoff, logoID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -101,9 +103,10 @@ func (s *Store) GC(now time.Time, dryRun bool) (*GCReport, error) {
|
||||
}
|
||||
}
|
||||
for _, img := range rep.OrphanImages {
|
||||
// 条件复查含 Logo 保护:SELECT 与 DELETE 之间 Logo 可能指向该图
|
||||
res, err := s.db.Exec(
|
||||
`DELETE FROM images WHERE id=? AND NOT EXISTS (SELECT 1 FROM image_refs WHERE image_id=?)`,
|
||||
img.ID, img.ID)
|
||||
`DELETE FROM images WHERE id=? AND NOT EXISTS (SELECT 1 FROM image_refs WHERE image_id=?) AND id<>?`,
|
||||
img.ID, img.ID, logoID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("删除孤儿图片 %d: %w", img.ID, err)
|
||||
}
|
||||
|
||||
+22
-10
@@ -9,6 +9,7 @@ import (
|
||||
)
|
||||
|
||||
// Note 笔记实体。DeletedAt 非 nil 表示处于回收站(软删除)。
|
||||
// PublishedAt 为对外展示的发布日期(可自选,§8.2);排序仍按 updated_at。
|
||||
type Note struct {
|
||||
ID int64 `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
@@ -21,19 +22,21 @@ type Note struct {
|
||||
DeletedAt *int64 `json:"deleted_at,omitempty"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
PublishedAt int64 `json:"published_at"`
|
||||
}
|
||||
|
||||
// ErrNotFound 统一的「不存在」错误。
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
const noteColumns = "id, slug, title, summary, content, status, tags, pinned, deleted_at, created_at, updated_at"
|
||||
// published_at 经 v2 迁移回填,理论上恒非 NULL;COALESCE 兜底历史异常行。
|
||||
const noteColumns = "id, slug, title, summary, content, status, tags, pinned, deleted_at, created_at, updated_at, COALESCE(published_at, created_at) AS published_at"
|
||||
|
||||
func scanNote(scan func(dest ...any) error) (*Note, error) {
|
||||
var n Note
|
||||
var tagsJSON string
|
||||
var pinned int
|
||||
var content string
|
||||
if err := scan(&n.ID, &n.Slug, &n.Title, &n.Summary, &content, &n.Status, &tagsJSON, &pinned, &n.DeletedAt, &n.CreatedAt, &n.UpdatedAt); err != nil {
|
||||
if err := scan(&n.ID, &n.Slug, &n.Title, &n.Summary, &content, &n.Status, &tagsJSON, &pinned, &n.DeletedAt, &n.CreatedAt, &n.UpdatedAt, &n.PublishedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n.Content = content
|
||||
@@ -54,9 +57,9 @@ func (s *Store) CreateNote(n *Note) (int64, error) {
|
||||
return 0, err
|
||||
}
|
||||
res, err := s.db.Exec(
|
||||
`INSERT INTO notes (slug, title, summary, content, status, tags, pinned, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
n.Slug, n.Title, n.Summary, n.Content, n.Status, tags, boolToInt(n.Pinned), n.CreatedAt, n.UpdatedAt)
|
||||
`INSERT INTO notes (slug, title, summary, content, status, tags, pinned, published_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
n.Slug, n.Title, n.Summary, n.Content, n.Status, tags, boolToInt(n.Pinned), n.PublishedAt, n.CreatedAt, n.UpdatedAt)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -70,8 +73,8 @@ func (s *Store) UpdateNote(n *Note) error {
|
||||
return err
|
||||
}
|
||||
res, err := s.db.Exec(
|
||||
`UPDATE notes SET title=?, summary=?, content=?, status=?, tags=?, pinned=?, updated_at=? WHERE id=? AND deleted_at IS NULL`,
|
||||
n.Title, n.Summary, n.Content, n.Status, tags, boolToInt(n.Pinned), n.UpdatedAt, n.ID)
|
||||
`UPDATE notes SET title=?, summary=?, content=?, status=?, tags=?, pinned=?, published_at=?, updated_at=? WHERE id=? AND deleted_at IS NULL`,
|
||||
n.Title, n.Summary, n.Content, n.Status, tags, boolToInt(n.Pinned), n.PublishedAt, n.UpdatedAt, n.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -164,7 +167,7 @@ func (s *Store) ListPublicNotes(page, pageSize int, tag string) ([]Note, int, er
|
||||
if err := s.db.QueryRow(`SELECT COUNT(*) FROM notes n WHERE `+where, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
q := `SELECT n.id, n.slug, n.title, n.summary, '' AS content, n.status, n.tags, n.pinned, n.deleted_at, n.created_at, n.updated_at
|
||||
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, COALESCE(n.published_at, n.created_at) AS published_at
|
||||
FROM notes n WHERE ` + where + `
|
||||
ORDER BY n.pinned DESC, n.updated_at DESC, n.id DESC LIMIT ? OFFSET ?`
|
||||
rows, err := s.db.Query(q, append(args, pageSize, (page-1)*pageSize)...)
|
||||
@@ -234,7 +237,7 @@ func (s *Store) ListPublicNotesFull(limit int) ([]Note, error) {
|
||||
|
||||
// ListAdminNotes 全部正常笔记(含私有,不含回收站),不含全文。
|
||||
func (s *Store) ListAdminNotes() ([]Note, error) {
|
||||
q := `SELECT id, slug, title, summary, '' AS content, status, tags, pinned, deleted_at, created_at, updated_at
|
||||
q := `SELECT id, slug, title, summary, '' AS content, status, tags, pinned, deleted_at, created_at, updated_at, COALESCE(published_at, created_at) AS published_at
|
||||
FROM notes WHERE deleted_at IS NULL
|
||||
ORDER BY pinned DESC, updated_at DESC, id DESC`
|
||||
return s.queryNotes(q)
|
||||
@@ -242,12 +245,21 @@ func (s *Store) ListAdminNotes() ([]Note, error) {
|
||||
|
||||
// ListTrash 回收站列表(软删除中),不含全文。
|
||||
func (s *Store) ListTrash() ([]Note, error) {
|
||||
q := `SELECT id, slug, title, summary, '' AS content, status, tags, pinned, deleted_at, created_at, updated_at
|
||||
q := `SELECT id, slug, title, summary, '' AS content, status, tags, pinned, deleted_at, created_at, updated_at, COALESCE(published_at, created_at) AS published_at
|
||||
FROM notes WHERE deleted_at IS NOT NULL
|
||||
ORDER BY deleted_at DESC`
|
||||
return s.queryNotes(q)
|
||||
}
|
||||
|
||||
// EmptyTrash 物理删除回收站全部笔记(refs 级联,需 foreign_keys=1),返回删除数。
|
||||
func (s *Store) EmptyTrash() (int64, error) {
|
||||
res, err := s.db.Exec(`DELETE FROM notes WHERE deleted_at IS NOT NULL`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
// ListExpiredTrash 过期待物理删除的笔记 id(gc 用)。
|
||||
func (s *Store) ListExpiredTrash(before int64) ([]int64, error) {
|
||||
rows, err := s.db.Query(`SELECT id FROM notes WHERE deleted_at IS NOT NULL AND deleted_at < ?`, before)
|
||||
|
||||
@@ -14,6 +14,7 @@ const (
|
||||
KeySiteDesc = "site_desc"
|
||||
KeyPageSize = "page_size"
|
||||
KeyBeianNo = "beian_no"
|
||||
KeySiteLogo = "site_logo"
|
||||
)
|
||||
|
||||
// GetSetting 读取单个设置。
|
||||
@@ -39,6 +40,7 @@ type SiteSettings struct {
|
||||
SiteDesc string `json:"site_desc"`
|
||||
PageSize int `json:"page_size"`
|
||||
BeianNo string `json:"beian_no"`
|
||||
SiteLogo string `json:"site_logo"`
|
||||
}
|
||||
|
||||
// Defaults,未初始化时兜底。
|
||||
@@ -49,6 +51,15 @@ const (
|
||||
DefaultBeianNo = ""
|
||||
)
|
||||
|
||||
// ValidPageSize 每页条数可选项(前端选择器同源:10/20/30/50)。
|
||||
func ValidPageSize(n int) bool {
|
||||
switch n {
|
||||
case 10, 20, 30, 50:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetSiteSettings 读取站点设置(白名单键,带默认值)。
|
||||
func (s *Store) GetSiteSettings() (*SiteSettings, error) {
|
||||
ss := &SiteSettings{SiteTitle: DefaultSiteTitle, SiteDesc: DefaultSiteDesc, PageSize: DefaultPageSize, BeianNo: DefaultBeianNo}
|
||||
@@ -65,8 +76,9 @@ func (s *Store) GetSiteSettings() (*SiteSettings, error) {
|
||||
if v, ok, err := s.GetSetting(KeyPageSize); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
// strconv.Atoi 全文解析:拒绝 "10abc" 类部分解析的脏值(评审 round2 P2-13)
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n >= 1 && n <= 100 {
|
||||
// strconv.Atoi 全文解析:拒绝 "10abc" 类部分解析的脏值(评审 round2 P2-13);
|
||||
// 脏值/越界值回退默认,而非落入任意 [1,100]
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && ValidPageSize(n) {
|
||||
ss.PageSize = n
|
||||
}
|
||||
}
|
||||
@@ -75,5 +87,25 @@ func (s *Store) GetSiteSettings() (*SiteSettings, error) {
|
||||
} else if ok {
|
||||
ss.BeianNo = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok, err := s.GetSetting(KeySiteLogo); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
ss.SiteLogo = strings.TrimSpace(v)
|
||||
}
|
||||
return ss, nil
|
||||
}
|
||||
|
||||
// logoImageID 从 site_logo 设置解析受 GC 保护的图片 id(无 logo 或非本站图片路径返回 0)。
|
||||
func (s *Store) logoImageID() int64 {
|
||||
v, ok, err := s.GetSetting(KeySiteLogo)
|
||||
if err != nil || !ok || v == "" {
|
||||
return 0
|
||||
}
|
||||
if m := imageRefRe.FindStringSubmatch(v); m != nil {
|
||||
var id int64
|
||||
if _, err := fmtSscanInt(m[1], &id); err == nil && id > 0 {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -69,6 +69,11 @@ CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
`,
|
||||
// v2: 笔记发布日期(可自选);存量笔记回填为 created_at
|
||||
`
|
||||
ALTER TABLE notes ADD COLUMN published_at INTEGER;
|
||||
UPDATE notes SET published_at = created_at WHERE published_at IS NULL;
|
||||
`,
|
||||
}
|
||||
|
||||
|
||||
@@ -64,12 +64,16 @@ function ThemeButton({ className = 'pn-site-navlink' }: { className?: string })
|
||||
export function BlogLayout() {
|
||||
const { data } = useSiteTitle()
|
||||
const title = data?.site_title ?? 'Pure Note'
|
||||
const logo = data?.site_logo ?? ''
|
||||
const location = useLocation()
|
||||
return (
|
||||
<div className="pn-site-page">
|
||||
<header className="pn-site-header">
|
||||
<div className="pn-site-header-inner">
|
||||
<Link to="/" className="pn-site-wordmark">{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="站点导航">
|
||||
<NavLink to="/" end className="pn-site-navlink hide-sm">
|
||||
<House size={14} />首页
|
||||
|
||||
+64
-71
@@ -408,6 +408,9 @@ button.pn-login-back:hover {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.pn-site-wordmark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 21px;
|
||||
line-height: 1;
|
||||
@@ -419,6 +422,14 @@ button.pn-login-back:hover {
|
||||
.pn-site-wordmark:hover {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1184,19 +1195,6 @@ button.pn-note-back:hover {
|
||||
font-weight: 600;
|
||||
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) */
|
||||
.pn-login-card {
|
||||
box-sizing: border-box;
|
||||
@@ -1438,6 +1436,9 @@ button.pn-note-back:hover {
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 14px 24px;
|
||||
/* 各页 actions 高度不一(有的页无按钮):固定高度,切换页面不抖动 */
|
||||
min-height: 62px;
|
||||
box-sizing: border-box;
|
||||
background: var(--background);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
@@ -1517,6 +1518,7 @@ button.pn-note-back:hover {
|
||||
}
|
||||
.pn-admin-topbar {
|
||||
padding: 12px 16px;
|
||||
min-height: 58px;
|
||||
}
|
||||
.pn-admin-topbar h1 {
|
||||
font-size: 16px;
|
||||
@@ -1721,19 +1723,16 @@ button.pn-note-back:hover {
|
||||
align-items: start;
|
||||
}
|
||||
/* 编辑卡与发布卡的 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 {
|
||||
width: 100%;
|
||||
margin: 0 0 14px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 30px;
|
||||
line-height: 1.3;
|
||||
letter-spacing: var(--tracking-tight);
|
||||
color: var(--foreground);
|
||||
outline: none;
|
||||
}
|
||||
input.pn-ae-title-input::placeholder {
|
||||
color: var(--muted-foreground);
|
||||
@@ -1772,58 +1771,10 @@ input.pn-ae-title-input::placeholder {
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.pn-ae-tag-x {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
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);
|
||||
/* 标签选择器(mode=tags:既可勾选既有标签,也可输入新建) */
|
||||
.pn-ae-tags-select {
|
||||
min-width: 220px;
|
||||
max-width: 100%;
|
||||
}
|
||||
.pn-ae-toolbar {
|
||||
display: flex;
|
||||
@@ -1892,6 +1843,7 @@ button.pn-ae-tool svg {
|
||||
}
|
||||
.pn-ae-plabel {
|
||||
color: var(--muted-foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pn-ae-pvalue {
|
||||
display: inline-flex;
|
||||
@@ -1907,6 +1859,15 @@ button.pn-ae-tool svg {
|
||||
height: 14px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
/* 发布日期选择器(size=small) */
|
||||
.pn-ae-datepicker {
|
||||
flex: none;
|
||||
width: 168px;
|
||||
}
|
||||
.pn-ae-datepicker .ant-input {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
.pn-ae-mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
@@ -1992,7 +1953,39 @@ button.pn-ae-tool svg {
|
||||
站点设置(站点设置.html)
|
||||
============================================================ */
|
||||
.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 {
|
||||
display: flex;
|
||||
|
||||
@@ -88,6 +88,7 @@ export interface SiteSettings {
|
||||
site_desc: string
|
||||
page_size: number
|
||||
beian_no: string
|
||||
site_logo: string
|
||||
}
|
||||
|
||||
export interface NoteItem {
|
||||
@@ -101,6 +102,7 @@ export interface NoteItem {
|
||||
deleted_at?: number | null
|
||||
created_at: number
|
||||
updated_at: number
|
||||
published_at: number
|
||||
}
|
||||
|
||||
export interface NoteDetail extends NoteItem {
|
||||
|
||||
+46
-59
@@ -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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import {
|
||||
Bold, Calendar, CirclePlus, Code, Eye, EyeOff, Italic, Link2, List, Quote,
|
||||
Save, Table, Trash2, X,
|
||||
Bold, Code, Eye, EyeOff, Italic, Link2, List, Quote,
|
||||
Save, Table, Trash2,
|
||||
} from 'lucide-react'
|
||||
import { Button, Card, Divider, Input, Switch, Tag } from 'antd'
|
||||
import { Button, Card, DatePicker, Divider, Input, Select, Switch } from 'antd'
|
||||
import { api, ApiError } from '../lib/api'
|
||||
import type { NoteItem } from '../lib/api'
|
||||
import { invalidatePublic } from '../lib/invalidate'
|
||||
@@ -13,7 +14,7 @@ import { useDocumentTitle } from '../features/site'
|
||||
import MarkdownViewer from '../components/MarkdownViewer'
|
||||
import { AdminTopbar } from '../components/Layout'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import { formatDateISO, slugPreview } from '../lib/utils'
|
||||
import { slugPreview } from '../lib/utils'
|
||||
|
||||
// CodeMirror 体积大,单独拆 chunk,编辑器在首帧后异步加载
|
||||
const Editor = lazy(() => import('../components/Editor'))
|
||||
@@ -32,9 +33,10 @@ type Form = {
|
||||
status: 'public' | 'private'
|
||||
tags: string[]
|
||||
pinned: boolean
|
||||
publishedAt: number
|
||||
}
|
||||
|
||||
const emptyForm: Form = {
|
||||
const emptyForm = (): Form => ({
|
||||
title: '',
|
||||
slug: '',
|
||||
summary: '',
|
||||
@@ -42,7 +44,8 @@ const emptyForm: Form = {
|
||||
status: 'private',
|
||||
tags: [],
|
||||
pinned: false,
|
||||
}
|
||||
publishedAt: Math.floor(Date.now() / 1000),
|
||||
})
|
||||
|
||||
export default function AdminEdit() {
|
||||
const { id } = useParams()
|
||||
@@ -59,17 +62,28 @@ export default function AdminEdit() {
|
||||
enabled: !isNew,
|
||||
})
|
||||
|
||||
// 既有标签(含私有笔记)作为选择器候选项
|
||||
const { data: allNotes } = useQuery({
|
||||
queryKey: ['admin', 'notes'],
|
||||
queryFn: () => api<{ items: NoteItem[] }>('/api/admin/notes'),
|
||||
})
|
||||
|
||||
const [form, setForm] = useState<Form>(emptyForm)
|
||||
const [savedAt, setSavedAt] = useState<number | null>(null)
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null)
|
||||
const [slugFieldError, setSlugFieldError] = useState<string | null>(null)
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
const [uploadMsg, setUploadMsg] = useState<string | null>(null)
|
||||
const [tagInput, setTagInput] = useState<string | null>(null)
|
||||
const dirtyRef = useRef(false)
|
||||
const slugTouchedRef = useRef(false)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const tagOptions = useMemo(() => {
|
||||
const seen = new Set<string>()
|
||||
for (const n of allNotes?.items ?? []) for (const t of n.tags) seen.add(t)
|
||||
for (const t of form.tags) seen.add(t)
|
||||
return [...seen].map((t) => ({ value: t, label: t }))
|
||||
}, [allNotes, form.tags])
|
||||
|
||||
useEffect(() => {
|
||||
if (existing) {
|
||||
setForm({
|
||||
@@ -80,6 +94,7 @@ export default function AdminEdit() {
|
||||
status: existing.status === 'public' ? 'public' : 'private',
|
||||
tags: existing.tags,
|
||||
pinned: existing.pinned,
|
||||
publishedAt: existing.published_at || existing.created_at,
|
||||
})
|
||||
slugTouchedRef.current = false
|
||||
dirtyRef.current = false
|
||||
@@ -104,6 +119,7 @@ export default function AdminEdit() {
|
||||
status: f.status,
|
||||
tags: f.tags,
|
||||
pinned: f.pinned,
|
||||
published_at: f.publishedAt,
|
||||
}
|
||||
if (isNew) {
|
||||
// 新建:slug 由服务端从标题自动生成(首次保存即定稿,§8.2)
|
||||
@@ -118,7 +134,6 @@ export default function AdminEdit() {
|
||||
})
|
||||
},
|
||||
onSuccess: (saved, vars) => {
|
||||
setSavedAt(Date.now())
|
||||
setSlugFieldError(null)
|
||||
setErrorMsg(null)
|
||||
dirtyRef.current = false
|
||||
@@ -171,14 +186,6 @@ export default function AdminEdit() {
|
||||
|
||||
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) {
|
||||
return (
|
||||
<>
|
||||
@@ -214,7 +221,7 @@ export default function AdminEdit() {
|
||||
<div className="pn-ae-grid">
|
||||
<Card className="pn-ae-main min-w-0">
|
||||
<Input
|
||||
variant="borderless"
|
||||
variant="filled"
|
||||
className="pn-ae-title-input"
|
||||
type="text"
|
||||
value={form.title}
|
||||
@@ -229,43 +236,16 @@ export default function AdminEdit() {
|
||||
<span>/notes/{slugDisplay}</span>
|
||||
</span>
|
||||
<div className="pn-ae-tags">
|
||||
{form.tags.map((t) => (
|
||||
<Tag
|
||||
key={t}
|
||||
closable
|
||||
closeIcon={<X size={12} />}
|
||||
onClose={(e) => {
|
||||
e.preventDefault()
|
||||
set('tags', form.tags.filter((x) => x !== t))
|
||||
}}
|
||||
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)
|
||||
}}
|
||||
<Select
|
||||
mode="tags"
|
||||
className="pn-ae-tags-select"
|
||||
value={form.tags}
|
||||
options={tagOptions}
|
||||
placeholder="添加或选择标签"
|
||||
aria-label="标签"
|
||||
tokenSeparators={[',']}
|
||||
onChange={(v) => set('tags', v)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -328,10 +308,17 @@ export default function AdminEdit() {
|
||||
</div>
|
||||
|
||||
<div className="pn-ae-prow">
|
||||
<span className="pn-ae-plabel">日期</span>
|
||||
<span className="pn-ae-pvalue pn-ae-mono">
|
||||
<Calendar size={14} />{formatDateISO(savedAt ? Math.floor(savedAt / 1000) : (existing?.updated_at ?? Math.floor(Date.now() / 1000)))}
|
||||
</span>
|
||||
<span className="pn-ae-plabel">发布日期</span>
|
||||
<DatePicker
|
||||
className="pn-ae-datepicker"
|
||||
size="small"
|
||||
value={dayjs.unix(form.publishedAt)}
|
||||
onChange={(d) => d && set('publishedAt', d.unix())}
|
||||
allowClear={false}
|
||||
showTime={{ format: 'HH:mm' }}
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
aria-label="发布日期"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pn-ae-pfield">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Eye, EyeOff, KeyRound, Lock, Save } from 'lucide-react'
|
||||
import { Button, Card, Divider, Input, InputNumber } from 'antd'
|
||||
import { Eye, EyeOff, ImageUp, KeyRound, Lock, Save, Trash2 } from 'lucide-react'
|
||||
import { Button, Card, Divider, Input, Select, Upload } from 'antd'
|
||||
import { api, ApiError } from '../lib/api'
|
||||
import type { SiteSettings } from '../lib/api'
|
||||
import { invalidatePublic } from '../lib/invalidate'
|
||||
@@ -9,6 +9,8 @@ import { useDocumentTitle } from '../features/site'
|
||||
import { AdminTopbar } from '../components/Layout'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [10, 20, 30, 50].map((n) => ({ value: n, label: `${n} 篇` }))
|
||||
|
||||
export default function AdminSettings() {
|
||||
useDocumentTitle('设置')
|
||||
const qc = useQueryClient()
|
||||
@@ -22,6 +24,7 @@ export default function AdminSettings() {
|
||||
const [desc, setDesc] = useState('')
|
||||
const [beian, setBeian] = useState('')
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [logo, setLogo] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
@@ -29,6 +32,7 @@ export default function AdminSettings() {
|
||||
setDesc(data.site_desc)
|
||||
setBeian(data.beian_no)
|
||||
setPageSize(data.page_size)
|
||||
setLogo(data.site_logo ?? '')
|
||||
}
|
||||
}, [data])
|
||||
|
||||
@@ -36,7 +40,7 @@ export default function AdminSettings() {
|
||||
mutationFn: () =>
|
||||
api('/api/admin/settings', {
|
||||
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: () => {
|
||||
toast({ description: '设置已保存' })
|
||||
@@ -46,6 +50,25 @@ export default function AdminSettings() {
|
||||
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 (
|
||||
<>
|
||||
<AdminTopbar title="站点设置">
|
||||
@@ -96,16 +119,41 @@ export default function AdminSettings() {
|
||||
<p className="pn-as-hint">显示在博客页脚并链接至公安部备案官网;留空则不显示。</p>
|
||||
</div>
|
||||
<div className="pn-as-field">
|
||||
<label htmlFor="page-size" className="pn-field-label">首页每页条数(1–100)</label>
|
||||
<InputNumber
|
||||
<label htmlFor="page-size" className="pn-field-label">首页每页条数</label>
|
||||
<Select
|
||||
id="page-size"
|
||||
min={1}
|
||||
max={100}
|
||||
style={{ width: 140 }}
|
||||
options={PAGE_SIZE_OPTIONS}
|
||||
value={pageSize}
|
||||
onChange={(v) => setPageSize(Number(v ?? 10))}
|
||||
onChange={(v) => setPageSize(v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="pn-as-field">
|
||||
<span className="pn-field-label">站点 Logo</span>
|
||||
<div className="pn-as-logo-row">
|
||||
{logo ? (
|
||||
<img className="pn-as-logo-preview" src={logo} alt="站点 Logo 预览" />
|
||||
) : (
|
||||
<span className="pn-as-logo-empty">未设置</span>
|
||||
)}
|
||||
<Upload
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
showUploadList={false}
|
||||
customRequest={uploadLogo}
|
||||
>
|
||||
<Button icon={<ImageUp size={15} />}>{logo ? '更换图片' : '上传图片'}</Button>
|
||||
</Upload>
|
||||
{logo && (
|
||||
<Button
|
||||
icon={<Trash2 size={15} />}
|
||||
onClick={() => setLogo('')}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="pn-as-hint">显示在博客顶部站点名称前,建议正方形图片;修改后需点击「保存更改」。</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -194,7 +242,6 @@ function ChangePassword({
|
||||
</div>
|
||||
<div className="pn-as-card-foot">
|
||||
<Button
|
||||
size="small"
|
||||
disabled={change.isPending || oldPw.length === 0 || newPw.length === 0}
|
||||
onClick={() => change.mutate()}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { RotateCcw } from 'lucide-react'
|
||||
import { Alert, Button, Card, Table } from 'antd'
|
||||
import { RotateCcw, Trash2 } from 'lucide-react'
|
||||
import { Alert, App, Button, Card, Table } from 'antd'
|
||||
import type { TableProps } from 'antd'
|
||||
import { api } from '../lib/api'
|
||||
import type { NoteItem } from '../lib/api'
|
||||
@@ -14,6 +14,7 @@ export default function AdminTrash() {
|
||||
useDocumentTitle('回收站')
|
||||
const qc = useQueryClient()
|
||||
const { toast } = useToast()
|
||||
const { modal } = App.useApp()
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'trash'],
|
||||
queryFn: () => api<{ items: NoteItem[]; total: number }>('/api/admin/trash'),
|
||||
@@ -29,6 +30,27 @@ export default function AdminTrash() {
|
||||
onError: () => toast({ description: '恢复失败,请重试', variant: 'error' }),
|
||||
})
|
||||
|
||||
const clear = useMutation({
|
||||
mutationFn: () => api<{ deleted: number }>('/api/admin/trash', { method: 'DELETE' }),
|
||||
onSuccess: (res) => {
|
||||
toast({ description: `已清空回收站(${res.deleted} 篇)` })
|
||||
void qc.invalidateQueries({ queryKey: ['admin'] })
|
||||
invalidatePublic(qc)
|
||||
},
|
||||
onError: () => toast({ description: '操作失败,请重试', variant: 'error' }),
|
||||
})
|
||||
|
||||
const confirmEmpty = () => {
|
||||
modal.confirm({
|
||||
title: '清空回收站?',
|
||||
content: `将永久删除回收站中的 ${items.length} 篇笔记,此操作不可恢复。`,
|
||||
okText: '清空',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: () => clear.mutateAsync(),
|
||||
})
|
||||
}
|
||||
|
||||
const items = data?.items ?? []
|
||||
|
||||
const columns: TableProps<NoteItem>['columns'] = [
|
||||
@@ -80,7 +102,16 @@ export default function AdminTrash() {
|
||||
|
||||
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">
|
||||
<Alert
|
||||
|
||||
@@ -26,13 +26,13 @@ function useTags() {
|
||||
function NotePost({ note }: { note: NoteItem }) {
|
||||
return (
|
||||
<article className="pn-home-post">
|
||||
<time className="pn-home-date" dateTime={formatDateISO(note.updated_at)}>
|
||||
<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.updated_at)}
|
||||
{formatDateISO(note.published_at)}
|
||||
</time>
|
||||
<h2 className="pn-home-post-title">
|
||||
<Link to={`/notes/${encodeURIComponent(note.slug)}`}>
|
||||
|
||||
@@ -49,7 +49,6 @@ export default function Login() {
|
||||
<Card className="pn-login-card" styles={{ body: { padding: 0 } }}>
|
||||
<form onSubmit={onSubmit}>
|
||||
<h1 className="pn-login-title">管理员登录</h1>
|
||||
<p className="pn-login-sub">请输入管理密码以继续</p>
|
||||
|
||||
<label htmlFor="pn-login-username" className="sr-only">用户名</label>
|
||||
<input
|
||||
@@ -103,8 +102,6 @@ export default function Login() {
|
||||
<span>{submitting ? '登录中…' : '登录'}</span>
|
||||
<ArrowRight size={16} />
|
||||
</Button>
|
||||
|
||||
<p className="pn-login-hint">连续失败将被暂时锁定</p>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -60,8 +60,8 @@ export default function Note() {
|
||||
<h1 className="pn-note-title">{note.title}</h1>
|
||||
|
||||
<div className="pn-note-meta">
|
||||
<time className="pn-note-meta-date" dateTime={formatDateISO(note.created_at)}>
|
||||
{formatDateISO(note.created_at)}
|
||||
<time className="pn-note-meta-date" dateTime={formatDateISO(note.published_at)}>
|
||||
{formatDateISO(note.published_at)}
|
||||
</time>
|
||||
<span className="pn-note-meta-sep">·</span>
|
||||
<span className="pn-note-meta-read"><Clock size={14} />约 {readingMinutes(note.content)} 分钟阅读</span>
|
||||
|
||||
Reference in New Issue
Block a user