后台“认知观察”文章管理改版:搜索筛选、上下线、批量删除,修改正文网站立即生效
- 文章列表:按标题/路径搜索,按状态和来源(内容系统/手动添加)筛选,显示发布时间、字数和推荐权重;一键上线/下线、查看前台页面、单篇和批量删除,操作结果有提示 - 编辑改为右侧抽屉,正文按段落纯文本编辑;发布时间可改;来自内容系统的文章提示再次发布会覆盖修改 - 修复:后台改正文网站不变(网站显示 body_plain,后台只改了 content_html),现在两者一起保存 - 修复:修改文章时没传的字段会被清空,现在只改提交的字段 - 删除文章时同时删除内容系统的发布记录,内容系统可以重新发布;新增批量删除接口 - 上线时没有发布时间就用当前时间;路径标识重复时给出可读提示 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+2
-19
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Articles } from './Articles'
|
||||
|
||||
const API_BASE = '' // 同域,相对路径
|
||||
const SIDEBAR = [
|
||||
@@ -94,7 +95,7 @@ export default function App(){
|
||||
{cur==='methods' && <Methods token={token} />}
|
||||
{cur==='services' && <Services token={token} />}
|
||||
{cur==='cases' && <Cases token={token} />}
|
||||
{cur==='articles' && <Articles token={token} />}
|
||||
{cur==='articles' && <Articles token={token} api={api} />}
|
||||
{cur==='selfcheck' && <SelfCheck token={token} />}
|
||||
{cur==='diag_q' && <DiagQ token={token} />}
|
||||
{cur==='diag_config' && <DiagConfig token={token} />}
|
||||
@@ -235,24 +236,6 @@ function Cases({token}:{token:string}){
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
function Articles({token}:{token:string}){
|
||||
const [list,setList]=useState<any[]>([]); const [f,setF]=useState<any>({status:'published'}); const [editing,setEditing]=useState(false)
|
||||
useEffect(()=>{api('/api/cms/articles',token).then(j=> setList(j.data||[]))},[])
|
||||
const save=()=>api(editing?'/api/cms/articles/'+f.id:'/api/cms/articles',token,{method:editing?'PUT':'POST',body:JSON.stringify(f)}).then(j=>{if(j.code===0){const saved={...f,id:editing?f.id:j.data?.id};setList(editing?list.map(x=>x.id===saved.id?saved:x):[...list,saved]);setF({status:'published'});setEditing(false);alert('保存成功')}else alert(j.msg||'保存失败')})
|
||||
return <>
|
||||
<div className="card"><h3>文章列表</h3><table className="table"><thead><tr><th>标题</th><th>分类</th><th>推荐权重</th><th>状态</th><th>操作</th></tr></thead><tbody>{list.map(a=> <tr key={a.id}><td>{a.title}</td><td>{a.category}</td><td>{a.recommend_weight}</td><td>{a.status}</td><td><button onClick={()=>{setF({...a});setEditing(true)}}>编辑</button> <button onClick={()=>{if(confirm('删除?')) api('/api/cms/articles/'+a.id,token,{method:'DELETE'}).then(j=>{if(j.code===0)setList(list.filter(x=>x.id!==a.id))})}}>删除</button></td></tr>)}</tbody></table></div>
|
||||
<div className="card"><h3>{editing?'编辑文章':'新增文章'}(富文本)</h3>
|
||||
<div className="field"><label>标题*</label><input value={f.title||''} onChange={e=> setF({...f,title:e.target.value})}/></div>
|
||||
<div className="form-row"><div className="field"><label>路径标识(slug)</label><input value={f.slug||''} onChange={e=>setF({...f,slug:e.target.value})}/></div><div className="field"><label>分类</label><input value={f.category||''} onChange={e=>setF({...f,category:e.target.value})}/></div></div>
|
||||
<div className="field"><label>摘要</label><textarea value={f.subtitle||''} onChange={e=>setF({...f,subtitle:e.target.value})}/></div>
|
||||
<div className="field"><label>正文(富文本 HTML)</label><textarea value={f.content_html||''} onChange={e=> setF({...f,content_html:e.target.value})} /></div>
|
||||
<Upload token={token} onUrl={url=> setF({...f,cover_url:url})} />
|
||||
{f.cover_url && <img src={f.cover_url} style={{width:120,height:80,objectFit:'cover'}}/>}
|
||||
<div className="form-row"><div className="field"><label>状态</label><select value={f.status||'published'} onChange={e=> setF({...f,status:e.target.value})}><option value="published">published</option><option value="draft">draft</option></select></div><div className="field"><label>推荐权重</label><input type="number" value={f.recommend_weight||0} onChange={e=> setF({...f,recommend_weight:Number(e.target.value)})}/></div></div>
|
||||
<button className="btn" onClick={save}>{editing?'保存修改':'新增文章'}</button>{editing&&<button className="btn btn-ghost" style={{marginLeft:8}} onClick={()=>{setF({status:'published'});setEditing(false)}}>取消编辑</button>}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
function SelfCheck({token}:{token:string}){
|
||||
const [f,setF]=useState<any>({})
|
||||
useEffect(()=>{api('/api/cms/self-check',token).then(j=> setF(j.data||{}))},[])
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
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>
|
||||
}
|
||||
@@ -25,3 +25,36 @@ a{color:#163E70;text-decoration:none}
|
||||
.topbar{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}
|
||||
.hint{font-size:12px;color:#64748b;line-height:1.7;margin:-4px 0 12px}
|
||||
.editor-block{border:1px solid #e2e8f0;border-radius:8px;padding:14px;margin:12px 0;background:#fbfdff}
|
||||
/* 认知观察:文章管理 */
|
||||
.btn:disabled,.article-actions button:disabled{opacity:.45;cursor:not-allowed}
|
||||
.article-toolbar{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-bottom:12px}
|
||||
.article-toolbar input,.article-toolbar select{height:36px;padding:0 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px;background:#fff}
|
||||
.article-toolbar input{flex:1 1 220px}
|
||||
.article-count{margin-left:auto;font-size:12px;color:#64748b}
|
||||
.article-table td{vertical-align:middle}
|
||||
.article-table tbody tr:hover{background:#f8fafc}
|
||||
.article-title strong{display:block;font-weight:600;max-width:420px}
|
||||
.article-title small{display:block;margin-top:3px;color:#94a3b8;font-size:12px}
|
||||
.nowrap{white-space:nowrap;color:#475569}
|
||||
.tag.muted{background:#f1f5f9;color:#64748b}
|
||||
.tag.ok{background:#dcfce7;color:#166534}
|
||||
.tag.off{background:#fee2e2;color:#991b1b}
|
||||
.article-actions{white-space:nowrap}
|
||||
.article-actions button,.article-actions a{display:inline-block;margin-right:6px;padding:4px 10px;border:1px solid #e2e8f0;border-radius:6px;background:#fff;color:#163E70;font-size:12px;cursor:pointer}
|
||||
.article-actions button:hover,.article-actions a:hover{border-color:#163E70}
|
||||
.article-actions .danger{color:#b91c1c}
|
||||
.article-actions .danger:hover{border-color:#b91c1c}
|
||||
.table .empty{padding:32px;text-align:center;color:#94a3b8}
|
||||
.notice{position:fixed;top:16px;left:50%;transform:translateX(-50%);z-index:60;display:flex;gap:12px;align-items:center;padding:10px 16px;border-radius:8px;background:#163E70;color:#fff;font-size:13px;box-shadow:0 6px 20px rgba(15,23,42,.18)}
|
||||
.notice.error{background:#b91c1c}
|
||||
.notice button{border:0;background:rgba(255,255,255,.2);color:#fff;border-radius:4px;padding:2px 8px;cursor:pointer}
|
||||
.drawer-backdrop{position:fixed;inset:0;z-index:50;display:flex;justify-content:flex-end;background:rgba(15,23,42,.35)}
|
||||
.drawer{width:min(760px,100%);height:100%;overflow-y:auto;background:#fff;padding:20px 24px}
|
||||
.drawer header{display:flex;justify-content:space-between;align-items:center;margin-bottom:14px}
|
||||
.drawer header h3{margin:0}
|
||||
.drawer textarea.body{min-height:360px;line-height:1.8}
|
||||
.drawer .warn{margin-bottom:12px;padding:8px 12px;border-radius:6px;background:#fef3c7;color:#92400e;font-size:12px}
|
||||
.drawer .cover{display:flex;align-items:center;gap:12px;margin-bottom:12px}
|
||||
.drawer .cover img{width:160px;height:100px;object-fit:cover;border-radius:6px;border:1px solid #e2e8f0}
|
||||
.drawer-actions{display:flex;gap:8px;position:sticky;bottom:-20px;padding:12px 0;background:#fff;border-top:1px solid #e2e8f0}
|
||||
@media (max-width:700px){.drawer .form-row{grid-template-columns:1fr}.article-count{margin-left:0}}
|
||||
|
||||
Reference in New Issue
Block a user