From ca00afb0fe26333c97a56d8967f4a9e8834ab34a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E7=BB=AB?= <486494914@qq.com> Date: Mon, 19 Oct 2026 15:50:55 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E6=96=87=E7=AB=A0=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E6=94=B9=E7=89=88=E5=B9=B6=E4=BF=AE=E5=A4=8D=E4=B8=A4?= =?UTF-8?q?=E5=A4=84=E5=AE=89=E5=85=A8=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 文章管理:按标题搜索、按状态和来源(内容系统/手动添加)筛选,显示发布时间和最后修改时间;一键上线/下线、查看前台页面、单篇删除和批量删除;右侧抽屉编辑标题、正文、分类、标签、作者、发布时间和状态;来自内容系统的文章提示再次发布会覆盖修改 - 其他内容类型的列表增加删除按钮 - 删除文章时同时删除它的发布记录,内容系统可以重新发布同一篇文章 - 上线时如果没有发布时间就用当前时间;后台表单的发布时间转换成 MySQL 格式 - 安全:/api/contents、/api/entities、/api/relationships、/api/joins 的新增、修改、删除需要登录管理后台(读取仍然公开,前台不受影响) - 安全:列表接口的 orderBy 参数只接受字段名和 ASC/DESC,修复 SQL 注入 Co-Authored-By: Claude Opus 5.5 --- frontend/src/app/admin/ArticleManager.tsx | 153 ++++++++++++++++ frontend/src/app/admin/page.tsx | 4 +- frontend/src/styles/globals.css | 209 ++++++++++++++++++++++ src/index.js | 9 +- src/middleware/adminAuth.js | 20 +++ src/models/Article.js | 5 +- src/models/Capability.js | 5 +- src/models/Case.js | 5 +- src/models/Content.js | 9 +- src/models/DirectedRelationship.js | 5 +- src/models/Entity.js | 5 +- src/models/EntityContentJoin.js | 5 +- src/models/FAQ.js | 5 +- src/models/Page.js | 5 +- src/models/Service.js | 5 +- src/routes/adminRoutes.js | 59 +++++- src/services/ContentService.js | 8 +- src/services/PublicApiService.js | 13 +- src/utils/sqlSafety.js | 11 ++ 19 files changed, 504 insertions(+), 36 deletions(-) create mode 100644 frontend/src/app/admin/ArticleManager.tsx create mode 100644 src/middleware/adminAuth.js create mode 100644 src/utils/sqlSafety.js diff --git a/frontend/src/app/admin/ArticleManager.tsx b/frontend/src/app/admin/ArticleManager.tsx new file mode 100644 index 0000000..9f8cd61 --- /dev/null +++ b/frontend/src/app/admin/ArticleManager.tsx @@ -0,0 +1,153 @@ +'use client'; + +import { FormEvent, useEffect, useMemo, useState } from 'react'; + +type Row = { + content_id: string; title: string; publish_status: string; created_at: string; updated_at: string; + category?: string | null; tags?: string | null; author?: string | null; published_at?: string | null; + source: 'content_system' | 'manual'; public_path: string; +}; +type Draft = { content_id: string; title: string; body: string; publish_status: string; category: string; tags: string; author: string; published_at: string; source?: Row['source'] }; +type Request = (path: string, options?: RequestInit) => Promise; + +const STATUS: Record = { published: '已发布', draft: '草稿', review: '审核中', unpublished: '已下线', historical: '历史版本' }; +const SITE = 'https://www.aiameiba.com'; + +/** "2026-10-19T07:18:22.000Z" -> "2026-10-19T15:18" for a datetime-local input (the admin's local time). */ +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 => ({ content_id: '', title: '', body: '', publish_status: 'draft', category: '', tags: '', author: '阿美巴', published_at: '' }); + +/** Articles: search, filter, publish or take offline, edit, and delete one or many. */ +export function ArticleManager({ request, onMessage, onError }: { request: Request; onMessage: (text: string) => void; onError: (text: string) => void }) { + 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); + + async function load() { + setLoading(true); + try { setRows(await request('/api/admin/articles')); } catch (e: any) { onError(e.message); } finally { setLoading(false); } + } + useEffect(() => { void load(); }, []); + + const shown = useMemo(() => rows.filter((row) => + (status === 'all' || row.publish_status === status) + && (source === 'all' || row.source === source) + && (!query.trim() || (row.title || '').toLowerCase().includes(query.trim().toLowerCase()))), [rows, query, status, source]); + const allShownSelected = shown.length > 0 && shown.every((row) => selected.includes(row.content_id)); + + async function run(work: () => Promise, done: string) { + setBusy(true); + try { await work(); onMessage(done); await load(); } catch (e: any) { onError(e.message); } finally { setBusy(false); } + } + function remove(row: Row) { + const note = row.source === 'content_system' ? '\n这篇文章来自内容系统,删除后可以在内容系统里重新发布。' : ''; + if (!window.confirm(`确定删除《${row.title}》?删除后网站上不再显示。${note}`)) return; + void run(() => request(`/api/admin/contents/article/${row.content_id}`, { method: 'DELETE' }), '文章已删除。'); + setSelected(selected.filter((id) => id !== row.content_id)); + } + function removeSelected() { + if (!selected.length || !window.confirm(`确定删除选中的 ${selected.length} 篇文章?删除后网站上不再显示。`)) return; + void run(async () => { + const result = await request('/api/admin/contents/article/batch-delete', { method: 'POST', body: JSON.stringify({ ids: selected }) }); + if (result?.failed?.length) throw new Error(`有 ${result.failed.length} 篇未能删除:${result.failed[0].error}`); + }, `已删除 ${selected.length} 篇文章。`); + setSelected([]); + } + function toggle(row: Row) { + const next = row.publish_status === 'published' ? 'unpublished' : 'published'; + void run(() => request(`/api/admin/contents/article/${row.content_id}`, { method: 'PUT', body: JSON.stringify({ publish_status: next }) }), + next === 'published' ? '文章已上线。' : '文章已下线,网站上不再显示。'); + } + async function edit(row: Row) { + try { + const full = await request(`/api/admin/contents/article/${row.content_id}`); + setEditing({ content_id: row.content_id, title: full.title || '', body: full.body || '', publish_status: full.publish_status || 'draft', + category: full.category || '', tags: full.tags || '', author: full.author || '', published_at: toInput(full.published_at), source: row.source }); + } catch (e: any) { onError(e.message); } + } + async function save(event: FormEvent) { + event.preventDefault(); + if (!editing) return; + const data = { title: editing.title.trim(), body: editing.body, publish_status: editing.publish_status, category: editing.category, + tags: editing.tags, author: editing.author, published_at: editing.published_at ? `${editing.published_at.replace('T', ' ')}:00` : '' }; + const path = editing.content_id ? `/api/admin/contents/article/${editing.content_id}` : '/api/admin/contents/article'; + await run(async () => { await request(path, { method: editing.content_id ? 'PUT' : 'POST', body: JSON.stringify(data) }); setEditing(null); }, + editing.content_id ? '修改已保存,网站上立即生效。' : '文章已新增。'); + } + + return
+
+ setQuery(e.target.value)} aria-label="搜索标题" /> + + + 共 {shown.length} 篇 + + +
+
+ + + + + + + {shown.map((row) => + + + + + + + + )} + {!loading && shown.length === 0 && } + {loading && } + +
setSelected(allShownSelected ? selected.filter((id) => !shown.some((row) => row.content_id === id)) : Array.from(new Set([...selected, ...shown.map((row) => row.content_id)])))} />标题来源状态发布时间最后修改操作
setSelected(selected.includes(row.content_id) ? selected.filter((id) => id !== row.content_id) : [...selected, row.content_id])} />{row.title || '未命名文章'}{(row.category || row.author) && {[row.category, row.author].filter(Boolean).join(' · ')}}{row.source === 'content_system' ? '内容系统' : '手动添加'}{STATUS[row.publish_status] || row.publish_status}{toText(row.published_at)}{toText(row.updated_at)} + + {row.publish_status === 'published' && 查看} + + +
没有符合条件的文章。
正在读取文章…
+
+ + {editing &&
{ if (e.target === e.currentTarget) setEditing(null); }}> +
+

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

+ {editing.source === 'content_system' &&

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

} + +