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 => (
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 [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 (

系统设置

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

{activeTab === 'branding' && limits && (
站点与呈现 {branding.name}
{branding.logo ? ( ) : ( {branding.logo_mark} )}
{branding.name} {branding.slogan &&

{branding.slogan}

}

品牌标识

名称、标语、简介与字标;简介用于右侧栏顶部展示与搜索引擎 description

setBranding(b => ({ ...b, name: e.target.value }))} placeholder="姜十三论坛" maxLength={64} />
setBranding(b => ({ ...b, logo_mark: e.target.value.slice(0, 2) }))} placeholder="姜" maxLength={2} /> 建议 1 个汉字或字母
setBranding(b => ({ ...b, slogan: e.target.value }))} placeholder="拾三一隅,自在交流" maxLength={200} /> 短句,出现在浏览器标题与页脚