import { useEffect, useState } from 'react';
import { Database, Mail, Shield, Server, SlidersHorizontal, KeyRound, FolderGit2, Palette, HardDrive } 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 { DEFAULT_BRANDING, seedSiteBrandingCache } from '../../hooks/useSiteBranding';
import { clearAllFeedCache } from '../../utils/feedCache';
import { normalizeAsideWidgets, resolveAsideWidgets, mergeForumLimitsWithAsideWidgets, resolveSavedAsideWidgets } from '../../utils/asideWidgets';
import AsideWidgetList from '../../components/admin/AsideWidgetList';
import type { AdminSettings, ForumLimits, MailConfig, OIDCConfig, OAuthClient, GiteaSyncConfig, StorageConfig, SiteBranding, AsideWidget } from '../../api/types';
import { DEFAULT_ASIDE_WIDGETS } from '../../api/types';
type TabId = 'branding' | 'limits' | 'mail' | 'oidc' | 'gitea' | 'storage' | 'filter' | 'system';
type NumberLimitKey = {
[K in keyof ForumLimits]: ForumLimits[K] extends number ? K : never;
}[keyof ForumLimits];
type SettingRow = {
key: NumberLimitKey;
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: '控制普通用户修改自己帖子 / 评论的时限(0 = 不限)',
rows: [
{ key: 'post_edit_window_hours', label: '帖子可编辑时限', unit: '小时', hint: '0 = 不限', min: 0 },
{ key: 'comment_edit_window_minutes', 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: '条', 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 },
{ key: 'signature_max', label: '签名上限', unit: '字', min: 0 },
],
},
];
type BoolLimitKey =
| 'open_posts_in_new_tab'
| 'open_content_links_in_new_tab';
const NAV_TOGGLES: { key: BoolLimitKey; label: string; hint: string }[] = [
{
key: 'open_posts_in_new_tab',
label: '打开帖子时新开标签页',
hint: '首页、热门、收藏等入口打开帖子详情',
},
{
key: 'open_content_links_in_new_tab',
label: '帖子正文链接新开标签页',
hint: '正文内的外链与站内链接均在新标签打开',
},
];
const TABS: { id: TabId; label: string; icon: typeof SlidersHorizontal }[] = [
{ id: 'branding', label: '站点与呈现', icon: Palette },
{ id: 'limits', label: '论坛限制', icon: SlidersHorizontal },
{ id: 'mail', label: '邮件服务', icon: Mail },
{ id: 'oidc', label: 'OIDC / SSO', icon: KeyRound },
{ id: 'gitea', label: 'Gitea 同步', icon: FolderGit2 },
{ id: 'storage', label: '对象存储', icon: HardDrive },
{ id: 'filter', label: '敏感词', icon: Shield },
{ id: 'system', label: '系统维护', icon: Server },
];
const EMPTY_MAIL: MailConfig = {
enabled: false,
host: '',
port: 465,
username: '',
from: '',
from_name: '姜十三论坛',
encryption: 'ssl',
has_password: false,
};
const EMPTY_OIDC: OIDCConfig = {
enabled: false,
root_url: '',
ready: false,
group_claim: 'groups',
admin_group: 'gitea-admin',
user_group: 'gitea-users',
client_count: 0,
};
/** 就绪需:已启用 + 已持久化 ROOT_URL + 至少一个启用中的应用 */
function oidcStatusLabel(oidc: OIDCConfig, appCount: number): string {
if (oidc.ready) return '已就绪';
if (!oidc.enabled) return '未启用';
const reasons: string[] = [];
// discovery_url 仅在服务端已保存 ROOT_URL 时返回;表单里填写但未点保存时仍为空
if (!oidc.discovery_url) reasons.push('保存 ROOT_URL');
if (appCount < 1 && (oidc.client_count ?? 0) < 1) reasons.push('至少一个应用');
if (reasons.length === 0) reasons.push('点击「保存全局设置」刷新状态');
return `未就绪(需${reasons.join('、')})`;
}
const EMPTY_GITEA: GiteaSyncConfig = {
enabled: false,
base_url: '',
has_token: false,
sync_interval_min: 60,
ready: false,
repo_count: 0,
};
const EMPTY_STORAGE: StorageConfig = {
type: 'local',
endpoint: '',
region: 'us-east-1',
bucket: '',
access_key: '',
public_base_url: '',
prefix: '',
force_path_style: true,
has_secret_key: false,
ready: true,
image_delivery: 'webp',
};
function giteaStatusLabel(gitea: GiteaSyncConfig): string {
if (gitea.ready) return `已就绪 · ${gitea.repo_count} 个仓库`;
if (!gitea.enabled) return '未启用';
const reasons: string[] = [];
if (!gitea.base_url.trim()) reasons.push('BASE_URL');
if (!gitea.has_token) reasons.push('Token');
if (reasons.length === 0) reasons.push('保存后生效');
return `未就绪(需${reasons.join('、')})`;
}
function storageStatusLabel(storage: StorageConfig): string {
if (storage.type === 'local') return '本地磁盘';
if (storage.ready) return 'S3 已就绪';
const reasons: string[] = [];
if (!storage.endpoint.trim()) reasons.push('Endpoint');
if (!storage.bucket.trim()) reasons.push('Bucket');
if (!storage.access_key.trim()) reasons.push('Access Key');
if (!storage.has_secret_key) reasons.push('Secret Key');
if (!storage.public_base_url.trim()) reasons.push('公开访问地址');
if (reasons.length === 0) reasons.push('保存后生效');
return `未就绪(需${reasons.join('、')})`;
}
function SettingTable({
sections,
limits,
onChange,
}: {
sections: SettingSection[];
limits: ForumLimits;
onChange: (key: NumberLimitKey, value: string) => void;
}) {
return (
{sections.map(section => (
{section.title}
{section.summary}
{section.rows.map(row => (
))}
))}
);
}
export default function AdminSettingsPage() {
const { ready } = useAdminGuard();
const [settings, setSettings] = useState(null);
const [limits, setLimits] = useState(null);
const [asideWidgets, setAsideWidgets] = useState(DEFAULT_ASIDE_WIDGETS);
const [branding, setBranding] = useState(DEFAULT_BRANDING);
const [mail, setMail] = useState(EMPTY_MAIL);
const [oidc, setOidc] = useState(EMPTY_OIDC);
const [gitea, setGitea] = useState(EMPTY_GITEA);
const [storage, setStorage] = useState(EMPTY_STORAGE);
const [oauthClients, setOauthClients] = useState([]);
const [clientForm, setClientForm] = useState({
client_id: 'gitea',
name: 'Gitea',
redirect_uris: 'https://git.iioio.com/user/oauth2/jiang13/callback',
enabled: true,
});
const [editingClientId, setEditingClientId] = useState(null);
const [revealedSecret, setRevealedSecret] = useState(null);
const [testTo, setTestTo] = useState('');
const [filterWords, setFilterWords] = useState('');
const [activeTab, setActiveTab] = useState('branding');
const [loading, setLoading] = useState(true);
const [backing, setBacking] = useState(false);
const [savingBranding, setSavingBranding] = useState(false);
const [uploadingBrand, setUploadingBrand] = useState<'logo' | 'favicon' | 'og_image' | null>(null);
const [savingForum, setSavingForum] = useState(false);
const [savingMail, setSavingMail] = useState(false);
const [savingOidc, setSavingOidc] = useState(false);
const [savingGitea, setSavingGitea] = useState(false);
const [savingStorage, setSavingStorage] = useState(false);
const [syncingGitea, setSyncingGitea] = useState(false);
const [savingClient, setSavingClient] = useState(false);
const [testingMail, setTestingMail] = useState(false);
const [savingFilter, setSavingFilter] = useState(false);
useEffect(() => {
if (!ready) return;
api.adminSettings()
.then(s => {
setSettings(s);
const loadedAsideWidgets = normalizeAsideWidgets(
resolveAsideWidgets({
aside_widgets: s.limits?.aside_widgets,
aside_show_tag_cloud: s.limits?.aside_show_tag_cloud ?? false,
aside_show_recent_comments: s.limits?.aside_show_recent_comments ?? false,
aside_show_friend_links: s.limits?.aside_show_friend_links ?? true,
}),
);
setAsideWidgets(loadedAsideWidgets);
const baseLimits: ForumLimits = {
open_posts_in_new_tab: true,
open_content_links_in_new_tab: true,
aside_show_tag_cloud: false,
aside_show_recent_comments: false,
aside_show_friend_links: true,
aside_widgets: loadedAsideWidgets,
nav_show_friend_links: true,
footer_show_friend_links: true,
feed_list_style: 'title',
permalink_enabled: false,
permalink_ext: 'html',
...s.limits,
};
setLimits(mergeForumLimitsWithAsideWidgets(baseLimits, loadedAsideWidgets));
setBranding({ ...DEFAULT_BRANDING, ...(s.branding ?? {}) });
setMail({ ...EMPTY_MAIL, ...s.mail, password: '' });
setOidc({ ...EMPTY_OIDC, ...(s.oidc ?? {}) });
setGitea({ ...EMPTY_GITEA, ...(s.gitea ?? {}), token: '' });
setStorage({ ...EMPTY_STORAGE, ...(s.storage ?? {}), secret_key: '' });
setOauthClients(s.oauth_clients ?? []);
setFilterWords(s.filter_words);
if (s.mail?.from) setTestTo(s.mail.from);
})
.finally(() => setLoading(false));
}, [ready]);
const handleLimitChange = (key: NumberLimitKey, value: string) => {
const n = parseInt(value, 10);
if (Number.isNaN(n)) return;
setLimits(prev => prev ? { ...prev, [key]: n } : prev);
};
const handleBoolLimitChange = (key: BoolLimitKey, checked: boolean) => {
setLimits(prev => prev ? { ...prev, [key]: checked } : prev);
};
const handleAsideWidgetsChange = (widgets: AsideWidget[]) => {
const normalized = normalizeAsideWidgets(widgets);
setAsideWidgets(normalized);
setLimits(prev => prev ? mergeForumLimitsWithAsideWidgets(prev, normalized) : prev);
};
const applySavedForumLimits = (savedWidgets: AsideWidget[], response: ForumLimits): ForumLimits => {
const nextWidgets = resolveSavedAsideWidgets(savedWidgets, response.aside_widgets);
const merged = mergeForumLimitsWithAsideWidgets(response, nextWidgets);
setAsideWidgets(nextWidgets);
setLimits(merged);
return merged;
};
const applyBranding = (next: SiteBranding) => {
setBranding({ ...DEFAULT_BRANDING, ...next });
setSettings(s => s ? { ...s, branding: next } : s);
seedSiteBrandingCache(next);
};
const handleSaveBranding = async () => {
if (!limits) return;
setSavingBranding(true);
try {
const r = await api.adminUpdateBranding(branding);
applyBranding(r.branding);
const forumPayload = mergeForumLimitsWithAsideWidgets(limits, asideWidgets);
// 伪静态、右侧栏、浏览与链接、列表呈现同属站点呈现,一并保存
const forum = await api.adminUpdateForumSettings(forumPayload);
applySavedForumLimits(asideWidgets, forum.limits);
clearAllFeedCache();
notify.success('站点设置已保存');
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSavingBranding(false);
}
};
const handleUploadBrandAsset = async (kind: 'logo' | 'favicon' | 'og_image', file: File | undefined) => {
if (!file) return;
setUploadingBrand(kind);
try {
const r = await api.adminUploadBrandingAsset(kind, file);
notify.success(r.message);
applyBranding(r.branding);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '上传失败');
} finally {
setUploadingBrand(null);
}
};
const handleClearBrandAsset = async (kind: 'logo' | 'favicon' | 'og_image') => {
setUploadingBrand(kind);
try {
const r = await api.adminClearBrandingAsset(kind);
notify.success(r.message);
applyBranding(r.branding);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '清除失败');
} finally {
setUploadingBrand(null);
}
};
const handleSaveForumSettings = async () => {
if (!limits) return;
setSavingForum(true);
try {
const forumPayload = mergeForumLimitsWithAsideWidgets(limits, asideWidgets);
const r = await api.adminUpdateForumSettings(forumPayload);
notify.success(r.message);
invalidateForumLimitsCache();
clearAllFeedCache();
const merged = applySavedForumLimits(asideWidgets, r.limits);
setSettings(s => s ? { ...s, limits: merged } : s);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSavingForum(false);
}
};
const handleSaveMailSettings = async () => {
setSavingMail(true);
try {
const payload: MailConfig = {
...mail,
password: mail.password?.trim() ? mail.password : undefined,
};
const r = await api.adminUpdateMailSettings(payload);
notify.success(r.message);
setMail({ ...r.mail, password: '' });
setSettings(s => s ? { ...s, mail: r.mail } : s);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSavingMail(false);
}
};
const handleSaveOidcSettings = async () => {
setSavingOidc(true);
try {
const r = await api.adminUpdateOIDCSettings(oidc);
notify.success(r.message);
setOidc({ ...EMPTY_OIDC, ...r.oidc });
setSettings(s => s ? { ...s, oidc: r.oidc } : s);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSavingOidc(false);
}
};
const handleSaveGiteaSettings = async () => {
setSavingGitea(true);
try {
const payload: GiteaSyncConfig = {
...gitea,
token: gitea.token?.trim() ? gitea.token : undefined,
};
const r = await api.adminUpdateGiteaSettings(payload);
notify.success(r.message);
setGitea({ ...EMPTY_GITEA, ...r.gitea, token: '' });
setSettings(s => s ? { ...s, gitea: r.gitea } : s);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSavingGitea(false);
}
};
const handleSaveStorageSettings = async () => {
setSavingStorage(true);
try {
const payload: StorageConfig = {
...storage,
secret_key: storage.secret_key?.trim() ? storage.secret_key : undefined,
};
const r = await api.adminUpdateStorageSettings(payload);
notify.success(r.message);
setStorage({ ...EMPTY_STORAGE, ...r.storage, secret_key: '' });
setSettings(s => s ? { ...s, storage: r.storage } : s);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSavingStorage(false);
}
};
const handleSyncGitea = async () => {
setSyncingGitea(true);
try {
const r = await api.adminSyncGitea();
notify.success(r.message);
setGitea({ ...EMPTY_GITEA, ...r.gitea, token: '' });
setSettings(s => s ? { ...s, gitea: r.gitea } : s);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '同步失败');
} finally {
setSyncingGitea(false);
}
};
const refreshClients = (clients: OAuthClient[], nextOidc?: OIDCConfig) => {
setOauthClients(clients);
if (nextOidc) {
setOidc({ ...EMPTY_OIDC, ...nextOidc });
setSettings(s => s ? { ...s, oidc: nextOidc, oauth_clients: clients } : s);
} else {
setSettings(s => s ? { ...s, oauth_clients: clients } : s);
}
};
const resetClientForm = () => {
setEditingClientId(null);
setClientForm({
client_id: 'gitea',
name: 'Gitea',
redirect_uris: 'https://git.iioio.com/user/oauth2/jiang13/callback',
enabled: true,
});
};
const handleSaveOAuthClient = async () => {
setSavingClient(true);
try {
if (editingClientId) {
const r = await api.adminUpdateOAuthClient(editingClientId, {
name: clientForm.name,
redirect_uris: clientForm.redirect_uris,
enabled: clientForm.enabled,
});
notify.success(r.message);
if (r.client.client_secret) setRevealedSecret(r.client.client_secret);
const list = await api.adminListOAuthClients();
refreshClients(list.clients, r.oidc);
resetClientForm();
} else {
const r = await api.adminCreateOAuthClient({
client_id: clientForm.client_id,
name: clientForm.name,
redirect_uris: clientForm.redirect_uris,
enabled: clientForm.enabled,
});
notify.success(r.message);
if (r.client.client_secret) setRevealedSecret(r.client.client_secret);
const list = await api.adminListOAuthClients();
refreshClients(list.clients, r.oidc);
resetClientForm();
}
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSavingClient(false);
}
};
const handleRotateSecret = async (id: number) => {
if (!window.confirm('确定轮换密钥?旧密钥将立即失效。')) return;
setSavingClient(true);
try {
const row = oauthClients.find(c => c.id === id);
if (!row) return;
const r = await api.adminUpdateOAuthClient(id, {
name: row.name,
redirect_uris: row.redirect_uris,
enabled: row.enabled,
rotate_secret: true,
});
notify.success(r.message);
if (r.client.client_secret) setRevealedSecret(r.client.client_secret);
const list = await api.adminListOAuthClients();
refreshClients(list.clients, r.oidc);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '轮换失败');
} finally {
setSavingClient(false);
}
};
const handleDeleteOAuthClient = async (id: number) => {
if (!window.confirm('确定删除该 OAuth 应用?')) return;
try {
const r = await api.adminDeleteOAuthClient(id);
notify.success(r.message);
const list = await api.adminListOAuthClients();
refreshClients(list.clients, r.oidc);
if (editingClientId === id) resetClientForm();
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '删除失败');
}
};
const handleTestMail = async () => {
if (!testTo.trim()) {
notify.error('请填写测试收件邮箱');
return;
}
setTestingMail(true);
try {
const r = await api.adminTestMail(testTo.trim());
notify.success(r.message);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '发送失败');
} finally {
setTestingMail(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
;
}
if (!settings || !limits) return null;
return (
{activeTab === 'branding' && limits && (
站点与呈现
{branding.name}
{branding.logo ? (

) : (
{branding.logo_mark}
)}
{branding.name}
{branding.slogan &&
{branding.slogan}
}
品牌标识
名称、标语、简介与字标;简介用于右侧栏顶部展示与搜索引擎 description
视觉资源
站点 Logo、浏览器标签图标与默认社交分享图
右侧栏组件
「站点简介」固定在最上方;以下模块可拖拽排序,保存后同步影响桌面右侧栏与手机「社区动态」抽屉
浏览与链接
控制打开帖子与正文链接时是否新开浏览器标签页
{NAV_TOGGLES.map(row => (
{row.label}
{row.hint}
))}
帖子列表样式
{([
{ value: 'title' as const, label: '仅标题' },
{ value: 'excerpt' as const, label: '标题+摘要' },
{ value: 'thumbnail' as const, label: '标题+摘要+缩略图' },
]).map(opt => (
))}
仅标题最紧凑;摘要模式增加一行预览;缩略图模式在有站内配图时右侧显示封面
伪静态 URL
为帖子 / 用户生成带后缀的规范链接,非规范路径会 301 跳转
启用伪静态
关闭:/post/123 · 开启:/post/123.后缀
URL 后缀
预览:
/post/123{limits.permalink_enabled ? `.${(limits.permalink_ext || 'html').replace(/^\./, '')}` : ''}
保存后立即影响顶栏、页脚、列表样式、链接打开方式、伪静态链接与浏览器标题
)}
{activeTab === 'limits' && (
论坛限制
共 {SETTING_SECTIONS.length} 组
修改后请点击保存,新规则立即对全部用户生效
)}
{activeTab === 'mail' && (
SMTP 邮件配置
{mail.enabled ? '已启用' : '未启用'}
保存后立即生效,注册页将按配置要求邮箱验证码
)}
{activeTab === 'oidc' && (
OIDC Provider(全局)
{oidcStatusLabel(oidc, oauthClients.length)}
{oidc.discovery_url && (
给 Gitea 填写
Provider 选 OpenID Connect;可选填 groups 映射管理员
{oidc.discovery_url}
{oidc.logout_url &&
登出:{oidc.logout_url}}
)}
全局配置保存后立即生效;应用凭证在下方管理(密钥 bcrypt 存储)
OAuth 应用
{oauthClients.length} 个
{revealedSecret && (
客户端密钥(仅显示一次)
请立即复制到 Gitea,离开后无法再查看明文
{revealedSecret}
)}
{oauthClients.length > 0 && (
{oauthClients.map(c => (
{c.name}
{c.client_id} · {c.enabled ? '启用' : '停用'} · {c.redirect_uris}
))}
)}
{editingClientId ? `正在编辑 #${editingClientId}` : '新建时自动生成密钥(仅显示一次)'}
{editingClientId && (
)}
)}
{activeTab === 'gitea' && (
Gitea 仓库同步
{giteaStatusLabel(gitea)}
保存后按间隔自动同步;也可立即手动拉取
)}
{activeTab === 'storage' && (
上传对象存储
{storageStatusLabel(storage)}
头像、帖子图、站点品牌图均走此后端
静态图上传后同时保存原图与 WebP;此项只影响新上传写入正文/头像的 URL。动图 GIF 始终保留原文件
{storage.type === 's3' && (
<>
请确保 Bucket 已配置公开读或 CDN 回源可读;本程序不代设 ACL。
>
)}
保存后立即生效,无需重启;切换存储后端或展示方案不会改写历史帖子里的图片 URL
)}
{activeTab === 'filter' && (
敏感词过滤
{settings.filter_word_count} 个词条
每行一个词,# 开头为注释。保存后写入 filter_words.txt 并立即生效。
敏感词会在发帖、评论、昵称等文本中自动替换为 *
)}
{activeTab === 'system' && (
运行信息
- 数据目录
{settings.data_dir}
- 数据库
{settings.db_path}
- 敏感词文件
{settings.filter_path}
- 监听端口
- {settings.port}
数据备份
导出当前 SQLite 数据库副本,便于迁移或灾难恢复。
文件名:jiang13_backup_YYYYMMDD_HHMMSS.db
)}
);
}