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:
2026-09-09 11:10:30 +08:00
parent 87d9bab5dc
commit 5457aeaa33
25 changed files with 600 additions and 257 deletions
+5 -1
View File
@@ -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
View File
@@ -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;
+2
View File
@@ -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 {
+47 -60
View File
@@ -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">
+56 -9
View File
@@ -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()}
>
+34 -3
View File
@@ -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
+2 -2
View File
@@ -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)}`}>
-3
View File
@@ -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>
+2 -2
View File
@@ -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>