Files
jiang13-forum/frontend/src/pages/admin/AdminSettingsPage.tsx

327 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react';
import { Database, Shield, Server, SlidersHorizontal } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { api } from '../../api/client';
import { useAdminGuard } from '../../layouts/AdminLayout';
import { invalidateForumLimitsCache } from '../../hooks/useForumLimits';
import type { AdminSettings, ForumLimits } from '../../api/types';
type TabId = 'limits' | 'filter' | 'system';
type SettingRow = {
key: keyof ForumLimits;
label: string;
unit?: string;
hint?: string;
min?: number;
};
type SettingSection = {
id: string;
title: string;
summary: string;
rows: SettingRow[];
};
const SETTING_SECTIONS: SettingSection[] = [
{
id: 'rule',
title: '编辑规则',
summary: '控制普通用户修改自己帖子的时限',
rows: [
{ key: 'post_edit_window_hours', label: '可编辑时限', unit: '小时', hint: '0 = 不限', min: 0 },
],
},
{
id: 'rate',
title: '操作限流',
summary: '同一用户或 IP 在窗口期内的最大请求次数',
rows: [
{ key: 'rate_limit_window_sec', label: '限流窗口', unit: '秒', min: 10 },
{ key: 'rate_limit_post', label: '发帖', unit: '次', min: 1 },
{ key: 'rate_limit_comment', label: '评论', unit: '次', min: 1 },
{ key: 'rate_limit_register', label: '注册', unit: '次', min: 1 },
{ key: 'rate_limit_login', label: '登录', unit: '次', min: 1 },
],
},
{
id: 'content',
title: '内容长度',
summary: '发帖与评论的字数上限,服务端强制校验',
rows: [
{ key: 'post_title_max', label: '帖子标题', unit: '字', min: 1 },
{ key: 'post_tags_max', label: '帖子标签', unit: '字', hint: '0 = 不限', min: 0 },
{ key: 'post_content_max', label: '帖子正文', unit: '字', hint: '0 = 不限', min: 0 },
{ key: 'comment_max', label: '评论内容', unit: '字', min: 1 },
],
},
{
id: 'search',
title: '搜索与列表',
summary: '关键词长度与帖子列表分页',
rows: [
{ key: 'search_keyword_min', label: '关键词最短', unit: '字', min: 0 },
{ key: 'search_keyword_max', label: '关键词最长', unit: '字', min: 1 },
{ key: 'page_size_default', label: '默认每页', unit: '条', min: 1 },
{ key: 'page_size_max', label: '最大每页', unit: '条', min: 1 },
{ key: 'feed_max_pages', label: '首页自动加载页数', unit: '页', hint: '超出后需手动加载', min: 1 },
{ key: 'feed_max_items', label: '首页自动加载条数', unit: '条', hint: '与页数取先到上限', min: 1 },
],
},
{
id: 'user',
title: '用户账号',
summary: '注册、改密与头像上传限制',
rows: [
{ key: 'password_min_len', label: '密码最短', unit: '位', min: 4 },
{ key: 'avatar_max_mb', label: '头像上限', unit: 'MB', min: 1 },
],
},
];
const TABS: { id: TabId; label: string; icon: typeof SlidersHorizontal }[] = [
{ id: 'limits', label: '论坛限制', icon: SlidersHorizontal },
{ id: 'filter', label: '敏感词', icon: Shield },
{ id: 'system', label: '系统维护', icon: Server },
];
function SettingTable({
sections,
limits,
onChange,
}: {
sections: SettingSection[];
limits: ForumLimits;
onChange: (key: keyof ForumLimits, value: string) => void;
}) {
return (
<div className="admin-settings-sections">
{sections.map(section => (
<section key={section.id} className="admin-settings-section" id={`settings-${section.id}`}>
<div className="admin-settings-section-head">
<h3>{section.title}</h3>
<p>{section.summary}</p>
</div>
<div className="admin-settings-table" role="group" aria-label={section.title}>
{section.rows.map(row => (
<div key={row.key} className="admin-settings-row">
<label htmlFor={`limit-${row.key}`} className="admin-settings-row-label">
{row.label}
</label>
<div className="admin-settings-row-input">
<Input
id={`limit-${row.key}`}
type="number"
min={row.min ?? 0}
value={limits[row.key]}
onChange={e => onChange(row.key, e.target.value)}
className="admin-settings-input"
/>
{row.unit && <span className="admin-settings-unit">{row.unit}</span>}
</div>
<span className="admin-settings-row-hint">{row.hint ?? ''}</span>
</div>
))}
</div>
</section>
))}
</div>
);
}
export default function AdminSettingsPage() {
const { ready } = useAdminGuard();
const [settings, setSettings] = useState<AdminSettings | null>(null);
const [limits, setLimits] = useState<ForumLimits | null>(null);
const [filterWords, setFilterWords] = useState('');
const [activeTab, setActiveTab] = useState<TabId>('limits');
const [loading, setLoading] = useState(true);
const [backing, setBacking] = useState(false);
const [savingForum, setSavingForum] = useState(false);
const [savingFilter, setSavingFilter] = useState(false);
useEffect(() => {
if (!ready) return;
api.adminSettings()
.then(s => {
setSettings(s);
setLimits(s.limits);
setFilterWords(s.filter_words);
})
.finally(() => setLoading(false));
}, [ready]);
const handleLimitChange = (key: keyof ForumLimits, value: string) => {
const n = parseInt(value, 10);
if (Number.isNaN(n)) return;
setLimits(prev => prev ? { ...prev, [key]: n } : prev);
};
const handleSaveForumSettings = async () => {
if (!limits) return;
setSavingForum(true);
try {
const r = await api.adminUpdateForumSettings(limits);
notify.success(r.message);
invalidateForumLimitsCache();
setLimits(r.limits);
setSettings(s => s ? { ...s, limits: r.limits } : s);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSavingForum(false);
}
};
const handleSaveFilterWords = async () => {
setSavingFilter(true);
try {
const r = await api.adminUpdateFilterWords(filterWords);
notify.success(r.message);
setSettings(s => s ? { ...s, filter_words: filterWords, filter_word_count: r.word_count } : s);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSavingFilter(false);
}
};
const handleBackup = async () => {
setBacking(true);
try {
const r = await api.adminBackup();
notify.success(r.message);
window.location.href = r.download;
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '备份失败');
} finally {
setBacking(false);
}
};
if (!ready || loading) {
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
}
if (!settings || !limits) return null;
return (
<div className="admin-settings-page">
<header className="admin-page-head">
<h1></h1>
<p></p>
</header>
<nav className="admin-settings-tabs" aria-label="设置分类">
{TABS.map(({ id, label, icon: Icon }) => (
<button
key={id}
type="button"
className={`admin-settings-tab${activeTab === id ? ' active' : ''}`}
onClick={() => setActiveTab(id)}
aria-selected={activeTab === id}
>
<Icon size={15} />
{label}
</button>
))}
</nav>
{activeTab === 'limits' && (
<div className="admin-settings-panel">
<div className="admin-card admin-settings-card">
<div className="admin-card-head">
<span></span>
<span className="admin-settings-card-badge"> {SETTING_SECTIONS.length} </span>
</div>
<div className="admin-card-body">
<SettingTable sections={SETTING_SECTIONS} limits={limits} onChange={handleLimitChange} />
</div>
</div>
<div className="admin-settings-bar">
<p></p>
<Button onClick={handleSaveForumSettings} loading={savingForum}>
</Button>
</div>
</div>
)}
{activeTab === 'filter' && (
<div className="admin-settings-panel">
<div className="admin-card admin-settings-card">
<div className="admin-card-head">
<span></span>
<span className="admin-settings-card-badge">{settings.filter_word_count} </span>
</div>
<div className="admin-card-body admin-settings-filter-body">
<p className="admin-settings-filter-tip">
<code>#</code> <code>filter_words.txt</code>
</p>
<Textarea
rows={16}
value={filterWords}
onChange={e => setFilterWords(e.target.value)}
className="admin-settings-filter-textarea"
spellCheck={false}
placeholder={'# 示例\n违禁词\n广告刷单'}
/>
</div>
</div>
<div className="admin-settings-bar">
<p> *</p>
<Button onClick={handleSaveFilterWords} loading={savingFilter}>
</Button>
</div>
</div>
)}
{activeTab === 'system' && (
<div className="admin-settings-panel">
<div className="admin-card admin-settings-card">
<div className="admin-card-head"></div>
<div className="admin-card-body">
<dl className="admin-settings-info">
<div className="admin-settings-info-row">
<dt></dt>
<dd><code>{settings.data_dir}</code></dd>
</div>
<div className="admin-settings-info-row">
<dt></dt>
<dd><code>{settings.db_path}</code></dd>
</div>
<div className="admin-settings-info-row">
<dt></dt>
<dd><code>{settings.filter_path}</code></dd>
</div>
<div className="admin-settings-info-row">
<dt></dt>
<dd>{settings.port}</dd>
</div>
</dl>
</div>
</div>
<div className="admin-card admin-settings-card">
<div className="admin-card-head"></div>
<div className="admin-card-body admin-settings-backup-body">
<p> SQLite 便</p>
<p className="admin-settings-backup-name">
<code>jiang13_backup_YYYYMMDD_HHMMSS.db</code>
</p>
<Button onClick={handleBackup} loading={backing}>
<Database size={16} />
</Button>
</div>
</div>
</div>
)}
</div>
);
}