import { useEffect, useMemo, useState } from 'react' type Api = (path: string, token: string, opts?: RequestInit) => Promise 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 = { 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([]) const [loading, setLoading] = useState(true) const [query, setQuery] = useState('') const [status, setStatus] = useState('all') const [source, setSource] = useState('all') const [selected, setSelected] = useState([]) const [editing, setEditing] = useState(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, 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
{notice &&
{notice.text}{notice.error && }
}
setQuery(e.target.value)} /> 共 {shown.length} 篇

网站按“推荐权重”从大到小排列,权重相同时按发布时间从新到旧。内容系统发布的文章,在内容系统里再次发布会覆盖这里的修改。

{shown.map(r => )} {!loading && !shown.length && } {loading && }
setSelected(allShownSelected ? selected.filter(id => !shown.some(r => r.id === id)) : Array.from(new Set([...selected, ...shown.map(r => r.id)])))} /> 标题来源状态发布时间推荐权重操作
setSelected(selected.includes(r.id) ? selected.filter(id => id !== r.id) : [...selected, r.id])} /> {r.title || '未命名文章'}{[r.category, r.body_length ? `约 ${r.body_length} 字` : ''].filter(Boolean).join(' · ')} {r.source_system ? '内容系统' : '手动添加'} {STATUS[r.status] || r.status} {toText(r.publish_time)} {r.recommend_weight || 0} {r.status === 'published' && 查看}
没有符合条件的文章
正在读取文章…
{editing &&
{ if (e.target === e.currentTarget) setEditing(null) }}>

{editing.id ? '编辑文章' : '新增文章'}

{editing.fromSystem &&

这篇文章来自内容系统。这里的修改立即生效,但内容系统再次发布同一篇文章时会覆盖这里的修改。

}
setEditing({ ...editing, title: e.target.value })} />