修复后台删不掉内容系统发布的文章,登录状态重启后不再失效

- 删除文章:先删引用它的发布记录和实体关联再删文章(之前顺序反了,数据库外键拒绝删除),并放在一个事务里,失败时不会只删掉一半
- 管理员登录状态存到数据库(admin_sessions,只存令牌哈希),服务重启或部署后不用重新登录;登录过期时后台回到登录页并提示,而不是操作一直失败
- 内容系统发布的文章可修改知识中心列表里的摘要
- 文章缺少扩展记录时,修改分类和发布时间也能保存
- 提示文字改为“约 1 分钟内在网站生效”(前台页面缓存 60 秒)
- 新增不连数据库的单元测试 tests/adminFixes.unit.test.js(其他测试会写 .env 里的库)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
amb
2026-10-19 16:11:12 +08:00
co-authored by Claude Opus 5.5
parent 8da2962bfa
commit b07394eed2
8 changed files with 145 additions and 28 deletions
+10 -8
View File
@@ -5,9 +5,9 @@ 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;
source: 'content_system' | 'manual'; public_path: string; summary?: string | null;
};
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 Draft = { content_id: string; title: string; body: string; publish_status: string; category: string; tags: string; author: string; published_at: string; summary: string; source?: Row['source'] };
type Request = (path: string, options?: RequestInit) => Promise<any>;
const STATUS: Record<string, string> = { published: '已发布', draft: '草稿', review: '审核中', unpublished: '已下线', historical: '历史版本' };
@@ -26,7 +26,7 @@ function toText(value?: string | null) {
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: '' });
const empty = (): Draft => ({ content_id: '', title: '', body: '', publish_status: 'draft', category: '', tags: '', author: '阿美巴', published_at: '', summary: '' });
/** 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 }) {
@@ -72,23 +72,24 @@ export function ArticleManager({ request, onMessage, onError }: { request: Reque
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' ? '文章已上线。' : '文章已下线,网站上不再显示。');
next === 'published' ? '文章已上线,网站约 1 分钟内显示。' : '文章已下线,网站约 1 分钟内不再显示。');
}
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 });
category: full.category || '', tags: full.tags || '', author: full.author || '', published_at: toInput(full.published_at), summary: row.summary || '', 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` : '' };
tags: editing.tags, author: editing.author, published_at: editing.published_at ? `${editing.published_at.replace('T', ' ')}:00` : '',
...(editing.source === 'content_system' ? { summary: editing.summary } : {}) };
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 ? '修改已保存,网站上立即生效。' : '文章已新增。');
editing.content_id ? '修改已保存,网站约 1 分钟内更新。' : '文章已新增。');
}
return <div className="article-manager">
@@ -134,8 +135,9 @@ export function ArticleManager({ request, onMessage, onError }: { request: Reque
{editing && <div className="article-editor-backdrop" role="presentation" onMouseDown={(e) => { if (e.target === e.currentTarget) setEditing(null); }}>
<form className="editor-form article-editor" onSubmit={save} role="dialog" aria-modal="true" aria-label={editing.content_id ? '编辑文章' : '新增文章'}>
<header><h3>{editing.content_id ? '编辑文章' : '新增文章'}</h3><button type="button" className="admin-quiet-button" onClick={() => setEditing(null)}>关闭</button></header>
{editing.source === 'content_system' && <p className="article-note">这篇文章来自内容系统。这里的修改会立即生效,但内容系统再次发布同一篇文章时会覆盖这里的修改。</p>}
{editing.source === 'content_system' && <p className="article-note">这篇文章来自内容系统。这里的修改约 1 分钟内在网站生效,但内容系统再次发布同一篇文章时会覆盖这里的修改。</p>}
<label>标题<input value={editing.title} onChange={(e) => setEditing({ ...editing, title: e.target.value })} required /></label>
{editing.source === 'content_system' && <label>摘要(知识中心列表里显示)<textarea rows={3} value={editing.summary} onChange={(e) => setEditing({ ...editing, summary: e.target.value })} /></label>}
<label>正文<small>{editing.body.replace(/\s/g, '').length} 字</small><textarea rows={16} value={editing.body} onChange={(e) => setEditing({ ...editing, body: e.target.value })} /></label>
<div className="article-editor-grid">
<label>分类<input value={editing.category} onChange={(e) => setEditing({ ...editing, category: e.target.value })} /></label>
+3 -1
View File
@@ -129,7 +129,8 @@ type Setting = { setting_key: string; setting_value: string; setting_group: stri
async function request(path: string, token: string, options: RequestInit = {}) {
const response = await fetch(`${API_URL}${path}`, { ...options, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, ...(options.headers || {}) } });
const data = response.status === 204 ? null : await response.json();
const data = response.status === 204 ? null : await response.json().catch(() => null);
if (response.status === 401 && path !== '/api/admin/login') { window.dispatchEvent(new Event('ameiba-admin-unauthorized')); throw new Error('登录已过期,请重新登录。'); }
if (!response.ok) throw new Error(data?.error || '操作没有完成,请稍后重试');
return data;
}
@@ -143,6 +144,7 @@ export default function AdminPage() {
const [editing, setEditing] = useState<ContentItem | null>(null); const [details, setDetails] = useState<Record<string, string>>({}); const [currentPassword, setCurrentPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
useEffect(() => { const saved = window.localStorage.getItem('ameiba_admin_token'); if (saved) { setToken(saved); loadSettings(saved); } }, []);
useEffect(() => { const expired = () => { window.localStorage.removeItem('ameiba_admin_token'); setToken(''); setMessage(''); setError('登录已过期,请重新登录。'); }; window.addEventListener('ameiba-admin-unauthorized', expired); return () => window.removeEventListener('ameiba-admin-unauthorized', expired); }, []);
async function loadSettings(authToken = token) { try { setSettings(await request('/api/admin/settings', authToken)); } catch (e: any) { setError(e.message); } }
async function loadContents(contentType = type, authToken = token) { try { setContents((await request(`/api/admin/contents/${contentType}?pageSize=100`, authToken)).list || []); } catch (e: any) { setError(e.message); } }
async function login(event: FormEvent) { event.preventDefault(); setError(''); try { const response = await fetch(`${API_URL}/api/admin/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); const data = await response.json(); if (!response.ok) throw new Error(data.error || '用户名或密码错误'); window.localStorage.setItem('ameiba_admin_token', data.token); setToken(data.token); setPassword(''); await loadSettings(data.token); } catch (e: any) { setError(e.message); } }