后台文章管理改版并修复两处安全问题
- 文章管理:按标题搜索、按状态和来源(内容系统/手动添加)筛选,显示发布时间和最后修改时间;一键上线/下线、查看前台页面、单篇删除和批量删除;右侧抽屉编辑标题、正文、分类、标签、作者、发布时间和状态;来自内容系统的文章提示再次发布会覆盖修改 - 其他内容类型的列表增加删除按钮 - 删除文章时同时删除它的发布记录,内容系统可以重新发布同一篇文章 - 上线时如果没有发布时间就用当前时间;后台表单的发布时间转换成 MySQL 格式 - 安全:/api/contents、/api/entities、/api/relationships、/api/joins 的新增、修改、删除需要登录管理后台(读取仍然公开,前台不受影响) - 安全:列表接口的 orderBy 参数只接受字段名和 ASC/DESC,修复 SQL 注入 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<any>;
|
||||
|
||||
const STATUS: Record<string, string> = { 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<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);
|
||||
|
||||
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<unknown>, 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 <div className="article-manager">
|
||||
<div className="article-toolbar">
|
||||
<input type="search" placeholder="搜索标题" value={query} onChange={(e) => setQuery(e.target.value)} aria-label="搜索标题" />
|
||||
<select value={status} onChange={(e) => setStatus(e.target.value)} aria-label="按状态筛选">
|
||||
<option value="all">全部状态</option><option value="published">已发布</option><option value="draft">草稿</option><option value="unpublished">已下线</option><option value="review">审核中</option>
|
||||
</select>
|
||||
<select value={source} onChange={(e) => setSource(e.target.value)} aria-label="按来源筛选">
|
||||
<option value="all">全部来源</option><option value="content_system">内容系统发布</option><option value="manual">后台手动添加</option>
|
||||
</select>
|
||||
<span className="article-count">共 {shown.length} 篇</span>
|
||||
<button className="admin-quiet-button" disabled={!selected.length || busy} onClick={removeSelected}>批量删除{selected.length ? `(${selected.length})` : ''}</button>
|
||||
<button className="admin-button" onClick={() => setEditing(empty())}>新增文章</button>
|
||||
</div>
|
||||
<div className="article-table-wrap">
|
||||
<table className="article-table">
|
||||
<thead><tr>
|
||||
<th><input type="checkbox" aria-label="全选" checked={allShownSelected} onChange={() => setSelected(allShownSelected ? selected.filter((id) => !shown.some((row) => row.content_id === id)) : Array.from(new Set([...selected, ...shown.map((row) => row.content_id)])))} /></th>
|
||||
<th>标题</th><th>来源</th><th>状态</th><th>发布时间</th><th>最后修改</th><th>操作</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{shown.map((row) => <tr key={row.content_id}>
|
||||
<td><input type="checkbox" aria-label={`选择《${row.title}》`} checked={selected.includes(row.content_id)} onChange={() => setSelected(selected.includes(row.content_id) ? selected.filter((id) => id !== row.content_id) : [...selected, row.content_id])} /></td>
|
||||
<td className="article-title-cell"><strong>{row.title || '未命名文章'}</strong>{(row.category || row.author) && <small>{[row.category, row.author].filter(Boolean).join(' · ')}</small>}</td>
|
||||
<td><span className={`article-tag ${row.source}`}>{row.source === 'content_system' ? '内容系统' : '手动添加'}</span></td>
|
||||
<td><span className={`article-tag status-${row.publish_status}`}>{STATUS[row.publish_status] || row.publish_status}</span></td>
|
||||
<td>{toText(row.published_at)}</td>
|
||||
<td>{toText(row.updated_at)}</td>
|
||||
<td className="article-actions">
|
||||
<button className="admin-quiet-button" disabled={busy} onClick={() => void edit(row)}>编辑</button>
|
||||
{row.publish_status === 'published' && <a className="admin-quiet-button" href={`${SITE}${row.public_path}`} target="_blank" rel="noreferrer">查看</a>}
|
||||
<button className="admin-quiet-button" disabled={busy} onClick={() => toggle(row)}>{row.publish_status === 'published' ? '下线' : '上线'}</button>
|
||||
<button className="admin-quiet-button danger" disabled={busy} onClick={() => remove(row)}>删除</button>
|
||||
</td>
|
||||
</tr>)}
|
||||
{!loading && shown.length === 0 && <tr><td colSpan={7} className="article-empty">没有符合条件的文章。</td></tr>}
|
||||
{loading && <tr><td colSpan={7} className="article-empty">正在读取文章…</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{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>}
|
||||
<label>标题<input value={editing.title} onChange={(e) => setEditing({ ...editing, title: e.target.value })} required /></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>
|
||||
<label>标签(逗号分隔)<input value={editing.tags} onChange={(e) => setEditing({ ...editing, tags: e.target.value })} /></label>
|
||||
<label>作者<input value={editing.author} onChange={(e) => setEditing({ ...editing, author: e.target.value })} /></label>
|
||||
<label>发布时间<input type="datetime-local" value={editing.published_at} onChange={(e) => setEditing({ ...editing, published_at: e.target.value })} /></label>
|
||||
<label>状态<select value={editing.publish_status} onChange={(e) => setEditing({ ...editing, publish_status: e.target.value })}>
|
||||
<option value="draft">草稿</option><option value="published">已发布</option><option value="unpublished">已下线</option><option value="review">审核中</option>
|
||||
</select></label>
|
||||
</div>
|
||||
<div className="admin-actions"><button className="admin-button" type="submit" disabled={busy}>{busy ? '保存中…' : '保存'}</button><button className="admin-quiet-button" type="button" onClick={() => setEditing(null)}>取消</button></div>
|
||||
</form>
|
||||
</div>}
|
||||
</div>;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { FormEvent, useEffect, useState } from 'react';
|
||||
import { ArticleManager } from './ArticleManager';
|
||||
|
||||
const API_URL = typeof window !== 'undefined'
|
||||
? (process.env.NEXT_PUBLIC_API_URL ?? '')
|
||||
@@ -149,6 +150,7 @@ export default function AdminPage() {
|
||||
function beginCreate() { setError(''); setMessage(''); setDetails({}); setEditing({ content_id: '', title: '', body: '', content_type: type, publish_status: 'draft' }); }
|
||||
function beginEdit(item: ContentItem) { setError(''); setMessage(''); const next: Record<string, string> = {}; (detailFields[item.content_type] || []).forEach(field => { next[field.key] = item[field.key] == null ? '' : String(item[field.key]); }); setDetails(next); setEditing({ ...item }); }
|
||||
async function saveContent(event: FormEvent) { event.preventDefault(); if (!editing) return; setError(''); setMessage(''); const data = { title: editing.title, body: editing.body || '', publish_status: editing.publish_status, ...details }; try { const path = editing.content_id ? `/api/admin/contents/${editing.content_type}/${editing.content_id}` : `/api/admin/contents/${type}`; await request(path, token, { method: editing.content_id ? 'PUT' : 'POST', body: JSON.stringify(data) }); setMessage('内容已保存。'); setEditing(null); await loadContents(type); } catch (e: any) { setError(e.message); } }
|
||||
async function removeContent(item: ContentItem) { if (!window.confirm(`确定删除“${item.title || '未命名内容'}”?删除后网站上不再显示。`)) return; setError(''); setMessage(''); try { await request(`/api/admin/contents/${item.content_type}/${item.content_id}`, token, { method: 'DELETE' }); setMessage('内容已删除。'); if (editing?.content_id === item.content_id) setEditing(null); await loadContents(type); } catch (e: any) { setError(e.message); } }
|
||||
async function changePassword(event: FormEvent) { event.preventDefault(); setError(''); setMessage(''); try { await request('/api/admin/password', token, { method: 'PUT', body: JSON.stringify({ currentPassword, newPassword }) }); setMessage('密码已修改,请重新登录。'); logout(); } catch (e: any) { setError(e.message); } }
|
||||
function logout() { window.localStorage.removeItem('ameiba_admin_token'); setToken(''); }
|
||||
|
||||
@@ -156,7 +158,7 @@ export default function AdminPage() {
|
||||
|
||||
return <main className="admin-shell"><div className="admin-panel"><header className="admin-header"><div><p className="admin-kicker">阿美巴内容管理</p><h1>站点管理</h1><p className="admin-muted">修改并保存后,前台对应位置会使用新内容。</p></div><button className="admin-quiet-button" onClick={logout}>退出登录</button></header><nav className="admin-tabs"><button className={activeTab === 'settings' ? 'active' : ''} onClick={() => { setActiveTab('settings'); loadSettings(); }}>首页与站点文字</button><button className={activeTab === 'contents' ? 'active' : ''} onClick={() => { setActiveTab('contents'); loadContents(); }}>文章与内容</button><button className={activeTab === 'password' ? 'active' : ''} onClick={() => setActiveTab('password')}>修改密码</button></nav>{message && <p className="admin-success">{message}</p>}{error && <p className="admin-error">{error}</p>}
|
||||
{activeTab === 'settings' && <section className="admin-section"><div className="admin-section-heading"><div><h2>按页面修改文字</h2><p className="admin-muted">先选择前台页面,再修改该页面上实际看到的文字。每项旁边都写明了修改位置。</p></div><select className="page-picker" aria-label="选择要修改的页面" value={selectedPage} onChange={e => setSelectedPage(e.target.value)}>{pageChoices.map(page => <option key={page.value} value={page.value}>{page.label}</option>)}</select></div><div className="page-description">{pageChoices.find(page => page.value === selectedPage)?.description}</div><div className="settings-list">{(pageFields[selectedPage] || []).map(field => { const index = settings.findIndex(item => item.setting_key === field.key); const setting = index >= 0 ? settings[index] : { setting_key: field.key, setting_value: '', setting_group: selectedPage }; return <div className="setting-row page-setting-row" key={field.key}><div><strong>{field.label}</strong><span>{field.help}</span></div>{field.multiline ? <textarea value={setting.setting_value || ''} rows={4} onChange={e => setSettings(settings.some(item => item.setting_key === field.key) ? settings.map(item => item.setting_key === field.key ? { ...item, setting_value: e.target.value } : item) : [...settings, { ...setting, setting_value: e.target.value }])} /> : <input value={setting.setting_value || ''} onChange={e => setSettings(settings.some(item => item.setting_key === field.key) ? settings.map(item => item.setting_key === field.key ? { ...item, setting_value: e.target.value } : item) : [...settings, { ...setting, setting_value: e.target.value }])} />}<button className="admin-button small" onClick={() => saveSetting(setting)}>保存</button></div>})}</div></section>}
|
||||
{activeTab === 'contents' && <section className="admin-section"><div className="admin-section-heading"><div><h2>文章与内容</h2><p className="admin-muted">选择内容类型后,可以新增、修改或删除内容。</p></div><div className="admin-actions"><select aria-label="选择内容类型" value={type} onChange={e => { setType(e.target.value); setEditing(null); loadContents(e.target.value); }}>{contentTypes.map(item => <option key={item.value} value={item.value}>{item.label}</option>)}</select><button className="admin-button" onClick={beginCreate}>新增{typeLabel(type)}</button></div></div>{editing && <form className="editor-form" onSubmit={saveContent}><p className="editor-hint">以下内容保存后,将作为这条{typeLabel(editing.content_type)}的展示内容。</p><label>标题<input value={editing.title} onChange={e => setEditing({ ...editing, title: e.target.value })} required /></label><label>正文<textarea rows={8} value={editing.body || ''} onChange={e => setEditing({ ...editing, body: e.target.value })} /></label>{(detailFields[editing.content_type] || []).map(field => <label key={field.key}>{field.label}{field.multiline ? <textarea rows={4} value={details[field.key] || ''} onChange={e => setDetails({ ...details, [field.key]: e.target.value })} /> : <input value={details[field.key] || ''} onChange={e => setDetails({ ...details, [field.key]: e.target.value })} />}</label>)}<label>发布状态<select value={editing.publish_status} onChange={e => setEditing({ ...editing, publish_status: e.target.value })}><option value="draft">草稿</option><option value="review">审核中</option><option value="published">已发布</option><option value="unpublished">已下线</option></select></label><div className="admin-actions"><button className="admin-button" type="submit">保存内容</button><button className="admin-quiet-button" type="button" onClick={() => setEditing(null)}>取消</button></div></form>}<div className="content-table">{contents.map(item => <div className="content-row" key={item.content_id}><div><strong>{item.title || '未命名内容'}</strong><span>{typeLabel(item.content_type)} · {statusLabels[item.publish_status] || item.publish_status}</span></div><button className="admin-quiet-button" onClick={() => beginEdit(item)}>编辑</button></div>)}</div></section>}
|
||||
{activeTab === 'contents' && <section className="admin-section"><div className="admin-section-heading"><div><h2>文章与内容</h2><p className="admin-muted">选择内容类型后,可以新增、修改或删除内容。</p></div><div className="admin-actions"><select aria-label="选择内容类型" value={type} onChange={e => { setType(e.target.value); setEditing(null); loadContents(e.target.value); }}>{contentTypes.map(item => <option key={item.value} value={item.value}>{item.label}</option>)}</select>{type !== 'article' && <button className="admin-button" onClick={beginCreate}>新增{typeLabel(type)}</button>}</div></div>{type === 'article' && <ArticleManager request={(path, options) => request(path, token, options)} onMessage={text => { setError(''); setMessage(text); }} onError={text => { setMessage(''); setError(text); }} />}{type !== 'article' && editing && <form className="editor-form" onSubmit={saveContent}><p className="editor-hint">以下内容保存后,将作为这条{typeLabel(editing.content_type)}的展示内容。</p><label>标题<input value={editing.title} onChange={e => setEditing({ ...editing, title: e.target.value })} required /></label><label>正文<textarea rows={8} value={editing.body || ''} onChange={e => setEditing({ ...editing, body: e.target.value })} /></label>{(detailFields[editing.content_type] || []).map(field => <label key={field.key}>{field.label}{field.multiline ? <textarea rows={4} value={details[field.key] || ''} onChange={e => setDetails({ ...details, [field.key]: e.target.value })} /> : <input value={details[field.key] || ''} onChange={e => setDetails({ ...details, [field.key]: e.target.value })} />}</label>)}<label>发布状态<select value={editing.publish_status} onChange={e => setEditing({ ...editing, publish_status: e.target.value })}><option value="draft">草稿</option><option value="review">审核中</option><option value="published">已发布</option><option value="unpublished">已下线</option></select></label><div className="admin-actions"><button className="admin-button" type="submit">保存内容</button><button className="admin-quiet-button" type="button" onClick={() => setEditing(null)}>取消</button></div></form>}{type !== 'article' && <div className="content-table">{contents.map(item => <div className="content-row" key={item.content_id}><div><strong>{item.title || '未命名内容'}</strong><span>{typeLabel(item.content_type)} · {statusLabels[item.publish_status] || item.publish_status}</span></div><div className="admin-actions"><button className="admin-quiet-button" onClick={() => beginEdit(item)}>编辑</button><button className="admin-quiet-button danger" onClick={() => removeContent(item)}>删除</button></div></div>)}</div>}</section>}
|
||||
{activeTab === 'password' && <section className="admin-section"><h2>修改管理员密码</h2><p className="admin-muted">修改后请使用新密码登录。建议使用容易记住但不容易被猜到的密码。</p><form onSubmit={changePassword} className="admin-form narrow"><label>当前密码<input type="password" value={currentPassword} onChange={e => setCurrentPassword(e.target.value)} required /></label><label>新密码<input type="password" minLength={8} value={newPassword} onChange={e => setNewPassword(e.target.value)} required /></label><button className="admin-button" type="submit">保存新密码</button></form></section>}
|
||||
</div></main>;
|
||||
}
|
||||
|
||||
@@ -1235,3 +1235,212 @@ body {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Article management */
|
||||
.admin-quiet-button.danger:hover {
|
||||
border-color: #b42318;
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.admin-button:disabled,
|
||||
.admin-quiet-button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.article-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.article-toolbar input,
|
||||
.article-toolbar select {
|
||||
min-height: 42px;
|
||||
border: 1px solid #c8d0cc;
|
||||
border-radius: 8px;
|
||||
padding: 0 12px;
|
||||
background: #fbfcfb;
|
||||
color: var(--color-ink);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.article-toolbar input {
|
||||
flex: 1 1 220px;
|
||||
}
|
||||
|
||||
.article-count {
|
||||
margin-left: auto;
|
||||
color: var(--color-ink-soft);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.article-table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.article-table {
|
||||
width: 100%;
|
||||
min-width: 860px;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.article-table th,
|
||||
.article-table td {
|
||||
padding: 12px 10px;
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.article-table th {
|
||||
background: #f3f6f4;
|
||||
color: var(--color-ink-soft);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.article-table tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.article-table tbody tr:hover {
|
||||
background: #fbfcfb;
|
||||
}
|
||||
|
||||
.article-table td:nth-child(5),
|
||||
.article-table td:nth-child(6) {
|
||||
color: var(--color-ink-soft);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.article-title-cell strong {
|
||||
display: block;
|
||||
max-width: 360px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.article-title-cell small {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--color-ink-soft);
|
||||
}
|
||||
|
||||
.article-tag {
|
||||
display: inline-block;
|
||||
border-radius: 999px;
|
||||
padding: 3px 9px;
|
||||
background: #eef1ef;
|
||||
color: var(--color-ink-soft);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.article-tag.content_system {
|
||||
background: #e8f0fb;
|
||||
color: #1d4f91;
|
||||
}
|
||||
|
||||
.article-tag.status-published {
|
||||
background: #e6f4ea;
|
||||
color: #1e6b3a;
|
||||
}
|
||||
|
||||
.article-tag.status-unpublished {
|
||||
background: #fdeceb;
|
||||
color: #9f2a1f;
|
||||
}
|
||||
|
||||
.article-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.article-actions .admin-quiet-button {
|
||||
min-height: 32px;
|
||||
padding: 5px 10px;
|
||||
font-size: 0.8rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.article-empty {
|
||||
padding: 36px 10px !important;
|
||||
color: var(--color-ink-soft);
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.article-editor-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
background: rgba(15, 23, 20, 0.36);
|
||||
}
|
||||
|
||||
.article-editor.editor-form {
|
||||
width: min(760px, 100%);
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow-y: auto;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: #fff;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.article-editor header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.article-editor h3 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.article-editor label small {
|
||||
justify-self: end;
|
||||
margin-top: -26px;
|
||||
color: var(--color-ink-soft);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.article-editor textarea {
|
||||
resize: vertical;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.article-editor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px 18px;
|
||||
}
|
||||
|
||||
.article-note {
|
||||
margin: 0;
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
background: #fff6e5;
|
||||
color: #7a4b00;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.article-editor-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.article-count {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
+5
-4
@@ -14,6 +14,7 @@ const entityContentJoinRoutes = require('./routes/entityContentJoinRoutes');
|
||||
const publicApiRoutes = require('./routes/publicApiRoutes');
|
||||
const jsonLdRoutes = require('./routes/jsonLdRoutes');
|
||||
const adminRoutes = require('./routes/adminRoutes');
|
||||
const { requireAdminForWrites } = require('./middleware/adminAuth');
|
||||
const internalContentRoutes = require('./routes/internalContentRoutes');
|
||||
|
||||
const app = express();
|
||||
@@ -28,10 +29,10 @@ app.use(express.urlencoded({ extended: true }));
|
||||
app.use('/static', express.static(path.join(__dirname, '../public')));
|
||||
|
||||
// 路由
|
||||
app.use('/api/entities', entityRoutes);
|
||||
app.use('/api/relationships', relationshipRoutes);
|
||||
app.use('/api/contents', contentRoutes);
|
||||
app.use('/api/joins', entityContentJoinRoutes);
|
||||
app.use('/api/entities', requireAdminForWrites, entityRoutes);
|
||||
app.use('/api/relationships', requireAdminForWrites, relationshipRoutes);
|
||||
app.use('/api/contents', requireAdminForWrites, contentRoutes);
|
||||
app.use('/api/joins', requireAdminForWrites, entityContentJoinRoutes);
|
||||
app.use('/api/public', publicApiRoutes);
|
||||
app.use('/api/jsonld', jsonLdRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
const AdminAuthService = require('../services/AdminAuthService');
|
||||
|
||||
// Reading stays public (the website itself reads these lists); changing anything needs the admin login.
|
||||
const READ_ONLY = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
|
||||
function adminSession(req) {
|
||||
const header = req.headers.authorization || '';
|
||||
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
|
||||
return AdminAuthService.verify(token);
|
||||
}
|
||||
|
||||
function requireAdminForWrites(req, res, next) {
|
||||
if (READ_ONLY.has(req.method)) return next();
|
||||
const session = adminSession(req);
|
||||
if (!session) return res.status(401).json({ error: '请先登录管理后台' });
|
||||
req.admin = session;
|
||||
return next();
|
||||
}
|
||||
|
||||
module.exports = { requireAdminForWrites, adminSession };
|
||||
@@ -1,3 +1,4 @@
|
||||
const { safeOrderBy } = require('../utils/sqlSafety');
|
||||
const { getDb } = require('../config/database');
|
||||
const Content = require('./Content');
|
||||
|
||||
@@ -124,8 +125,8 @@ class Article extends Content {
|
||||
params.push(...Object.values(options.where));
|
||||
}
|
||||
|
||||
if (options.orderBy) {
|
||||
query += ` ORDER BY ${options.orderBy}`;
|
||||
if (safeOrderBy(options.orderBy)) {
|
||||
query += ` ORDER BY ${safeOrderBy(options.orderBy)}`;
|
||||
}
|
||||
|
||||
if (options.limit) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { safeOrderBy } = require('../utils/sqlSafety');
|
||||
const { getDb } = require('../config/database');
|
||||
const Content = require('./Content');
|
||||
|
||||
@@ -116,8 +117,8 @@ class Capability extends Content {
|
||||
params.push(...Object.values(options.where));
|
||||
}
|
||||
|
||||
if (options.orderBy) {
|
||||
query += ` ORDER BY ${options.orderBy}`;
|
||||
if (safeOrderBy(options.orderBy)) {
|
||||
query += ` ORDER BY ${safeOrderBy(options.orderBy)}`;
|
||||
}
|
||||
|
||||
if (options.limit) {
|
||||
|
||||
+3
-2
@@ -1,3 +1,4 @@
|
||||
const { safeOrderBy } = require('../utils/sqlSafety');
|
||||
const { getDb } = require('../config/database');
|
||||
const Content = require('./Content');
|
||||
|
||||
@@ -178,8 +179,8 @@ class Case extends Content {
|
||||
params.push(...Object.values(options.where));
|
||||
}
|
||||
|
||||
if (options.orderBy) {
|
||||
query += ` ORDER BY ${options.orderBy}`;
|
||||
if (safeOrderBy(options.orderBy)) {
|
||||
query += ` ORDER BY ${safeOrderBy(options.orderBy)}`;
|
||||
}
|
||||
|
||||
if (options.limit) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { safeOrderBy } = require('../utils/sqlSafety');
|
||||
const { getDb } = require('../config/database');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
@@ -70,8 +71,8 @@ class Content {
|
||||
params.push(...Object.values(options.where));
|
||||
}
|
||||
|
||||
if (options.orderBy) {
|
||||
query += ` ORDER BY ${options.orderBy}`;
|
||||
if (safeOrderBy(options.orderBy)) {
|
||||
query += ` ORDER BY ${safeOrderBy(options.orderBy)}`;
|
||||
}
|
||||
|
||||
if (options.limit) {
|
||||
@@ -106,8 +107,8 @@ class Content {
|
||||
params.push(...Object.values(options.where));
|
||||
}
|
||||
|
||||
if (options.orderBy) {
|
||||
query += ` ORDER BY ${options.orderBy}`;
|
||||
if (safeOrderBy(options.orderBy)) {
|
||||
query += ` ORDER BY ${safeOrderBy(options.orderBy)}`;
|
||||
}
|
||||
|
||||
if (options.limit) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { safeOrderBy } = require('../utils/sqlSafety');
|
||||
const { getDb } = require('../config/database');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
@@ -92,8 +93,8 @@ class DirectedRelationship {
|
||||
params.push(...Object.values(options.where));
|
||||
}
|
||||
|
||||
if (options.orderBy) {
|
||||
query += ` ORDER BY ${options.orderBy}`;
|
||||
if (safeOrderBy(options.orderBy)) {
|
||||
query += ` ORDER BY ${safeOrderBy(options.orderBy)}`;
|
||||
}
|
||||
|
||||
if (options.limit) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { safeOrderBy } = require('../utils/sqlSafety');
|
||||
const { getDb } = require('../config/database');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
@@ -65,8 +66,8 @@ class Entity {
|
||||
params.push(...Object.values(options.where));
|
||||
}
|
||||
|
||||
if (options.orderBy) {
|
||||
query += ` ORDER BY ${options.orderBy}`;
|
||||
if (safeOrderBy(options.orderBy)) {
|
||||
query += ` ORDER BY ${safeOrderBy(options.orderBy)}`;
|
||||
}
|
||||
|
||||
if (options.limit) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { safeOrderBy } = require('../utils/sqlSafety');
|
||||
const { getDb } = require('../config/database');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
@@ -59,8 +60,8 @@ class EntityContentJoin {
|
||||
params.push(...Object.values(options.where));
|
||||
}
|
||||
|
||||
if (options.orderBy) {
|
||||
query += ` ORDER BY ${options.orderBy}`;
|
||||
if (safeOrderBy(options.orderBy)) {
|
||||
query += ` ORDER BY ${safeOrderBy(options.orderBy)}`;
|
||||
}
|
||||
|
||||
if (options.limit) {
|
||||
|
||||
+3
-2
@@ -1,3 +1,4 @@
|
||||
const { safeOrderBy } = require('../utils/sqlSafety');
|
||||
const { getDb } = require('../config/database');
|
||||
const Content = require('./Content');
|
||||
|
||||
@@ -116,8 +117,8 @@ class FAQ extends Content {
|
||||
params.push(...Object.values(options.where));
|
||||
}
|
||||
|
||||
if (options.orderBy) {
|
||||
query += ` ORDER BY ${options.orderBy}`;
|
||||
if (safeOrderBy(options.orderBy)) {
|
||||
query += ` ORDER BY ${safeOrderBy(options.orderBy)}`;
|
||||
}
|
||||
|
||||
if (options.limit) {
|
||||
|
||||
+3
-2
@@ -1,3 +1,4 @@
|
||||
const { safeOrderBy } = require('../utils/sqlSafety');
|
||||
const { getDb } = require('../config/database');
|
||||
const Content = require('./Content');
|
||||
|
||||
@@ -145,8 +146,8 @@ class Page extends Content {
|
||||
params.push(...Object.values(options.where));
|
||||
}
|
||||
|
||||
if (options.orderBy) {
|
||||
query += ` ORDER BY ${options.orderBy}`;
|
||||
if (safeOrderBy(options.orderBy)) {
|
||||
query += ` ORDER BY ${safeOrderBy(options.orderBy)}`;
|
||||
}
|
||||
|
||||
if (options.limit) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { safeOrderBy } = require('../utils/sqlSafety');
|
||||
const { getDb } = require('../config/database');
|
||||
const Content = require('./Content');
|
||||
|
||||
@@ -116,8 +117,8 @@ class Service extends Content {
|
||||
params.push(...Object.values(options.where));
|
||||
}
|
||||
|
||||
if (options.orderBy) {
|
||||
query += ` ORDER BY ${options.orderBy}`;
|
||||
if (safeOrderBy(options.orderBy)) {
|
||||
query += ` ORDER BY ${safeOrderBy(options.orderBy)}`;
|
||||
}
|
||||
|
||||
if (options.limit) {
|
||||
|
||||
@@ -61,6 +61,41 @@ router.put('/settings/:key', (req, res) => {
|
||||
res.json({ setting_key: key, setting_value: value, setting_group: group });
|
||||
});
|
||||
|
||||
// Articles for the management table: newest first, with where each came from and its public address.
|
||||
router.get('/articles', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const rows = db.prepare(`
|
||||
SELECT c.content_id, c.title, c.publish_status, c.created_at, c.updated_at,
|
||||
a.category, a.tags, a.author, a.published_at,
|
||||
p.publication_id, p.slug, p.source_system
|
||||
FROM contents c
|
||||
LEFT JOIN articles a ON a.article_id = c.content_id
|
||||
LEFT JOIN integration_publications p ON p.content_id = c.content_id
|
||||
WHERE c.content_type = 'article'
|
||||
ORDER BY COALESCE(a.published_at, c.created_at) DESC, c.updated_at DESC
|
||||
`).all();
|
||||
res.json(rows.map((row) => ({
|
||||
...row,
|
||||
source: row.publication_id ? 'content_system' : 'manual',
|
||||
public_path: `/knowledge/${row.slug || row.content_id}`,
|
||||
})));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/contents/:type/batch-delete', (req, res) => {
|
||||
const ids = Array.isArray(req.body?.ids) ? req.body.ids.filter((id) => typeof id === 'string').slice(0, 200) : [];
|
||||
if (!ids.length) return res.status(400).json({ error: '请选择要删除的内容' });
|
||||
const deleted = [];
|
||||
const failed = [];
|
||||
ids.forEach((id) => {
|
||||
try { ContentService.delete(id); deleted.push(id); } catch (error) { failed.push({ id, error: error.message }); }
|
||||
});
|
||||
res.json({ deleted, failed });
|
||||
});
|
||||
|
||||
router.get('/contents/:type', (req, res) => {
|
||||
try {
|
||||
const { page = 1, pageSize = 50 } = req.query;
|
||||
@@ -70,6 +105,14 @@ router.get('/contents/:type', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/contents/:type/:id', (req, res) => {
|
||||
try {
|
||||
res.json(ContentService.getById(req.params.id, req.params.type));
|
||||
} catch (error) {
|
||||
res.status(404).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/contents/:type', (req, res) => {
|
||||
try {
|
||||
res.status(201).json(ContentService.create(req.body, req.params.type));
|
||||
@@ -80,7 +123,21 @@ router.post('/contents/:type', (req, res) => {
|
||||
|
||||
router.put('/contents/:type/:id', (req, res) => {
|
||||
try {
|
||||
res.json(ContentService.update(req.params.id, req.body, req.params.type));
|
||||
const data = { ...req.body };
|
||||
if (req.params.type === 'article') {
|
||||
// The admin form sends "2026-10-19T15:30" (local time); MySQL wants "2026-10-19 15:30:00".
|
||||
if (typeof data.published_at === 'string') {
|
||||
const value = data.published_at.trim();
|
||||
data.published_at = value ? value.replace('T', ' ').replace(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2})$/, '$1:00') : null;
|
||||
}
|
||||
// Going live without a publish time: now.
|
||||
if (data.publish_status === 'published' && !data.published_at) {
|
||||
const current = getDb().prepare('SELECT published_at FROM articles WHERE article_id = ?').get(req.params.id);
|
||||
if (!current?.published_at) data.published_at = new Date().toISOString();
|
||||
else delete data.published_at;
|
||||
}
|
||||
}
|
||||
res.json(ContentService.update(req.params.id, data, req.params.type));
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
|
||||
@@ -54,7 +54,8 @@ class ContentService {
|
||||
category: Joi.string().allow(null, ''),
|
||||
tags: Joi.string().allow(null, ''),
|
||||
cover_image: Joi.string().uri().allow(null, ''),
|
||||
author: Joi.string().allow(null, '')
|
||||
author: Joi.string().allow(null, ''),
|
||||
published_at: Joi.string().allow(null, '')
|
||||
};
|
||||
break;
|
||||
case 'case':
|
||||
@@ -174,7 +175,10 @@ class ContentService {
|
||||
}
|
||||
|
||||
const Model = this.getModelByType(content.content_type);
|
||||
return Model.delete(contentId);
|
||||
const result = Model.delete(contentId);
|
||||
// A deleted article published by the content system must be publishable again: forget its publication record.
|
||||
getDb().prepare('DELETE FROM integration_publications WHERE content_id = ?').run(contentId);
|
||||
return result;
|
||||
}
|
||||
|
||||
// 内容状态流转
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { safeOrderBy } = require('../utils/sqlSafety');
|
||||
const Entity = require('../models/Entity');
|
||||
const DirectedRelationship = require('../models/DirectedRelationship');
|
||||
const Content = require('../models/Content');
|
||||
@@ -67,8 +68,8 @@ class PublicApiService {
|
||||
params.push(...Object.values(options.where));
|
||||
}
|
||||
|
||||
if (options.orderBy) {
|
||||
query += ` ORDER BY ${options.orderBy}`;
|
||||
if (safeOrderBy(options.orderBy)) {
|
||||
query += ` ORDER BY ${safeOrderBy(options.orderBy)}`;
|
||||
}
|
||||
|
||||
if (options.limit) {
|
||||
@@ -143,8 +144,8 @@ class PublicApiService {
|
||||
let query = 'SELECT * FROM entities WHERE (name LIKE ? OR definition LIKE ? OR description LIKE ?)';
|
||||
const params = [`%${keyword}%`, `%${keyword}%`, `%${keyword}%`];
|
||||
|
||||
if (options.orderBy) {
|
||||
query += ` ORDER BY ${options.orderBy}`;
|
||||
if (safeOrderBy(options.orderBy)) {
|
||||
query += ` ORDER BY ${safeOrderBy(options.orderBy)}`;
|
||||
}
|
||||
|
||||
if (options.limit) {
|
||||
@@ -181,8 +182,8 @@ class PublicApiService {
|
||||
params.push(contentType);
|
||||
}
|
||||
|
||||
if (options.orderBy) {
|
||||
query += ` ORDER BY ${options.orderBy}`;
|
||||
if (safeOrderBy(options.orderBy)) {
|
||||
query += ` ORDER BY ${safeOrderBy(options.orderBy)}`;
|
||||
}
|
||||
|
||||
if (options.limit) {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// ORDER BY cannot use query parameters, so a value that reaches it must be checked first.
|
||||
// Only column names (optionally table-qualified) with ASC/DESC, separated by commas, are let through;
|
||||
// anything else (expressions, sub-queries, comments) is dropped and the default order is used.
|
||||
const ORDER_BY = /^\s*[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?(\s+(ASC|DESC))?(\s*,\s*[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?(\s+(ASC|DESC))?)*\s*$/i;
|
||||
|
||||
function safeOrderBy(value) {
|
||||
if (typeof value !== 'string' || value.length > 120 || !ORDER_BY.test(value)) return null;
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
module.exports = { safeOrderBy };
|
||||
Reference in New Issue
Block a user