移除旧版 HTML 模板与兼容层,并完善私信、举报、媒体存储与 SEO。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 16:37:11 +08:00
parent 060b7707cb
commit 48db333272
121 changed files with 11147 additions and 3225 deletions

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Database, Mail, Shield, Server, SlidersHorizontal, KeyRound, FolderGit2, Palette } from 'lucide-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';
@@ -10,9 +10,9 @@ 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';
import type { AdminSettings, ForumLimits, MailConfig, OIDCConfig, OAuthClient, GiteaSyncConfig, StorageConfig, SiteBranding, FriendLink } from '../../api/types';
type TabId = 'branding' | 'limits' | 'mail' | 'oidc' | 'gitea' | 'filter' | 'system';
type TabId = 'branding' | 'limits' | 'mail' | 'oidc' | 'gitea' | 'storage' | 'filter' | 'system';
type NumberLimitKey = {
[K in keyof ForumLimits]: ForumLimits[K] extends number ? K : never;
@@ -37,9 +37,10 @@ const SETTING_SECTIONS: SettingSection[] = [
{
id: 'rule',
title: '编辑规则',
summary: '控制普通用户修改自己帖子的时限',
summary: '控制普通用户修改自己帖子 / 评论的时限0 = 不限)',
rows: [
{ key: 'post_edit_window_hours', label: '可编辑时限', unit: '小时', hint: '0 = 不限', min: 0 },
{ key: 'post_edit_window_hours', label: '帖子可编辑时限', unit: '小时', hint: '0 = 不限', min: 0 },
{ key: 'comment_edit_window_hours', label: '评论可编辑时限', unit: '小时', hint: '0 = 不限', min: 0 },
],
},
{
@@ -103,11 +104,12 @@ const NAV_TOGGLES: { key: BoolLimitKey; label: string; hint: string }[] = [
];
const TABS: { id: TabId; label: string; icon: typeof SlidersHorizontal }[] = [
{ id: 'branding', label: '站点品牌', icon: Palette },
{ 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 },
];
@@ -154,6 +156,20 @@ const EMPTY_GITEA: GiteaSyncConfig = {
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 '未启用';
@@ -164,6 +180,19 @@ function giteaStatusLabel(gitea: GiteaSyncConfig): string {
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,
@@ -216,6 +245,7 @@ export default function AdminSettingsPage() {
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',
@@ -231,11 +261,12 @@ export default function AdminSettingsPage() {
const [loading, setLoading] = useState(true);
const [backing, setBacking] = useState(false);
const [savingBranding, setSavingBranding] = useState(false);
const [uploadingBrand, setUploadingBrand] = useState<'logo' | 'favicon' | null>(null);
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);
@@ -249,12 +280,15 @@ export default function AdminSettingsPage() {
setLimits({
open_posts_in_new_tab: true,
open_content_links_in_new_tab: true,
permalink_enabled: false,
permalink_ext: 'html',
...s.limits,
});
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);
@@ -279,11 +313,23 @@ export default function AdminSettingsPage() {
};
const handleSaveBranding = async () => {
if (!limits) return;
const links = (branding.friend_links ?? [])
.map(l => ({ name: l.name.trim(), url: l.url.trim() }))
.filter(l => l.name || l.url);
if (links.some(l => !l.name || !l.url)) {
notify.warning('友情链接需同时填写名称与完整 URL');
return;
}
setSavingBranding(true);
try {
const r = await api.adminUpdateBranding(branding);
notify.success(r.message);
const r = await api.adminUpdateBranding({ ...branding, friend_links: links });
applyBranding(r.branding);
// 伪静态与品牌同属站点呈现,一并保存
const forum = await api.adminUpdateForumSettings(limits);
setLimits(forum.limits);
invalidateForumLimitsCache();
notify.success('站点设置已保存');
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '保存失败');
} finally {
@@ -291,7 +337,7 @@ export default function AdminSettingsPage() {
}
};
const handleUploadBrandAsset = async (kind: 'logo' | 'favicon', file: File | undefined) => {
const handleUploadBrandAsset = async (kind: 'logo' | 'favicon' | 'og_image', file: File | undefined) => {
if (!file) return;
setUploadingBrand(kind);
try {
@@ -305,7 +351,7 @@ export default function AdminSettingsPage() {
}
};
const handleClearBrandAsset = async (kind: 'logo' | 'favicon') => {
const handleClearBrandAsset = async (kind: 'logo' | 'favicon' | 'og_image') => {
setUploadingBrand(kind);
try {
const r = await api.adminClearBrandingAsset(kind);
@@ -385,6 +431,24 @@ export default function AdminSettingsPage() {
}
};
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 {
@@ -558,14 +622,14 @@ export default function AdminSettingsPage() {
))}
</nav>
{activeTab === 'branding' && (
{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></span>
<span className="admin-settings-card-badge">{branding.name}</span>
</div>
<div className="admin-card-body admin-mail-body">
<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" />
@@ -574,119 +638,339 @@ export default function AdminSettingsPage() {
)}
<div>
<strong>{branding.name}</strong>
{branding.name_en && <div className="admin-mail-field-hint">{branding.name_en}</div>}
{branding.slogan && <p className="admin-mail-field-hint" style={{ marginTop: 4 }}>{branding.slogan}</p>}
</div>
</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}
/>
<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-field">
<label htmlFor="brand-name-en"></label>
<Input
id="brand-name-en"
value={branding.name_en}
onChange={e => setBranding(b => ({ ...b, name_en: e.target.value }))}
placeholder="Jiang13 Forum"
maxLength={64}
/>
<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>
<div className="admin-mail-field admin-mail-field--span2">
<label htmlFor="brand-slogan"> / Slogan</label>
<Input
id="brand-slogan"
value={branding.slogan}
onChange={e => setBranding(b => ({ ...b, slogan: e.target.value }))}
placeholder="拾三一隅,自在交流"
maxLength={200}
/>
</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>
</section>
<div className="admin-mail-grid" style={{ marginTop: 8 }}>
<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>
)}
<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>
<span className="admin-mail-field-hint">
{uploadingBrand === 'logo' ? '上传中…' : 'jpg/png/gif/webp最大 2MB'}
<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>
<div className="admin-friend-links" style={{ marginTop: 12 }}>
<div className="admin-friend-links-list">
{(branding.friend_links ?? []).map((link, idx) => (
<div key={idx} className="admin-friend-links-row">
<Input
value={link.name}
placeholder="友链名称"
maxLength={32}
onChange={e => {
const name = e.target.value;
setBranding(b => {
const next = [...(b.friend_links ?? [])];
next[idx] = { ...next[idx], name };
return { ...b, friend_links: next };
});
}}
/>
<Input
value={link.url}
placeholder="https://example.com"
maxLength={512}
onChange={e => {
const url = e.target.value;
setBranding(b => {
const next = [...(b.friend_links ?? [])];
next[idx] = { ...next[idx], url };
return { ...b, friend_links: next };
});
}}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setBranding(b => ({
...b,
friend_links: (b.friend_links ?? []).filter((_, i) => i !== idx),
}));
}}
>
</Button>
</div>
))}
</div>
<Button
type="button"
variant="outline"
size="sm"
disabled={(branding.friend_links?.length ?? 0) >= 20}
onClick={() => {
setBranding(b => ({
...b,
friend_links: [...(b.friend_links ?? []), { name: '', url: '' } as FriendLink],
}));
}}
>
</Button>
<span className="admin-mail-field-hint" style={{ display: 'block', marginTop: 8 }}>
20 http(s)
</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>
</section>
<section className="admin-settings-section" id="settings-permalink">
<div className="admin-settings-section-head">
<h3> URL</h3>
<p> / 301 </p>
</div>
</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>
<p></p>
<Button onClick={handleSaveBranding} loading={savingBranding}>
</Button>
</div>
</div>
@@ -1163,6 +1447,161 @@ export default function AdminSettingsPage() {
</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">