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
+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,