feat(edit): P2 条目就地编辑 + P3 备份/审计/改密

- 迁移 v17:entry.updated_at(乐观锁)+ audit 表
- GET/PATCH /api/items/{key}:字段编辑、标签替换、409 冲突、400 校验
- 详情页 [编辑] → Drawer 表单(标题/简介/标签/评价/正文/封面/跳转),保存后局部刷新
- GET /api/backup:VACUUM INTO 一致性快照,附件下载
- /admin 系统 Tab:修改密码(清空会话)、下载备份
- 审计:item.update / settings.update
- 测试 46 passed(entries 4 例)
- 文档:ADMIN-PLAN P2/P3 记录、README
This commit is contained in:
stash
2026-10-29 12:59:21 +08:00
parent 9ae5a455c5
commit 8f688885ed
12 changed files with 865 additions and 12 deletions
+11
View File
@@ -36,6 +36,7 @@ import {
type SettingsDocument,
} from './settings'
import { LoginDialog } from './components/auth/LoginDialog'
import { ItemEditor } from './components/content/ItemEditor'
import { parseViewPath, viewPath, type View } from './data/view'
import './App.css'
@@ -52,6 +53,7 @@ export default function App() {
settings: defaultSettings(),
})
const [loginOpen, setLoginOpen] = useState(false)
const [editingKey, setEditingKey] = useState<string | null>(null)
const [scrolled, setScrolled] = useState(false)
const searchRef = useRef<HTMLInputElement>(null)
@@ -253,6 +255,7 @@ export default function App() {
categoryId={itemCategory.id}
categoryTitle={itemCategory.title}
onNavigate={navigateHref}
onEdit={() => setEditingKey(itemView.itemId)}
/>
)
}
@@ -272,6 +275,7 @@ export default function App() {
related={relatedItems}
onNavigate={navigateHref}
onOpenItem={(itemId) => openItem(itemCategory.id, itemId)}
onEdit={() => setEditingKey(itemView.itemId)}
/>
)
}
@@ -295,6 +299,13 @@ export default function App() {
}}
/>
<ItemEditor
open={editingKey !== null}
itemKey={editingKey}
onClose={() => setEditingKey(null)}
onSaved={() => catalog.reload()}
/>
{/* 主题的顶部背景图:固定 65vh,底部渐隐到页面底色 */}
<div className="site-background" aria-hidden="true" />
+73 -3
View File
@@ -13,7 +13,7 @@ import {
Title,
} from '@stash/ui'
import { useEffect, useMemo, useState } from 'react'
import { fetchSession, logout, type SessionState } from '../../auth'
import { changePassword, fetchSession, logout, type SessionState } from '../../auth'
import { LoginDialog } from '../auth/LoginDialog'
import {
fetchSettings,
@@ -138,6 +138,7 @@ export function AdminPage() {
items={[
{ key: 'header', label: '页头滚动信息' },
{ key: 'sidebar', label: '侧栏导航' },
{ key: 'system', label: '系统' },
]}
activeKey={tab}
onChange={setTab}
@@ -154,12 +155,14 @@ export function AdminPage() {
value={draft.header}
onChange={(header) => setDraft({ ...draft, header })}
/>
) : (
) : tab === 'sidebar' ? (
<SidebarForm
categories={categories}
value={draft.sidebar}
onChange={(sidebar) => setDraft({ ...draft, sidebar })}
/>
) : (
<SystemPanel />
)}
<Space gap="sm" justify="end">
@@ -362,8 +365,75 @@ function SidebarForm({
)
}
// ---------- 行内按钮组 ----------
// ---------- 系统 ----------
function SystemPanel() {
const [current, setCurrent] = useState('')
const [next, setNext] = useState('')
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState<{ tone: 'info' | 'error'; text: string } | null>(null)
const change = async () => {
setBusy(true)
setMessage(null)
const result = await changePassword(current, next)
setBusy(false)
if (!result.ok) {
setMessage({ tone: 'error', text: result.error })
return
}
setMessage({ tone: 'info', text: '密码已修改,所有会话已失效,请重新登录' })
setCurrent('')
setNext('')
}
return (
<Space direction="column" gap="lg" align="stretch" className="admin-form">
<FormField label="当前密码" htmlFor="current-password">
<Input
id="current-password"
type="password"
value={current}
onChange={(event) => setCurrent(event.target.value)}
/>
</FormField>
<FormField label="新密码" htmlFor="next-password" hint="至少 8 位">
<Input
id="next-password"
type="password"
value={next}
onChange={(event) => setNext(event.target.value)}
/>
</FormField>
{message !== null && (
<Notice tone={message.tone} title={message.tone === 'info' ? '已修改' : '修改失败'}>
{message.text}
</Notice>
)}
<Button
variant="secondary"
loading={busy}
icon={<Icon name="key_2_line" />}
onClick={change}
>
修改密码
</Button>
<Text size="sm" tone="muted">
备份:下载一份 SQLite 快照(VACUUM INTO 生成,一致性副本)。恢复时停掉服务,用它替换
data/stash.db 再启动。
</Text>
<Button
icon={<Icon name="download_line" />}
onClick={() => window.location.assign('/api/backup')}
>
下载备份
</Button>
</Space>
)
}
// ---------- 行内按钮组 ----------
function IconButtonGroup({
onUp,
onDown,
@@ -15,6 +15,7 @@ type ArticleDetailViewProps = {
categoryId: string
categoryTitle: string
onNavigate: (href: string) => void
onEdit: () => void
}
export function ArticleDetailView({
@@ -22,6 +23,7 @@ export function ArticleDetailView({
categoryId,
categoryTitle,
onNavigate,
onEdit,
}: ArticleDetailViewProps) {
const meta = article.meta ?? ['2 年前', categoryTitle]
const crumbs = [
@@ -39,7 +41,7 @@ export function ArticleDetailView({
<header className="article-head">
<Title level={1} as="h2" className="article-heading">
{article.title}
<button type="button" className="item-edit" disabled title="编辑功能待接入">
<button type="button" className="item-edit" title="编辑文章" onClick={onEdit}>
[编辑]
</button>
</Title>
@@ -27,6 +27,8 @@ type ItemDetailViewProps = {
related: RelatedItem[]
onNavigate: (href: string) => void
onOpenItem: (itemId: string) => void
/** 打开编辑 Drawer */
onEdit: () => void
}
export function ItemDetailView({
@@ -35,6 +37,7 @@ export function ItemDetailView({
related,
onNavigate,
onOpenItem,
onEdit,
}: ItemDetailViewProps) {
return (
<div className="detail-view item-view">
@@ -45,7 +48,7 @@ export function ItemDetailView({
<Card padding="none" className="item-card">
<Title level={1} as="h2" weight="semibold" className="item-title">
{model.title}
<button type="button" className="item-edit" disabled title="编辑功能待接入">
<button type="button" className="item-edit" title="编辑条目" onClick={onEdit}>
[编辑]
</button>
</Title>
@@ -0,0 +1,203 @@
import { Button, Drawer, FormField, Icon, Input, Notice, Space, Textarea } from '@stash/ui'
import { useEffect, useState } from 'react'
import { fetchItem, saveItem, type EntryRecord } from '../../items'
/**
* 条目编辑器(P2):右侧 Drawer 表单,就地改标题 / 简介 / 标签 / 正文等。
*
* `updatedAt` 做乐观锁:别人先改过就提示刷新,避免静默覆盖。
*/
type ItemEditorProps = {
open: boolean
itemKey: string | null
onClose: () => void
onSaved: (entry: EntryRecord) => void
}
type FormState = {
name: string
description: string
tags: string
badge: string
comment: string
summary: string
body: string
cover: string
banner: string
href: string
}
const emptyForm: FormState = {
name: '',
description: '',
tags: '',
badge: '',
comment: '',
summary: '',
body: '',
cover: '',
banner: '',
href: '',
}
export function ItemEditor({ open, itemKey, onClose, onSaved }: ItemEditorProps) {
const [entry, setEntry] = useState<EntryRecord | null>(null)
const [form, setForm] = useState<FormState>(emptyForm)
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (!open || itemKey === null) return
let alive = true
setLoading(true)
setError('')
fetchItem(itemKey).then((result) => {
if (!alive) return
setLoading(false)
if (!result.ok) {
setError(result.error)
return
}
const next = result.entry
setEntry(next)
setForm({
name: next.name,
description: next.description,
tags: next.tags.join(','),
badge: next.badge,
comment: next.comment,
summary: next.summary,
body: next.body,
cover: next.cover,
banner: next.banner,
href: next.href,
})
})
return () => {
alive = false
}
}, [open, itemKey])
const update = (patch: Partial<FormState>) => setForm((previous) => ({ ...previous, ...patch }))
const save = async () => {
if (entry === null) return
setSaving(true)
setError('')
const result = await saveItem(entry.key, entry.updated_at, {
...form,
tags: form.tags
.split(/[,,]/)
.map((tag) => tag.trim())
.filter(Boolean),
})
setSaving(false)
if (!result.ok) {
setError(result.error)
return
}
onSaved(result.entry)
onClose()
}
return (
<Drawer
open={open}
onClose={onClose}
width={520}
title="编辑条目"
footer={
<Space gap="sm" justify="end">
<Button variant="secondary" onClick={onClose}>
取消
</Button>
<Button
loading={saving}
disabled={loading}
icon={<Icon name="check_line" />}
onClick={save}
>
保存
</Button>
</Space>
}
>
{loading && <p>正在读取…</p>}
{error !== '' && (
<Notice tone="error" title="保存失败">
{error}
</Notice>
)}
{!loading && entry !== null && (
<Space direction="column" gap="md" align="stretch">
<FormField label="标题" htmlFor="edit-name" required>
<Input
id="edit-name"
value={form.name}
onChange={(event) => update({ name: event.target.value })}
/>
</FormField>
<FormField label="简介" htmlFor="edit-description" hint="列表卡片上的一行说明">
<Input
id="edit-description"
value={form.description}
onChange={(event) => update({ description: event.target.value })}
/>
</FormField>
<FormField label="标签" htmlFor="edit-tags" hint="用逗号分隔,例如:推荐电影,动画">
<Input
id="edit-tags"
value={form.tags}
onChange={(event) => update({ tags: event.target.value })}
/>
</FormField>
<FormField label="一句话评价" htmlFor="edit-comment">
<Input
id="edit-comment"
value={form.comment}
onChange={(event) => update({ comment: event.target.value })}
/>
</FormField>
<FormField label="正文" htmlFor="edit-body" hint="支持 Markdown:##、段落、链接、图片">
<Textarea
id="edit-body"
rows={8}
value={form.body}
onChange={(event) => update({ body: event.target.value })}
/>
</FormField>
<FormField label="封面地址" htmlFor="edit-cover">
<Input
id="edit-cover"
value={form.cover}
placeholder="https://…"
onChange={(event) => update({ cover: event.target.value })}
/>
</FormField>
<FormField label="跳转地址" htmlFor="edit-href">
<Input
id="edit-href"
value={form.href}
placeholder="https://…"
onChange={(event) => update({ href: event.target.value })}
/>
</FormField>
</Space>
)}
</Drawer>
)
}
+80
View File
@@ -0,0 +1,80 @@
/**
* 条目编辑的前端接口。
*
* 读取是公开的(编辑表单要能预览),写入必须登录,并用 `updatedAt` 做乐观锁。
*/
export type EntryRecord = {
key: string
name: string
description: string
icon: string
href: string
cover: string
banner: string
badge: string
comment: string
summary: string
body: string
tags: string[]
updated_at: number
}
export type EntryInput = {
name: string
description: string
tags: string[]
badge: string
comment: string
summary: string
body: string
cover: string
banner: string
href: string
}
export type ItemResult =
| { ok: true; entry: EntryRecord }
| { ok: false; status: number; error: string }
export async function fetchItem(key: string): Promise<ItemResult> {
try {
const response = await fetch(`/api/items/${encodeURIComponent(key)}`, {
credentials: 'same-origin',
})
if (response.ok) {
return { ok: true, entry: (await response.json()) as EntryRecord }
}
const payload = (await response.json().catch(() => null)) as { error?: string } | null
return { ok: false, status: response.status, error: payload?.error ?? '读取失败' }
} catch (error) {
return { ok: false, status: 0, error: error instanceof Error ? error.message : '网络错误' }
}
}
export async function saveItem(
key: string,
updatedAt: number,
item: EntryInput,
): Promise<ItemResult> {
try {
const response = await fetch(`/api/items/${encodeURIComponent(key)}`, {
method: 'PATCH',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ updatedAt, item }),
})
if (response.ok) {
return { ok: true, entry: (await response.json()) as EntryRecord }
}
const payload = (await response.json().catch(() => null)) as { error?: string } | null
return {
ok: false,
status: response.status,
error: payload?.error ?? `保存失败(${response.status})`,
}
} catch (error) {
return { ok: false, status: 0, error: error instanceof Error ? error.message : '网络错误' }
}
}