import { useEffect, useState } from 'react'; import { Database, Mail, Shield, Server, SlidersHorizontal, KeyRound, FolderGit2, Palette } 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 type { AdminSettings, ForumLimits, MailConfig, OIDCConfig, OAuthClient, GiteaSyncConfig, SiteBranding } from '../../api/types'; type TabId = 'branding' | 'limits' | 'mail' | 'oidc' | 'gitea' | '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: '控制普通用户修改自己帖子的时限', 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: '条', 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: '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, }; 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 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 => (
onChange(row.key, e.target.value)} className="admin-settings-input" /> {row.unit && {row.unit}}
{row.hint ?? ''}
))}
))}
); } export default function AdminSettingsPage() { const { ready } = useAdminGuard(); const [settings, setSettings] = useState(null); const [limits, setLimits] = useState(null); 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 [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' | null>(null); const [savingForum, setSavingForum] = useState(false); const [savingMail, setSavingMail] = useState(false); const [savingOidc, setSavingOidc] = useState(false); const [savingGitea, setSavingGitea] = 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); setLimits({ open_posts_in_new_tab: true, open_content_links_in_new_tab: true, ...s.limits, }); setBranding({ ...DEFAULT_BRANDING, ...(s.branding ?? {}) }); setMail({ ...EMPTY_MAIL, ...s.mail, password: '' }); setOidc({ ...EMPTY_OIDC, ...(s.oidc ?? {}) }); setGitea({ ...EMPTY_GITEA, ...(s.gitea ?? {}), token: '' }); 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 applyBranding = (next: SiteBranding) => { setBranding({ ...DEFAULT_BRANDING, ...next }); setSettings(s => s ? { ...s, branding: next } : s); seedSiteBrandingCache(next); }; const handleSaveBranding = async () => { setSavingBranding(true); try { const r = await api.adminUpdateBranding(branding); notify.success(r.message); applyBranding(r.branding); } catch (e: unknown) { notify.error(e instanceof Error ? e.message : '保存失败'); } finally { setSavingBranding(false); } }; const handleUploadBrandAsset = async (kind: 'logo' | 'favicon', 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') => { 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 r = await api.adminUpdateForumSettings(limits); notify.success(r.message); invalidateForumLimitsCache(); clearAllFeedCache(); 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 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 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 (

系统设置

管理论坛运行规则、邮件、OIDC/SSO、敏感词与数据维护

{activeTab === 'branding' && (
站点品牌 {branding.name}
{branding.logo ? ( ) : ( {branding.logo_mark} )}
{branding.name} {branding.name_en &&
{branding.name_en}
} {branding.slogan &&

{branding.slogan}

}
setBranding(b => ({ ...b, name: e.target.value }))} placeholder="姜十三论坛" maxLength={64} />
setBranding(b => ({ ...b, name_en: e.target.value }))} placeholder="Jiang13 Forum" maxLength={64} />
setBranding(b => ({ ...b, slogan: e.target.value }))} placeholder="拾三一隅,自在交流" maxLength={200} />
setBranding(b => ({ ...b, logo_mark: e.target.value.slice(0, 2) }))} placeholder="姜" maxLength={2} /> 建议 1 个汉字或字母
{ const f = e.target.files?.[0]; void handleUploadBrandAsset('logo', f); e.target.value = ''; }} /> {branding.logo && ( )}
{uploadingBrand === 'logo' ? '上传中…' : 'jpg/png/gif/webp,最大 2MB'}
{ const f = e.target.files?.[0]; void handleUploadBrandAsset('favicon', f); e.target.value = ''; }} /> {branding.favicon && ( )}
{branding.favicon ? `当前:${branding.favicon}` : '浏览器标签图标'}

保存后立即影响顶栏、登录页、浏览器标题与右栏介绍

)} {activeTab === 'limits' && (
论坛限制 共 {SETTING_SECTIONS.length + 1} 组

浏览与链接

控制打开帖子与正文链接时是否新开浏览器标签页

{NAV_TOGGLES.map(row => (
{row.label}
{row.hint}
))}

修改后请点击保存,新规则立即对全部用户生效

)} {activeTab === 'mail' && (
SMTP 邮件配置 {mail.enabled ? '已启用' : '未启用'}
setMail(m => ({ ...m, host: e.target.value }))} placeholder="smtp.example.com" autoComplete="off" />
setMail(m => ({ ...m, port: parseInt(e.target.value, 10) || 0 }))} /> 常用 465 / 587
setMail(m => ({ ...m, username: e.target.value }))} placeholder="SMTP 登录账号" autoComplete="off" />
setMail(m => ({ ...m, password: e.target.value }))} placeholder={mail.has_password ? '已设置,留空不改' : '密码或授权码'} autoComplete="new-password" />
setMail(m => ({ ...m, from: e.target.value }))} placeholder="noreply@example.com" />
setMail(m => ({ ...m, from_name: e.target.value }))} placeholder="姜十三论坛" />
发送测试 请先保存配置,再向指定邮箱发一封测试信
setTestTo(e.target.value)} placeholder="your@email.com" aria-label="测试收件邮箱" />

保存后立即生效,注册页将按配置要求邮箱验证码

)} {activeTab === 'oidc' && (
OIDC Provider(全局) {oidcStatusLabel(oidc, oauthClients.length)}
setOidc(o => ({ ...o, root_url: e.target.value }))} placeholder="https://bbs.iioio.com" autoComplete="off" /> 无尾斜杠;作为 OIDC Issuer
setOidc(o => ({ ...o, group_claim: e.target.value }))} placeholder="groups" /> Gitea「用户组 Claim 名称」填此项
setOidc(o => ({ ...o, user_group: e.target.value }))} placeholder="gitea-users" />
setOidc(o => ({ ...o, admin_group: e.target.value }))} placeholder="gitea-admin" /> Gitea「管理员用户组」填此项
{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 && (
setClientForm(f => ({ ...f, client_id: e.target.value }))} placeholder="gitea" />
)}
setClientForm(f => ({ ...f, name: e.target.value }))} placeholder="Gitea" />