Files
jiang13-forum/frontend/src/pages/admin/AdminSettingsPage.tsx
freefire cce9fb9ed8 feat: 友链入口迁至站点区并支持左右页脚独立开关
修复左侧栏/页脚开关无法关闭的问题,改为专用设置接口可靠写入。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 03:04:22 +08:00

1694 lines
72 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, 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 (
<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 [asideWidgets, setAsideWidgets] = useState<AsideWidget[]>(DEFAULT_ASIDE_WIDGETS);
const [branding, setBranding] = useState<SiteBranding>(DEFAULT_BRANDING);
const [mail, setMail] = useState<MailConfig>(EMPTY_MAIL);
const [oidc, setOidc] = useState<OIDCConfig>(EMPTY_OIDC);
const [gitea, setGitea] = useState<GiteaSyncConfig>(EMPTY_GITEA);
const [storage, setStorage] = useState<StorageConfig>(EMPTY_STORAGE);
const [oauthClients, setOauthClients] = useState<OAuthClient[]>([]);
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<number | null>(null);
const [revealedSecret, setRevealedSecret] = useState<string | null>(null);
const [testTo, setTestTo] = useState('');
const [filterWords, setFilterWords] = useState('');
const [activeTab, setActiveTab] = useState<TabId>('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 <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>OIDC/SSO</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 === 'branding' && limits && (
<div className="admin-settings-panel admin-mail-panel">
<div className="admin-card admin-settings-card">
<div className="admin-card-head">
<span></span>
<span className="admin-settings-card-badge">{branding.name}</span>
</div>
<div className="admin-card-body admin-mail-body admin-brand-sections">
<div className="admin-brand-preview">
{branding.logo ? (
<img src={branding.logo} alt="" className="admin-brand-preview-logo" />
) : (
<span className="admin-brand-preview-mark">{branding.logo_mark}</span>
)}
<div>
<strong>{branding.name}</strong>
{branding.slogan && <p className="admin-mail-field-hint" style={{ marginTop: 4 }}>{branding.slogan}</p>}
</div>
</div>
<section className="admin-settings-section" id="settings-brand-identity">
<div className="admin-settings-section-head">
<h3></h3>
<p> description</p>
</div>
<div className="admin-mail-grid">
<div className="admin-mail-field">
<label htmlFor="brand-name"></label>
<Input
id="brand-name"
value={branding.name}
onChange={e => setBranding(b => ({ ...b, name: e.target.value }))}
placeholder="姜十三论坛"
maxLength={64}
/>
</div>
<div className="admin-mail-field">
<label htmlFor="brand-mark"> Logo </label>
<Input
id="brand-mark"
value={branding.logo_mark}
onChange={e => setBranding(b => ({ ...b, logo_mark: e.target.value.slice(0, 2) }))}
placeholder="姜"
maxLength={2}
/>
<span className="admin-mail-field-hint"> 1 </span>
</div>
<div className="admin-mail-field admin-mail-field--span2">
<label htmlFor="brand-slogan"></label>
<Input
id="brand-slogan"
value={branding.slogan}
onChange={e => setBranding(b => ({ ...b, slogan: e.target.value }))}
placeholder="拾三一隅,自在交流"
maxLength={200}
/>
<span className="admin-mail-field-hint"></span>
</div>
<div className="admin-mail-field admin-mail-field--span2">
<label htmlFor="brand-description"></label>
<Textarea
id="brand-description"
value={branding.description ?? ''}
onChange={e => setBranding(b => ({ ...b, description: e.target.value }))}
placeholder="一两段话介绍本站定位与内容,便于搜索引擎与访客理解"
maxLength={500}
rows={3}
/>
<span className="admin-mail-field-hint">
SEO description退 80160
</span>
</div>
<div className="admin-mail-field admin-mail-field--span2">
<label htmlFor="brand-keywords">SEO </label>
<Input
id="brand-keywords"
value={branding.keywords ?? ''}
onChange={e => setBranding(b => ({ ...b, keywords: e.target.value }))}
placeholder="论坛,社区,技术交流"
maxLength={200}
/>
<span className="admin-mail-field-hint">
meta keywords 20
</span>
</div>
</div>
</section>
<section className="admin-settings-section" id="settings-brand-assets">
<div className="admin-settings-section-head">
<h3></h3>
<p> Logo</p>
</div>
<div className="admin-mail-grid">
<div className="admin-mail-field">
<label htmlFor="brand-logo-file"> Logo</label>
<div className="admin-brand-upload-row">
<Input
id="brand-logo-file"
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
onChange={e => {
const f = e.target.files?.[0];
void handleUploadBrandAsset('logo', f);
e.target.value = '';
}}
/>
{branding.logo && (
<Button
variant="outline"
size="sm"
loading={uploadingBrand === 'logo'}
onClick={() => void handleClearBrandAsset('logo')}
>
</Button>
)}
</div>
<span className="admin-mail-field-hint">
{uploadingBrand === 'logo' ? '上传中…' : '保留原图并生成 WebP最大 2MB'}
</span>
</div>
<div className="admin-mail-field">
<label htmlFor="brand-favicon-file">Favicon</label>
<div className="admin-brand-upload-row">
<Input
id="brand-favicon-file"
type="file"
accept="image/png,image/jpeg,image/gif,image/webp,image/x-icon,image/vnd.microsoft.icon"
onChange={e => {
const f = e.target.files?.[0];
void handleUploadBrandAsset('favicon', f);
e.target.value = '';
}}
/>
{branding.favicon && (
<Button
variant="outline"
size="sm"
loading={uploadingBrand === 'favicon'}
onClick={() => void handleClearBrandAsset('favicon')}
>
</Button>
)}
</div>
<span className="admin-mail-field-hint">
{branding.favicon ? `当前:${branding.favicon}` : '浏览器标签图标'}
</span>
</div>
<div className="admin-mail-field admin-mail-field--span2">
<label htmlFor="brand-og-image-file">OG Image</label>
<div className="admin-brand-upload-row">
<Input
id="brand-og-image-file"
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
onChange={e => {
const f = e.target.files?.[0];
void handleUploadBrandAsset('og_image', f);
e.target.value = '';
}}
/>
{branding.og_image && (
<Button
variant="outline"
size="sm"
loading={uploadingBrand === 'og_image'}
onClick={() => void handleClearBrandAsset('og_image')}
>
</Button>
)}
</div>
<span className="admin-mail-field-hint">
{uploadingBrand === 'og_image'
? '上传中…'
: branding.og_image
? `当前:${branding.og_image};建议 1200×630用于微信/社交预览;未设置时回退 Logo`
: '建议 1200×630用于微信/社交预览;未设置时回退 Logo'}
</span>
</div>
</div>
</section>
<section className="admin-settings-section" id="settings-brand-footer">
<div className="admin-settings-section-head">
<h3></h3>
<p> </p>
</div>
<div className="admin-mail-grid">
<div className="admin-mail-field">
<label htmlFor="brand-icp">ICP </label>
<Input
id="brand-icp"
value={branding.icp_beian ?? ''}
onChange={e => setBranding(b => ({ ...b, icp_beian: e.target.value }))}
placeholder="京ICP备xxxxxxxx号"
maxLength={64}
/>
</div>
<div className="admin-mail-field">
<label htmlFor="brand-icp-url">ICP </label>
<Input
id="brand-icp-url"
value={branding.icp_beian_url ?? ''}
onChange={e => setBranding(b => ({ ...b, icp_beian_url: e.target.value }))}
placeholder="https://beian.miit.gov.cn/"
maxLength={512}
/>
<span className="admin-mail-field-hint"></span>
</div>
</div>
</section>
<section className="admin-settings-section" id="settings-aside">
<div className="admin-settings-section-head">
<h3></h3>
<p></p>
</div>
<AsideWidgetList
widgets={asideWidgets}
onChange={handleAsideWidgetsChange}
/>
</section>
<section className="admin-settings-section" id="settings-nav">
<div className="admin-settings-section-head">
<h3></h3>
<p></p>
</div>
<div className="admin-settings-table" role="group" aria-label="浏览与链接">
{NAV_TOGGLES.map(row => (
<div key={row.key} className="admin-settings-row">
<span className="admin-settings-row-label" id={`limit-label-${row.key}`}>
{row.label}
</span>
<div className="admin-settings-row-input">
<button
type="button"
id={`limit-${row.key}`}
role="switch"
aria-checked={!!limits[row.key]}
aria-labelledby={`limit-label-${row.key}`}
className={`admin-settings-switch${limits[row.key] ? ' is-on' : ''}`}
onClick={() => handleBoolLimitChange(row.key, !limits[row.key])}
>
<span className="admin-settings-switch-ui" aria-hidden />
</button>
</div>
<span className="admin-settings-row-hint">{row.hint}</span>
</div>
))}
</div>
</section>
<section className="admin-settings-section" id="settings-feed-list">
<div className="admin-settings-section-head">
<h3></h3>
<p></p>
</div>
<div className="admin-settings-table" role="group" aria-label="列表呈现">
<div className="admin-settings-row">
<span className="admin-settings-row-label" id="limit-label-feed_list_style">
</span>
<div className="admin-settings-row-input admin-settings-row-input--stack">
<div className="admin-permalink-presets">
{([
{ value: 'title' as const, label: '仅标题' },
{ value: 'excerpt' as const, label: '标题+摘要' },
{ value: 'thumbnail' as const, label: '标题+摘要+缩略图' },
]).map(opt => (
<button
key={opt.value}
type="button"
className={`admin-permalink-chip${limits.feed_list_style === opt.value ? ' is-active' : ''}`}
onClick={() => setLimits(prev => prev ? { ...prev, feed_list_style: opt.value } : prev)}
>
{opt.label}
</button>
))}
</div>
</div>
<span className="admin-settings-row-hint">
</span>
</div>
</div>
</section>
<section className="admin-settings-section" id="settings-permalink">
<div className="admin-settings-section-head">
<h3> URL</h3>
<p> / 301 </p>
</div>
<div className="admin-settings-table" role="group" aria-label="伪静态">
<div className="admin-settings-row">
<span className="admin-settings-row-label" id="limit-label-permalink_enabled">
</span>
<div className="admin-settings-row-input">
<button
type="button"
id="limit-permalink_enabled"
role="switch"
aria-checked={!!limits.permalink_enabled}
aria-labelledby="limit-label-permalink_enabled"
className={`admin-settings-switch${limits.permalink_enabled ? ' is-on' : ''}`}
onClick={() => setLimits(prev => prev ? { ...prev, permalink_enabled: !prev.permalink_enabled } : prev)}
>
<span className="admin-settings-switch-ui" aria-hidden />
</button>
</div>
<span className="admin-settings-row-hint">/post/123 · /post/123.</span>
</div>
<div className="admin-settings-row">
<span className="admin-settings-row-label" id="limit-label-permalink_ext">
URL
</span>
<div className="admin-settings-row-input admin-settings-row-input--stack">
<div className="admin-permalink-presets">
{(['html', 'htm', 'shtml'] as const).map(ext => (
<button
key={ext}
type="button"
className={`admin-permalink-chip${limits.permalink_ext === ext ? ' is-active' : ''}`}
disabled={!limits.permalink_enabled}
onClick={() => setLimits(prev => prev ? { ...prev, permalink_ext: ext } : prev)}
>
.{ext}
</button>
))}
</div>
<Input
id="limit-permalink_ext"
value={limits.permalink_ext}
disabled={!limits.permalink_enabled}
placeholder="html"
aria-labelledby="limit-label-permalink_ext"
onChange={e => {
const v = e.target.value.replace(/^\./, '').toLowerCase();
setLimits(prev => prev ? { ...prev, permalink_ext: v } : prev);
}}
/>
</div>
<span className="admin-settings-row-hint">
<code className="admin-permalink-preview">
/post/123{limits.permalink_enabled ? `.${(limits.permalink_ext || 'html').replace(/^\./, '')}` : ''}
</code>
</span>
</div>
</div>
</section>
</div>
</div>
<div className="admin-settings-bar">
<p></p>
<Button onClick={handleSaveBranding} loading={savingBranding}>
</Button>
</div>
</div>
)}
{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 === 'mail' && (
<div className="admin-settings-panel admin-mail-panel">
<div className="admin-card admin-settings-card">
<div className="admin-card-head">
<span>SMTP </span>
<span className={`admin-mail-status${mail.enabled ? ' is-on' : ''}`}>
<span className="admin-mail-status-dot" aria-hidden />
{mail.enabled ? '已启用' : '未启用'}
</span>
</div>
<div className="admin-card-body admin-mail-body">
<label className="admin-mail-switch" htmlFor="mail-enabled">
<input
id="mail-enabled"
type="checkbox"
checked={mail.enabled}
onChange={e => setMail(m => ({ ...m, enabled: e.target.checked }))}
/>
<span className="admin-mail-switch-ui" aria-hidden />
<span className="admin-mail-switch-copy">
<strong></strong>
<small></small>
</span>
</label>
<div className="admin-mail-grid">
<div className="admin-mail-field admin-mail-field--span2">
<label htmlFor="mail-host">SMTP </label>
<Input
id="mail-host"
value={mail.host}
onChange={e => setMail(m => ({ ...m, host: e.target.value }))}
placeholder="smtp.example.com"
autoComplete="off"
/>
</div>
<div className="admin-mail-field">
<label htmlFor="mail-port"></label>
<Input
id="mail-port"
type="number"
min={1}
value={mail.port}
onChange={e => setMail(m => ({ ...m, port: parseInt(e.target.value, 10) || 0 }))}
/>
<span className="admin-mail-field-hint"> 465 / 587</span>
</div>
<div className="admin-mail-field">
<label htmlFor="mail-encryption"></label>
<select
id="mail-encryption"
className="admin-mail-select"
value={mail.encryption}
onChange={e => setMail(m => ({
...m,
encryption: e.target.value as MailConfig['encryption'],
}))}
>
<option value="ssl">SSL / TLS465</option>
<option value="starttls">STARTTLS587</option>
<option value="none"></option>
</select>
</div>
<div className="admin-mail-field">
<label htmlFor="mail-username"></label>
<Input
id="mail-username"
value={mail.username}
onChange={e => setMail(m => ({ ...m, username: e.target.value }))}
placeholder="SMTP 登录账号"
autoComplete="off"
/>
</div>
<div className="admin-mail-field">
<label htmlFor="mail-password"></label>
<Input
id="mail-password"
type="password"
value={mail.password || ''}
onChange={e => setMail(m => ({ ...m, password: e.target.value }))}
placeholder={mail.has_password ? '已设置,留空不改' : '密码或授权码'}
autoComplete="new-password"
/>
</div>
<div className="admin-mail-field">
<label htmlFor="mail-from"></label>
<Input
id="mail-from"
type="email"
value={mail.from}
onChange={e => setMail(m => ({ ...m, from: e.target.value }))}
placeholder="noreply@example.com"
/>
</div>
<div className="admin-mail-field">
<label htmlFor="mail-from-name"></label>
<Input
id="mail-from-name"
value={mail.from_name}
onChange={e => setMail(m => ({ ...m, from_name: e.target.value }))}
placeholder="姜十三论坛"
/>
</div>
</div>
<div className="admin-mail-test">
<div className="admin-mail-test-head">
<strong></strong>
<span></span>
</div>
<div className="admin-mail-test-row">
<Input
id="mail-test-to"
type="email"
value={testTo}
onChange={e => setTestTo(e.target.value)}
placeholder="your@email.com"
aria-label="测试收件邮箱"
/>
<Button variant="outline" onClick={handleTestMail} loading={testingMail}>
</Button>
</div>
</div>
</div>
</div>
<div className="admin-settings-bar">
<p></p>
<Button onClick={handleSaveMailSettings} loading={savingMail}>
</Button>
</div>
</div>
)}
{activeTab === 'oidc' && (
<div className="admin-settings-panel admin-mail-panel">
<div className="admin-card admin-settings-card">
<div className="admin-card-head">
<span>OIDC Provider</span>
<span className={`admin-mail-status${oidc.ready ? ' is-on' : ''}`}>
<span className="admin-mail-status-dot" aria-hidden />
{oidcStatusLabel(oidc, oauthClients.length)}
</span>
</div>
<div className="admin-card-body admin-mail-body">
<label className="admin-mail-switch" htmlFor="oidc-enabled">
<input
id="oidc-enabled"
type="checkbox"
checked={oidc.enabled}
onChange={e => setOidc(o => ({ ...o, enabled: e.target.checked }))}
/>
<span className="admin-mail-switch-ui" aria-hidden />
<span className="admin-mail-switch-copy">
<strong> OIDC</strong>
<small>Gitea </small>
</span>
</label>
<div className="admin-mail-grid">
<div className="admin-mail-field admin-mail-field--span2">
<label htmlFor="oidc-root-url">ROOT_URL</label>
<Input
id="oidc-root-url"
value={oidc.root_url}
onChange={e => setOidc(o => ({ ...o, root_url: e.target.value }))}
placeholder="https://bbs.iioio.com"
autoComplete="off"
/>
<span className="admin-mail-field-hint"> OIDC Issuer</span>
</div>
<div className="admin-mail-field">
<label htmlFor="oidc-group-claim"> Claim </label>
<Input
id="oidc-group-claim"
value={oidc.group_claim}
onChange={e => setOidc(o => ({ ...o, group_claim: e.target.value }))}
placeholder="groups"
/>
<span className="admin-mail-field-hint">Gitea Claim </span>
</div>
<div className="admin-mail-field">
<label htmlFor="oidc-user-group"></label>
<Input
id="oidc-user-group"
value={oidc.user_group}
onChange={e => setOidc(o => ({ ...o, user_group: e.target.value }))}
placeholder="gitea-users"
/>
</div>
<div className="admin-mail-field">
<label htmlFor="oidc-admin-group"></label>
<Input
id="oidc-admin-group"
value={oidc.admin_group}
onChange={e => setOidc(o => ({ ...o, admin_group: e.target.value }))}
placeholder="gitea-admin"
/>
<span className="admin-mail-field-hint">Gitea</span>
</div>
</div>
{oidc.discovery_url && (
<div className="admin-mail-test">
<div className="admin-mail-test-head">
<strong> Gitea </strong>
<span>Provider OpenID Connect groups </span>
</div>
<code className="admin-oidc-url">{oidc.discovery_url}</code>
{oidc.logout_url && <code className="admin-oidc-url">{oidc.logout_url}</code>}
</div>
)}
</div>
</div>
<div className="admin-settings-bar">
<p> bcrypt </p>
<Button onClick={handleSaveOidcSettings} loading={savingOidc}>
</Button>
</div>
<div className="admin-card admin-settings-card" style={{ marginTop: 16 }}>
<div className="admin-card-head">
<span>OAuth </span>
<span className="admin-settings-card-badge">{oauthClients.length} </span>
</div>
<div className="admin-card-body admin-mail-body">
{revealedSecret && (
<div className="admin-mail-test">
<div className="admin-mail-test-head">
<strong></strong>
<span> Gitea</span>
</div>
<code className="admin-oidc-url">{revealedSecret}</code>
<Button variant="outline" className="mt-2" onClick={() => setRevealedSecret(null)}></Button>
</div>
)}
{oauthClients.length > 0 && (
<div className="admin-oauth-list">
{oauthClients.map(c => (
<div key={c.id} className="admin-oauth-row">
<div>
<strong>{c.name}</strong>
<div className="admin-mail-field-hint">
{c.client_id} · {c.enabled ? '启用' : '停用'} · {c.redirect_uris}
</div>
</div>
<div className="admin-oauth-actions">
<Button
variant="outline"
size="sm"
onClick={() => {
setEditingClientId(c.id);
setClientForm({
client_id: c.client_id,
name: c.name,
redirect_uris: c.redirect_uris,
enabled: c.enabled,
});
}}
>
</Button>
<Button variant="outline" size="sm" onClick={() => handleRotateSecret(c.id)} loading={savingClient}>
</Button>
<Button variant="outline" size="sm" onClick={() => handleDeleteOAuthClient(c.id)}>
</Button>
</div>
</div>
))}
</div>
)}
<div className="admin-mail-grid" style={{ marginTop: 12 }}>
{!editingClientId && (
<div className="admin-mail-field">
<label htmlFor="oauth-client-id"> ID</label>
<Input
id="oauth-client-id"
value={clientForm.client_id}
onChange={e => setClientForm(f => ({ ...f, client_id: e.target.value }))}
placeholder="gitea"
/>
</div>
)}
<div className="admin-mail-field">
<label htmlFor="oauth-client-name"></label>
<Input
id="oauth-client-name"
value={clientForm.name}
onChange={e => setClientForm(f => ({ ...f, name: e.target.value }))}
placeholder="Gitea"
/>
</div>
<div className="admin-mail-field admin-mail-field--span2">
<label htmlFor="oauth-redirect"></label>
<Textarea
id="oauth-redirect"
value={clientForm.redirect_uris}
onChange={e => setClientForm(f => ({ ...f, redirect_uris: e.target.value }))}
rows={2}
placeholder="https://git.iioio.com/user/oauth2/jiang13/callback"
/>
</div>
<label className="admin-mail-switch" htmlFor="oauth-enabled">
<input
id="oauth-enabled"
type="checkbox"
checked={clientForm.enabled}
onChange={e => setClientForm(f => ({ ...f, enabled: e.target.checked }))}
/>
<span className="admin-mail-switch-ui" aria-hidden />
<span className="admin-mail-switch-copy">
<strong></strong>
</span>
</label>
</div>
<div className="admin-settings-bar" style={{ marginTop: 8 }}>
<p>{editingClientId ? `正在编辑 #${editingClientId}` : '新建时自动生成密钥(仅显示一次)'}</p>
<div className="admin-settings-bar-actions">
{editingClientId && (
<Button variant="outline" onClick={resetClientForm}></Button>
)}
<Button onClick={handleSaveOAuthClient} loading={savingClient}>
{editingClientId ? '保存应用' : '创建应用'}
</Button>
</div>
</div>
</div>
</div>
</div>
)}
{activeTab === 'gitea' && (
<div className="admin-settings-panel admin-mail-panel">
<div className="admin-card admin-settings-card">
<div className="admin-card-head">
<span>Gitea </span>
<span className={`admin-mail-status${gitea.ready ? ' is-on' : ''}`}>
<span className="admin-mail-status-dot" aria-hidden />
{giteaStatusLabel(gitea)}
</span>
</div>
<div className="admin-card-body admin-mail-body">
<label className="admin-mail-switch" htmlFor="gitea-enabled">
<input
id="gitea-enabled"
type="checkbox"
checked={gitea.enabled}
onChange={e => setGitea(g => ({ ...g, enabled: e.target.checked }))}
/>
<span className="admin-mail-switch-ui" aria-hidden />
<span className="admin-mail-switch-copy">
<strong></strong>
<small> Gitea login</small>
</span>
</label>
<div className="admin-mail-grid">
<div className="admin-mail-field admin-mail-field--span2">
<label htmlFor="gitea-base-url">Gitea BASE_URL</label>
<Input
id="gitea-base-url"
value={gitea.base_url}
onChange={e => setGitea(g => ({ ...g, base_url: e.target.value }))}
placeholder="https://git.iioio.com"
autoComplete="off"
/>
<span className="admin-mail-field-hint"></span>
</div>
<div className="admin-mail-field admin-mail-field--span2">
<label htmlFor="gitea-token">Access Token</label>
<Input
id="gitea-token"
type="password"
value={gitea.token ?? ''}
onChange={e => setGitea(g => ({ ...g, token: e.target.value }))}
placeholder={gitea.has_token ? '已配置,留空则保持不变' : '只读 Token'}
autoComplete="new-password"
/>
<span className="admin-mail-field-hint"></span>
</div>
<div className="admin-mail-field">
<label htmlFor="gitea-interval"></label>
<Input
id="gitea-interval"
type="number"
min={5}
max={1440}
value={gitea.sync_interval_min}
onChange={e => setGitea(g => ({
...g,
sync_interval_min: Number.parseInt(e.target.value, 10) || 60,
}))}
/>
<span className="admin-mail-field-hint"> 5</span>
</div>
</div>
</div>
</div>
<div className="admin-settings-bar">
<p></p>
<div className="admin-settings-bar-actions">
<Button variant="outline" onClick={handleSyncGitea} loading={syncingGitea} disabled={!gitea.ready}>
</Button>
<Button onClick={handleSaveGiteaSettings} loading={savingGitea}>
</Button>
</div>
</div>
</div>
)}
{activeTab === 'storage' && (
<div className="admin-settings-panel admin-mail-panel">
<div className="admin-card admin-settings-card">
<div className="admin-card-head">
<span></span>
<span className={`admin-mail-status${storage.ready ? ' is-on' : ''}`}>
<span className="admin-mail-status-dot" aria-hidden />
{storageStatusLabel(storage)}
</span>
</div>
<div className="admin-card-body admin-mail-body">
<div className="admin-mail-grid">
<div className="admin-mail-field">
<label htmlFor="storage-type"></label>
<select
id="storage-type"
className="admin-mail-select"
value={storage.type}
onChange={e => setStorage(s => ({
...s,
type: e.target.value === 's3' ? 's3' : 'local',
}))}
>
<option value="local">data/uploads</option>
<option value="s3">S3 MinIO / OSS / </option>
</select>
<span className="admin-mail-field-hint"></span>
</div>
<div className="admin-mail-field">
<label htmlFor="storage-image-delivery"></label>
<select
id="storage-image-delivery"
className="admin-mail-select"
value={storage.image_delivery || 'webp'}
onChange={e => setStorage(s => ({
...s,
image_delivery: e.target.value === 'original' ? 'original' : 'webp',
}))}
>
<option value="webp">使 WebP</option>
<option value="original">使</option>
</select>
<span className="admin-mail-field-hint">
WebP/ URL GIF
</span>
</div>
</div>
{storage.type === 's3' && (
<>
<div className="admin-mail-grid">
<div className="admin-mail-field admin-mail-field--span2">
<label htmlFor="storage-endpoint">Endpoint</label>
<Input
id="storage-endpoint"
value={storage.endpoint}
onChange={e => setStorage(s => ({ ...s, endpoint: e.target.value }))}
placeholder="https://s3.example.com"
autoComplete="off"
/>
</div>
<div className="admin-mail-field">
<label htmlFor="storage-region">Region</label>
<Input
id="storage-region"
value={storage.region}
onChange={e => setStorage(s => ({ ...s, region: e.target.value }))}
placeholder="us-east-1"
autoComplete="off"
/>
</div>
<div className="admin-mail-field">
<label htmlFor="storage-bucket">Bucket</label>
<Input
id="storage-bucket"
value={storage.bucket}
onChange={e => setStorage(s => ({ ...s, bucket: e.target.value }))}
placeholder="jiang13"
autoComplete="off"
/>
</div>
<div className="admin-mail-field">
<label htmlFor="storage-access-key">Access Key</label>
<Input
id="storage-access-key"
value={storage.access_key}
onChange={e => setStorage(s => ({ ...s, access_key: e.target.value }))}
autoComplete="off"
/>
</div>
<div className="admin-mail-field">
<label htmlFor="storage-secret-key">Secret Key</label>
<Input
id="storage-secret-key"
type="password"
value={storage.secret_key ?? ''}
onChange={e => setStorage(s => ({ ...s, secret_key: e.target.value }))}
placeholder={storage.has_secret_key ? '已配置,留空则保持不变' : 'Secret Key'}
autoComplete="new-password"
/>
</div>
<div className="admin-mail-field admin-mail-field--span2">
<label htmlFor="storage-public-base">访</label>
<Input
id="storage-public-base"
value={storage.public_base_url}
onChange={e => setStorage(s => ({ ...s, public_base_url: e.target.value }))}
placeholder="https://cdn.example.com/forum"
autoComplete="off"
/>
<span className="admin-mail-field-hint"> URL</span>
</div>
<div className="admin-mail-field">
<label htmlFor="storage-prefix"></label>
<Input
id="storage-prefix"
value={storage.prefix}
onChange={e => setStorage(s => ({ ...s, prefix: e.target.value }))}
placeholder="forum/"
autoComplete="off"
/>
</div>
<div className="admin-mail-field">
<label className="admin-mail-switch" htmlFor="storage-path-style" style={{ marginTop: 22 }}>
<input
id="storage-path-style"
type="checkbox"
checked={storage.force_path_style}
onChange={e => setStorage(s => ({ ...s, force_path_style: e.target.checked }))}
/>
<span className="admin-mail-switch-ui" aria-hidden />
<span className="admin-mail-switch-copy">
<strong>Path-Style</strong>
<small>MinIO AWS S3 </small>
</span>
</label>
</div>
</div>
<p className="admin-mail-field-hint" style={{ marginTop: 8 }}>
Bucket CDN ACL
</p>
</>
)}
</div>
</div>
<div className="admin-settings-bar">
<p> URL</p>
<Button onClick={handleSaveStorageSettings} loading={savingStorage}>
</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>
);
}