Files
ReiandClaude Opus 5.5 b3d2dada81 后台“认知观察”文章管理改版:搜索筛选、上下线、批量删除,修改正文网站立即生效
- 文章列表:按标题/路径搜索,按状态和来源(内容系统/手动添加)筛选,显示发布时间、字数和推荐权重;一键上线/下线、查看前台页面、单篇和批量删除,操作结果有提示
- 编辑改为右侧抽屉,正文按段落纯文本编辑;发布时间可改;来自内容系统的文章提示再次发布会覆盖修改
- 修复:后台改正文网站不变(网站显示 body_plain,后台只改了 content_html),现在两者一起保存
- 修复:修改文章时没传的字段会被清空,现在只改提交的字段
- 删除文章时同时删除内容系统的发布记录,内容系统可以重新发布;新增批量删除接口
- 上线时没有发布时间就用当前时间;路径标识重复时给出可读提示

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:58:02 +08:00

164 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from 'react'
type Api = (path: string, token: string, opts?: RequestInit) => Promise<any>
type Row = {
id: string; slug: string; title: string; subtitle?: string; category?: string; cover_url?: string; publish_time?: string
recommend_weight?: number; status: string; source_system?: string | null; created_at?: string; updated_at?: string; body_length?: number
}
type Draft = { id?: string; title: string; slug: string; subtitle: string; category: string; cover_url: string; body_plain: string; publish_time: string; recommend_weight: number; status: string; fromSystem?: boolean }
const SITE = 'https://www.ameiba.cn'
const STATUS: Record<string, string> = { published: '已发布', draft: '未发布' }
/** ISO time -> value for a datetime-local input, in the admin's own time zone. */
function toInput(value?: string | null) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
}
function toText(value?: string | null) {
if (!value) return '—'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
}
const empty = (): Draft => ({ title: '', slug: '', subtitle: '', category: '', cover_url: '', body_plain: '', publish_time: '', recommend_weight: 0, status: 'published' })
/** 认知观察: search, filter, publish or take offline, edit, and delete one or many. */
export function Articles({ token, api }: { token: string; api: Api }) {
const [rows, setRows] = useState<Row[]>([])
const [loading, setLoading] = useState(true)
const [query, setQuery] = useState('')
const [status, setStatus] = useState('all')
const [source, setSource] = useState('all')
const [selected, setSelected] = useState<string[]>([])
const [editing, setEditing] = useState<Draft | null>(null)
const [busy, setBusy] = useState(false)
const [notice, setNotice] = useState<{ text: string; error?: boolean } | null>(null)
const load = async () => {
setLoading(true)
const j = await api('/api/cms/articles', token)
if (j.code === 0) setRows(j.data || []); else setNotice({ text: j.msg || '文章列表读取失败', error: true })
setLoading(false)
}
useEffect(() => { void load() }, [])
useEffect(() => { if (!notice || notice.error) return; const t = window.setTimeout(() => setNotice(null), 2500); return () => window.clearTimeout(t) }, [notice])
const shown = useMemo(() => rows.filter(r =>
(status === 'all' || r.status === status)
&& (source === 'all' || (source === 'system') === Boolean(r.source_system))
&& (!query.trim() || `${r.title || ''} ${r.slug || ''}`.toLowerCase().includes(query.trim().toLowerCase()))), [rows, query, status, source])
const allShownSelected = shown.length > 0 && shown.every(r => selected.includes(r.id))
/** Runs one change, shows its outcome, and reloads the list. */
const run = async (work: () => Promise<any>, done: string) => {
setBusy(true)
try {
const j = await work()
if (j?.code !== 0) { setNotice({ text: j?.msg || '操作没有完成,请稍后重试', error: true }); return false }
setNotice({ text: done }); await load(); return true
} catch (e: any) { setNotice({ text: e.message || '网络异常,请稍后重试', error: true }); return false }
finally { setBusy(false) }
}
const remove = (r: Row) => {
const note = r.source_system ? '\n这篇文章来自内容系统,删除后可以在内容系统里重新发布。' : ''
if (!confirm(`确定删除《${r.title}》?删除后网站上不再显示。${note}`)) return
setSelected(selected.filter(id => id !== r.id))
void run(() => api('/api/cms/articles/' + r.id, token, { method: 'DELETE' }), '文章已删除')
}
const removeSelected = () => {
if (!selected.length || !confirm(`确定删除选中的 ${selected.length} 篇文章?删除后网站上不再显示。`)) return
const ids = selected
setSelected([])
void run(() => api('/api/cms/articles/batch-delete', token, { method: 'POST', body: JSON.stringify({ ids }) }), `已删除 ${ids.length} 篇文章`)
}
const toggle = (r: Row) => {
const next = r.status === 'published' ? 'draft' : 'published'
void run(() => api('/api/cms/articles/' + r.id, token, { method: 'PUT', body: JSON.stringify({ status: next }) }), next === 'published' ? '文章已上线' : '文章已下线,网站上不再显示')
}
const edit = async (r: Row) => {
const j = await api('/api/cms/articles/' + r.id, token)
if (j.code !== 0) { setNotice({ text: j.msg || '文章读取失败', error: true }); return }
const a = j.data
setEditing({ id: a.id, title: a.title || '', slug: a.slug || '', subtitle: a.subtitle || '', category: a.category || '', cover_url: a.cover_url || '', body_plain: a.body_plain || '',
publish_time: toInput(a.publish_time), recommend_weight: Number(a.recommend_weight) || 0, status: a.status || 'draft', fromSystem: Boolean(a.source_system) })
}
const save = async () => {
if (!editing) return
if (!editing.title.trim()) { setNotice({ text: '标题不能为空', error: true }); return }
const { id, fromSystem, ...fields } = editing
const data = { ...fields, title: fields.title.trim(), slug: fields.slug.trim() || undefined, publish_time: fields.publish_time ? new Date(fields.publish_time).toISOString() : '' }
const ok = await run(() => api(id ? '/api/cms/articles/' + id : '/api/cms/articles', token, { method: id ? 'PUT' : 'POST', body: JSON.stringify(data) }), id ? '修改已保存,网站上立即生效' : '文章已新增')
if (ok) setEditing(null)
}
const upload = (e: any) => {
const file = e.target.files?.[0]; if (!file || !editing) return
const fd = new FormData(); fd.append('file', file)
fetch('/api/cms/upload', { method: 'POST', headers: { Authorization: 'Bearer ' + token }, body: fd }).then(r => r.json())
.then(j => { if (j.code === 0) setEditing(d => d && { ...d, cover_url: j.data.url }); else setNotice({ text: j.msg || '图片上传失败', error: true }) })
}
return <div className="card">
{notice && <div className={'notice' + (notice.error ? ' error' : '')} role="status">{notice.text}{notice.error && <button onClick={() => setNotice(null)}>知道了</button>}</div>}
<div className="article-toolbar">
<input type="search" placeholder="搜索标题或路径标识" value={query} onChange={e => setQuery(e.target.value)} />
<select value={status} onChange={e => setStatus(e.target.value)}><option value="all">全部状态</option><option value="published">已发布</option><option value="draft">未发布</option></select>
<select value={source} onChange={e => setSource(e.target.value)}><option value="all">全部来源</option><option value="system">内容系统发布</option><option value="manual">后台手动添加</option></select>
<span className="article-count">共 {shown.length} 篇</span>
<button className="btn btn-ghost" disabled={!selected.length || busy} onClick={removeSelected}>批量删除{selected.length ? `(${selected.length})` : ''}</button>
<button className="btn" onClick={() => setEditing(empty())}>新增文章</button>
</div>
<p className="hint">网站按“推荐权重”从大到小排列,权重相同时按发布时间从新到旧。内容系统发布的文章,在内容系统里再次发布会覆盖这里的修改。</p>
<table className="table article-table">
<thead><tr>
<th style={{ width: 32 }}><input type="checkbox" aria-label="全选" checked={allShownSelected} onChange={() => setSelected(allShownSelected ? selected.filter(id => !shown.some(r => r.id === id)) : Array.from(new Set([...selected, ...shown.map(r => r.id)])))} /></th>
<th>标题</th><th>来源</th><th>状态</th><th>发布时间</th><th>推荐权重</th><th>操作</th>
</tr></thead>
<tbody>
{shown.map(r => <tr key={r.id}>
<td><input type="checkbox" aria-label={`选择《${r.title}》`} checked={selected.includes(r.id)} onChange={() => setSelected(selected.includes(r.id) ? selected.filter(id => id !== r.id) : [...selected, r.id])} /></td>
<td className="article-title"><strong>{r.title || '未命名文章'}</strong><small>{[r.category, r.body_length ? `约 ${r.body_length} 字` : ''].filter(Boolean).join(' · ')}</small></td>
<td><span className={'tag' + (r.source_system ? '' : ' muted')}>{r.source_system ? '内容系统' : '手动添加'}</span></td>
<td><span className={'tag ' + (r.status === 'published' ? 'ok' : 'off')}>{STATUS[r.status] || r.status}</span></td>
<td className="nowrap">{toText(r.publish_time)}</td>
<td>{r.recommend_weight || 0}</td>
<td className="article-actions">
<button disabled={busy} onClick={() => void edit(r)}>编辑</button>
{r.status === 'published' && <a href={`${SITE}/insights/${encodeURIComponent(r.slug)}`} target="_blank" rel="noreferrer">查看</a>}
<button disabled={busy} onClick={() => toggle(r)}>{r.status === 'published' ? '下线' : '上线'}</button>
<button className="danger" disabled={busy} onClick={() => remove(r)}>删除</button>
</td>
</tr>)}
{!loading && !shown.length && <tr><td colSpan={7} className="empty">没有符合条件的文章</td></tr>}
{loading && <tr><td colSpan={7} className="empty">正在读取文章…</td></tr>}
</tbody>
</table>
{editing && <div className="drawer-backdrop" onMouseDown={e => { if (e.target === e.currentTarget) setEditing(null) }}>
<div className="drawer" role="dialog" aria-modal="true" aria-label={editing.id ? '编辑文章' : '新增文章'}>
<header><h3>{editing.id ? '编辑文章' : '新增文章'}</h3><button className="btn btn-ghost" onClick={() => setEditing(null)}>关闭</button></header>
{editing.fromSystem && <p className="warn">这篇文章来自内容系统。这里的修改立即生效,但内容系统再次发布同一篇文章时会覆盖这里的修改。</p>}
<div className="field"><label>标题*</label><input value={editing.title} onChange={e => setEditing({ ...editing, title: e.target.value })} /></div>
<div className="field"><label>摘要(列表和分享时显示)</label><textarea value={editing.subtitle} onChange={e => setEditing({ ...editing, subtitle: e.target.value })} /></div>
<div className="field"><label>正文(每段之间换行;约 {editing.body_plain.replace(/\s/g, '').length} 字)</label><textarea className="body" value={editing.body_plain} onChange={e => setEditing({ ...editing, body_plain: e.target.value })} /></div>
<div className="form-row">
<div className="field"><label>分类</label><input value={editing.category} onChange={e => setEditing({ ...editing, category: e.target.value })} /></div>
<div className="field"><label>路径标识(网址中的名字,留空自动生成)</label><input value={editing.slug} onChange={e => setEditing({ ...editing, slug: e.target.value })} /></div>
</div>
<div className="form-row">
<div className="field"><label>发布时间(留空则上线时用当前时间)</label><input type="datetime-local" value={editing.publish_time} onChange={e => setEditing({ ...editing, publish_time: e.target.value })} /></div>
<div className="field"><label>推荐权重(越大越靠前,一般填 0)</label><input type="number" value={editing.recommend_weight} onChange={e => setEditing({ ...editing, recommend_weight: Number(e.target.value) })} /></div>
</div>
<div className="form-row">
<div className="field"><label>状态</label><select value={editing.status} onChange={e => setEditing({ ...editing, status: e.target.value })}><option value="published">已发布</option><option value="draft">未发布</option></select></div>
<div className="field"><label>封面图</label><input type="file" accept="image/*" onChange={upload} /></div>
</div>
{editing.cover_url && <div className="cover"><img src={editing.cover_url} alt="封面预览" /><button className="btn btn-ghost" onClick={() => setEditing({ ...editing, cover_url: '' })}>移除封面</button></div>}
<div className="drawer-actions"><button className="btn" disabled={busy} onClick={() => void save()}>{busy ? '保存中…' : '保存'}</button><button className="btn btn-ghost" onClick={() => setEditing(null)}>取消</button></div>
</div>
</div>}
</div>
}