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:
@@ -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" />
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 : '网络错误' }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user