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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user