video-shuoshan/web/src/pages/SettingsPage.tsx
seaislee1209 b50ad147cd
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m0s
feat: v0.15.0 Seedance 2.0 Fast 模型上线 + 四档计费
- Fast 模型:取消隐藏 Toolbar 选项,用户可选 AirDrama / AirDrama Fast
- 四档计费:按模型+有无视频参考选单价(2.0: 46/28, Fast: 37/22 元/百万tokens)
- QuotaConfig 新增 base_token_price_fast / base_token_price_fast_video 字段
- 系统设置页 4 个价格输入框(Seedance 2.0 + Fast 各两个)
- 前端预估动态选价:根据当前选的模型和有无视频参考实时计算
- 推理接入点:Fast EP ep-m-20260329211530-68999
- 消费记录表格+CSV+详情弹窗加"模型"列
- 轮询间隔改为全程固定 5 秒

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

496 lines
22 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,
base_token_price_fast: 0,
base_token_price_fast_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>
<p className={styles.cardDesc}>Seedance 2.0</p>
<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>
<p className={styles.cardDesc}>Seedance 2.0 Fast</p>
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label> (/tokens)</label>
<input
type="number"
step="0.01"
value={settings.base_token_price_fast}
onChange={(e) => setSettings({ ...settings, base_token_price_fast: Number(e.target.value) })}
/>
</div>
<div className={styles.formGroup}>
<label> (/tokens)</label>
<input
type="number"
step="0.01"
value={settings.base_token_price_fast_video}
onChange={(e) => setSettings({ ...settings, base_token_price_fast_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>
);
}