video-shuoshan/web/src/pages/SettingsPage.tsx
seaislee1209 0ab5523ed1
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m5s
feat: v0.12.6 公告弹窗 + HTML 编辑器
①公告改为弹窗(用户未读自动弹出,已读不再弹)
②生成页右上角小铃铛按钮可重新查看公告
③公告支持 HTML 渲染(加粗/红字/蓝字/标题/分割线/列表)
④超管公告编辑器加格式工具栏 + 预览按钮
⑤去掉旧的公告横幅

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 20:57:58 +08:00

460 lines
21 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, useCallback, useRef } from 'react';
import { adminApi } from '../lib/api';
import type { SystemSettings } from '../types';
import { showToast } from '../components/Toast';
import styles from './SettingsPage.module.css';
export function SettingsPage() {
const [settings, setSettings] = useState<SystemSettings>({
default_daily_seconds_limit: 600,
default_monthly_seconds_limit: 6000,
default_daily_generation_limit: 50,
default_monthly_generation_limit: 500,
base_token_price: 0,
announcement: '',
announcement_enabled: false,
max_desktop_sessions: 1,
max_mobile_sessions: 0,
anomaly_detection_enabled: false,
r1_enabled_default: true,
r2_enabled_default: true,
r2_window_seconds: 3600,
r3_enabled_default: true,
r3_window_seconds: 3600,
r3_max_count: 10,
r4_enabled_default: true,
r4_window_seconds: 3600,
r4_city_count: 5,
r5_enabled_default: true,
r5_days: 7,
r5_country_count: 10,
feishu_alert_mobiles: '',
sms_alert_mobiles: '',
alert_cooldown_seconds: 1800,
});
const [testingFeishu, setTestingFeishu] = useState(false);
const [testingSms, setTestingSms] = useState(false);
const [previewAnnouncement, setPreviewAnnouncement] = useState(false);
const announcementRef = useRef<HTMLTextAreaElement>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const fetchSettings = useCallback(async () => {
try {
const { data } = await adminApi.getSettings();
setSettings(data);
} catch {
showToast('加载设置失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchSettings(); }, [fetchSettings]);
const handleSaveQuota = async () => {
setSaving(true);
try {
await adminApi.updateSettings(settings);
showToast('设置已保存');
} catch {
showToast('保存失败');
} finally {
setSaving(false);
}
};
const handleSaveAnnouncement = async () => {
setSaving(true);
try {
await adminApi.updateSettings(settings);
showToast('公告已保存');
} catch {
showToast('保存失败');
} finally {
setSaving(false);
}
};
const handleTestFeishu = async () => {
const mobiles = settings.feishu_alert_mobiles.split(',').map(s => s.trim()).filter(Boolean);
if (mobiles.length === 0) { showToast('请先填写飞书告警手机号'); return; }
setTestingFeishu(true);
try {
await adminApi.testFeishu(mobiles[0]);
showToast('测试消息已发送');
} catch (err: any) {
showToast(err.response?.data?.error || '发送失败');
} finally {
setTestingFeishu(false);
}
};
const handleTestSms = async () => {
const mobiles = settings.sms_alert_mobiles.split(',').map(s => s.trim()).filter(Boolean);
if (mobiles.length === 0) { showToast('请先填写短信告警手机号'); return; }
setTestingSms(true);
try {
await adminApi.testSms(mobiles[0]);
showToast('测试短信已发送');
} catch (err: any) {
showToast(err.response?.data?.error || '发送失败');
} finally {
setTestingSms(false);
}
};
if (loading) {
return (
<div className={styles.page}>
<h1 className={styles.title}></h1>
<div className={styles.skeletonCard} />
<div className={styles.skeletonCard} />
</div>
);
}
return (
<div className={styles.page}>
<h1 className={styles.title}></h1>
<div className={styles.grid}>
<div className={styles.card}>
<h2 className={styles.cardTitle}></h2>
<p className={styles.cardDesc}></p>
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label></label>
<input
type="number"
value={settings.default_daily_generation_limit}
onChange={(e) => setSettings({ ...settings, default_daily_generation_limit: Number(e.target.value) })}
/>
</div>
<div className={styles.formGroup}>
<label></label>
<input
type="number"
value={settings.default_monthly_generation_limit}
onChange={(e) => setSettings({ ...settings, default_monthly_generation_limit: Number(e.target.value) })}
/>
</div>
</div>
<div className={styles.formGroup}>
<label>token单价 (/tokens)</label>
<input
type="number"
step="0.01"
value={settings.base_token_price}
onChange={(e) => setSettings({ ...settings, base_token_price: Number(e.target.value) })}
/>
</div>
<button className={styles.saveBtn} onClick={handleSaveQuota} disabled={saving}>
{saving ? '保存中...' : '保存配额设置'}
</button>
</div>
<div className={styles.card}>
<h2 className={styles.cardTitle}></h2>
<p className={styles.cardDesc}></p>
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label></label>
<input
type="number"
min={1}
value={settings.max_desktop_sessions}
onChange={(e) => setSettings({ ...settings, max_desktop_sessions: Number(e.target.value) })}
/>
</div>
<div className={styles.formGroup}>
<label></label>
<input
type="number"
min={0}
value={settings.max_mobile_sessions}
onChange={(e) => setSettings({ ...settings, max_mobile_sessions: Number(e.target.value) })}
/>
</div>
</div>
<p className={styles.cardHint}> 1 0 </p>
<button className={styles.saveBtn} onClick={handleSaveQuota} disabled={saving}>
{saving ? '保存中...' : '保存登录限制'}
</button>
</div>
<div className={`${styles.card} ${styles.gridFull}`}>
<div className={styles.cardHeader}>
<div>
<h2 className={styles.cardTitle}></h2>
<p className={styles.cardDesc}></p>
</div>
<label className={styles.switch}>
<input
type="checkbox"
checked={settings.announcement_enabled}
onChange={(e) => setSettings({ ...settings, announcement_enabled: e.target.checked })}
/>
<span className={styles.slider}></span>
</label>
</div>
<div className={styles.formGroup}>
<label> HTML </label>
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
{[
{ label: 'B', tag: 'b', title: '加粗' },
{ label: '红字', wrap: ['<span style="color:#ff4d4f">', '</span>'], title: '红色文字' },
{ label: '蓝字', wrap: ['<span style="color:#00b8e6">', '</span>'], title: '蓝色文字' },
{ label: 'H3', wrap: ['<h3 style="margin:8px 0 4px">', '</h3>'], title: '标题' },
{ label: '分割线', insert: '<hr style="border:none;border-top:1px solid #333;margin:12px 0">', title: '分割线' },
{ label: '列表项', insert: '<li>', title: '列表项' },
].map((btn) => (
<button
key={btn.label}
type="button"
title={btn.title}
onClick={() => {
const ta = announcementRef.current;
if (!ta) return;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const text = settings.announcement;
const selected = text.substring(start, end);
let newText: string;
let cursorPos: number;
if ('insert' in btn && btn.insert) {
newText = text.substring(0, start) + btn.insert + text.substring(end);
cursorPos = start + btn.insert.length;
} else if ('wrap' in btn && btn.wrap) {
newText = text.substring(0, start) + btn.wrap[0] + selected + btn.wrap[1] + text.substring(end);
cursorPos = start + btn.wrap[0].length + selected.length + btn.wrap[1].length;
} else {
const open = `<${btn.tag}>`;
const close = `</${btn.tag}>`;
newText = text.substring(0, start) + open + selected + close + text.substring(end);
cursorPos = start + open.length + selected.length + close.length;
}
setSettings({ ...settings, announcement: newText });
setTimeout(() => { ta.focus(); ta.setSelectionRange(cursorPos, cursorPos); }, 0);
}}
style={{
padding: '2px 8px', fontSize: 12, background: 'rgba(255,255,255,0.06)',
border: '1px solid var(--color-border-card)', borderRadius: 4,
color: 'var(--color-text-secondary)', cursor: 'pointer',
}}
>
{btn.label}
</button>
))}
<button
type="button"
onClick={() => setPreviewAnnouncement(!previewAnnouncement)}
style={{
padding: '2px 8px', fontSize: 12,
background: previewAnnouncement ? 'rgba(108,99,255,0.12)' : 'rgba(255,255,255,0.06)',
border: `1px solid ${previewAnnouncement ? 'var(--color-primary)' : 'var(--color-border-card)'}`,
borderRadius: 4,
color: previewAnnouncement ? 'var(--color-primary)' : 'var(--color-text-secondary)',
cursor: 'pointer',
}}
>
{previewAnnouncement ? '编辑' : '预览'}
</button>
</div>
{previewAnnouncement ? (
<div style={{
padding: '12px 16px', background: 'var(--color-bg-page)',
border: '1px solid var(--color-border-card)', borderRadius: 8,
fontSize: 14, lineHeight: 1.8, color: 'var(--color-text-primary)',
minHeight: 100, wordBreak: 'break-word',
}} dangerouslySetInnerHTML={{ __html: settings.announcement }} />
) : (
<textarea
ref={announcementRef}
className={styles.textarea}
value={settings.announcement}
onChange={(e) => setSettings({ ...settings, announcement: e.target.value })}
placeholder="输入公告内容,支持 HTML 格式..."
rows={6}
/>
)}
</div>
<button className={styles.saveBtn} onClick={handleSaveAnnouncement} disabled={saving}>
{saving ? '保存中...' : '保存公告'}
</button>
</div>
<div className={`${styles.card} ${styles.gridFull}`}>
<div className={styles.cardHeader}>
<div>
<h2 className={styles.cardTitle}></h2>
<p className={styles.cardDesc}> IP </p>
</div>
<label className={styles.switch}>
<input
type="checkbox"
checked={settings.anomaly_detection_enabled}
onChange={(e) => setSettings({ ...settings, anomaly_detection_enabled: e.target.checked })}
/>
<span className={styles.slider}></span>
</label>
</div>
{settings.anomaly_detection_enabled && (
<>
<h3 style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text-primary)', marginBottom: 12, marginTop: 8 }}></h3>
<div style={{ marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
<label className={styles.switch} style={{ marginBottom: 0 }}>
<input type="checkbox" checked={settings.r1_enabled_default} onChange={(e) => setSettings({ ...settings, r1_enabled_default: e.target.checked })} />
<span className={styles.slider}></span>
</label>
<span style={{ color: 'var(--color-text-primary)', fontSize: 13 }}>R1 </span>
<span style={{ color: 'var(--color-text-secondary)', fontSize: 12 }}></span>
</div>
</div>
<div style={{ marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
<label className={styles.switch} style={{ marginBottom: 0 }}>
<input type="checkbox" checked={settings.r2_enabled_default} onChange={(e) => setSettings({ ...settings, r2_enabled_default: e.target.checked })} />
<span className={styles.slider}></span>
</label>
<span style={{ color: 'var(--color-text-primary)', fontSize: 13 }}>R2 </span>
<span style={{ color: 'var(--color-text-secondary)', fontSize: 12 }}></span>
</div>
<div className={styles.formGroup} style={{ marginLeft: 56 }}>
<label> ()</label>
<input type="number" value={settings.r2_window_seconds} onChange={(e) => setSettings({ ...settings, r2_window_seconds: Number(e.target.value) })} />
</div>
</div>
<div style={{ marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
<label className={styles.switch} style={{ marginBottom: 0 }}>
<input type="checkbox" checked={settings.r3_enabled_default} onChange={(e) => setSettings({ ...settings, r3_enabled_default: e.target.checked })} />
<span className={styles.slider}></span>
</label>
<span style={{ color: 'var(--color-text-primary)', fontSize: 13 }}>R3 </span>
<span style={{ color: 'var(--color-text-secondary)', fontSize: 12 }}></span>
</div>
<div className={styles.formRow} style={{ marginLeft: 56 }}>
<div className={styles.formGroup}>
<label> ()</label>
<input type="number" value={settings.r3_window_seconds} onChange={(e) => setSettings({ ...settings, r3_window_seconds: Number(e.target.value) })} />
</div>
<div className={styles.formGroup}>
<label></label>
<input type="number" value={settings.r3_max_count} onChange={(e) => setSettings({ ...settings, r3_max_count: Number(e.target.value) })} />
</div>
</div>
</div>
<div style={{ marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
<label className={styles.switch} style={{ marginBottom: 0 }}>
<input type="checkbox" checked={settings.r4_enabled_default} onChange={(e) => setSettings({ ...settings, r4_enabled_default: e.target.checked })} />
<span className={styles.slider}></span>
</label>
<span style={{ color: 'var(--color-text-primary)', fontSize: 13 }}>R4 </span>
<span style={{ color: 'var(--color-text-secondary)', fontSize: 12 }}></span>
</div>
<div className={styles.formRow} style={{ marginLeft: 56 }}>
<div className={styles.formGroup}>
<label> ()</label>
<input type="number" value={settings.r4_window_seconds} onChange={(e) => setSettings({ ...settings, r4_window_seconds: Number(e.target.value) })} />
</div>
<div className={styles.formGroup}>
<label></label>
<input type="number" value={settings.r4_city_count} onChange={(e) => setSettings({ ...settings, r4_city_count: Number(e.target.value) })} />
</div>
</div>
</div>
<div style={{ marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
<label className={styles.switch} style={{ marginBottom: 0 }}>
<input type="checkbox" checked={settings.r5_enabled_default} onChange={(e) => setSettings({ ...settings, r5_enabled_default: e.target.checked })} />
<span className={styles.slider}></span>
</label>
<span style={{ color: 'var(--color-text-primary)', fontSize: 13 }}>R5 IP太杂</span>
<span style={{ color: 'var(--color-text-secondary)', fontSize: 12 }}></span>
</div>
<div className={styles.formRow} style={{ marginLeft: 56 }}>
<div className={styles.formGroup}>
<label></label>
<input type="number" value={settings.r5_days} onChange={(e) => setSettings({ ...settings, r5_days: Number(e.target.value) })} />
</div>
<div className={styles.formGroup}>
<label></label>
<input type="number" value={settings.r5_country_count} onChange={(e) => setSettings({ ...settings, r5_country_count: Number(e.target.value) })} />
</div>
</div>
</div>
<h3 style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text-primary)', marginBottom: 12 }}></h3>
<div className={styles.formGroup}>
<label></label>
<div style={{ display: 'flex', gap: 8 }}>
<input
type="text"
value={settings.feishu_alert_mobiles}
onChange={(e) => setSettings({ ...settings, feishu_alert_mobiles: e.target.value })}
placeholder="13800138000,13900139000"
style={{ flex: 1 }}
/>
<button
className={styles.saveBtn}
onClick={handleTestFeishu}
disabled={testingFeishu}
style={{ whiteSpace: 'nowrap', padding: '10px 16px' }}
>
{testingFeishu ? '发送中...' : '测试'}
</button>
</div>
</div>
<div className={styles.formGroup}>
<label></label>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
type="text"
value={settings.sms_alert_mobiles}
onChange={(e) => setSettings({ ...settings, sms_alert_mobiles: e.target.value })}
placeholder="多个手机号用逗号分隔,如 13800138000,13900139000"
style={{ flex: 1 }}
/>
<button
className={styles.saveBtn}
onClick={handleTestSms}
disabled={testingSms}
style={{ whiteSpace: 'nowrap', padding: '10px 16px' }}
>
{testingSms ? '发送中...' : '测试'}
</button>
</div>
</div>
<div className={styles.formGroup}>
<label> ()</label>
<input
type="number"
value={settings.alert_cooldown_seconds}
onChange={(e) => setSettings({ ...settings, alert_cooldown_seconds: Number(e.target.value) })}
/>
<p className={styles.cardHint}> + </p>
</div>
</>
)}
<button className={styles.saveBtn} onClick={handleSaveQuota} disabled={saving}>
{saving ? '保存中...' : '保存异常检测设置'}
</button>
</div>
</div>
</div>
);
}