video-shuoshan/web/src/pages/SettingsPage.tsx
seaislee1209 f3f8d08b56 feat: v0.14.1 视频参考双单价 + Token刷新防抖 + CSV导出上限
- 计费双单价:含视频输入28元/百万tokens,不含视频输入46元/百万tokens
- QuotaConfig 加 base_token_price_video 字段,系统设置页两个并排输入框
- 预估费用和实际结算按参考素材类型自动选择单价
- Token 刷新加锁:同页面内并发 401 共用一次 refresh 请求
- 关闭 BLACKLIST_AFTER_ROTATION:防止快速刷新导致误登出
- ProtectedRoute 容错:请求中断时自动重试,不误跳转
- CSV 导出上限从 100 提升到 10000

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

472 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,
base_token_price_video: 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.formRow}>
<div className={styles.formGroup}>
<label> (/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>
<div className={styles.formGroup}>
<label> (/tokens)</label>
<input
type="number"
step="0.01"
value={settings.base_token_price_video}
onChange={(e) => setSettings({ ...settings, base_token_price_video: Number(e.target.value) })}
/>
</div>
</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>
);
}