后台文章管理改版并修复两处安全问题

- 文章管理:按标题搜索、按状态和来源(内容系统/手动添加)筛选,显示发布时间和最后修改时间;一键上线/下线、查看前台页面、单篇删除和批量删除;右侧抽屉编辑标题、正文、分类、标签、作者、发布时间和状态;来自内容系统的文章提示再次发布会覆盖修改
- 其他内容类型的列表增加删除按钮
- 删除文章时同时删除它的发布记录,内容系统可以重新发布同一篇文章
- 上线时如果没有发布时间就用当前时间;后台表单的发布时间转换成 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:
amb
2026-10-19 15:50:55 +08:00
co-authored by Claude Opus 5.5
parent 99b70a07a6
commit ca00afb0fe
19 changed files with 504 additions and 36 deletions
+153
View File
@@ -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>;
}
+3 -1
View File
@@ -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>;
}
+209
View File
@@ -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;
}
}