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