火山 Seedance 2.0 于 2026-04-16 上线 1080P 支持。本次实现前端 UI、
后端校验/计费、数据库迁移,并严格遵守三原则:
1. 禁止兜底/静默降级 — Fast+1080P 组合在 UI/store/serializer/view/计价
五层防御,任一层穿透都 fail loud,不悄悄按 720P 扣费
2. 钱的计算绝对准确 — 前端预估公式与后端 estimate_tokens 完全一致
`(输入时长+输出时长) × 宽 × 高 × fps / 1024`;实际扣费按火山返回
total_tokens × 官方单价;预估端不维护最低 token 修正表
3. 不隐藏 bug — 无 `or '720p'` / `|| '720p'` 兜底;类型严格;异常暴露
## 后端(7 处 + 1 次迁移)
- models.py: QuotaConfig 加 base_token_price_1080p(51)/base_token_price_1080p_video(31);
GenerationRecord.resolution 加 RESOLUTION_CHOICES 约束 + default='720p'
- migrations/0020: 含 RunPython data migration 回填历史 resolution='' → '720p'
- utils/billing.py:
* RESOLUTION_MAP 加 1080P 六种宽高比(21:9 是 2206×946,不是 seedance 1.0 值)
* get_resolution 去掉 tier 默认值,非法组合 raise KeyError 不静默降级
* estimate_tokens 纯官方公式,加 input_video_duration 参数(公式完整)
- utils/airdrama_client.py: create_task 加 resolution 必填参数(无默认值)
- apps/generation/serializers.py:
* VideoGenerateSerializer 加 resolution ChoiceField
* aspect_ratio 改 ChoiceField 显式拒绝 adaptive
* SystemSettingsSerializer 加 2 个 1080P 单价
- apps/generation/views.py:
* _get_token_price 加 resolution 必填参数,Fast+1080P raise ValueError
* _sum_video_duration 累加视频参考时长
* video_generate_view 读 resolution、400 拒绝 Fast+1080P 组合、
传给 get_resolution/estimate_tokens/_get_token_price/create_task/
GenerationRecord.resolution(移除 L450 硬编码 '720p')
* _settle_payment 按 record.resolution 取单价(1080P 结算按 1080P 价)
* _serialize_task + 5 处手工序列化加 resolution 字段(无 `or '720p'`)
- apps/accounts/views.py: team 接口返回 token_price_1080p/_video
## 前端(10 处)
- types/index.ts: Resolution 类型;GenerationTask/BackendTask/Team/
QuotaConfig/AssetVideo 加字段(全部必填,无 optional)
- store/inputBar.ts: resolution state;setModel/setResolution 双向拦截
Fast+1080P 组合,toast 提示引导,不静默降级
- store/generation.ts: addTask/backendToFrontend/reEdit/regenerate 全链路
携带 resolution;mapErrorMessage 改 '今日生成次数或团队余额不足'
- components/Toolbar.tsx:
* 加分辨率选择器 Dropdown(位置:比例和时长之间)
* modelItems/resolutionItems 双向 disabled(Fast 下 1080P 灰 / 1080P 下 Fast 灰)
* estimatedTokens 对齐后端公式(含输入视频时长 + assetMentions 视频时长)
* estimatedCost 按 resolution 选单价(Fast→fast_*、1080p→1080p_*、其他→基础)
* tooltip 明示"实际费用以火山 API 返回的 token 数为准"
- components/Dropdown.tsx: 加 disabled 属性支持
- components/VideoDetailModal.tsx: 重新编辑恢复 resolution
- components/GenerationCard.tsx: 动态显示 task.resolution.toUpperCase()
- pages/SettingsPage.tsx: 加 2 个 1080P 单价输入框(独立分组)
- pages/AdminAssetsPage.tsx / TeamAssetsPage.tsx: 去 || '720p' 兜底
- lib/api.ts: videoApi.generate 参数 resolution 必填
## 测试(47 个用例)
### 后端(28 个)
- tests/test_1080p_billing.py(23): RESOLUTION_MAP 像素、estimate_tokens
公式(含/不含输入视频、不做最低 token 修正)、_get_token_price 六种
组合、Fast+1080P 抛异常、calculate_cost 对齐官方示例 4.97 / 12.39 元
- tests/test_1080p_api.py(5): video_generate_view 拒绝 Fast+1080P (400)
+ 拒绝 adaptive + 拒绝非法 resolution + 默认值兼容 + 合法组合通过
### 前端(19 个)
- test/unit/resolution1080p.test.ts(14): store 状态、双向拦截
(1080P 下切 Fast 被阻止 model 不变、反向同样)、官方像素契约测试、
价格示例对齐(720P 4.97 / 1080P 12.39)
- test/e2e/resolution-1080p.spec.ts(5): 真实浏览器验证默认 720P、
Dropdown 双向置灰、tooltip 明示以火山为准
## 与官方文档对齐
- 参数:resolution (480p/720p/1080p 小写)、ratio、duration、generate_audio
- 像素:来自 docs/API文档/创建视频生成任务API.md Seedance 2.0 & 2.0 fast 列
- 单价:来自 docs/API文档/seedance模型价格.md (46/28/51/31/37/22)
- Fast 不支持 1080P:来自 docs/API文档/Seedance 2.0 1080P.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
519 lines
23 KiB
TypeScript
519 lines
23 KiB
TypeScript
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,
|
||
base_token_price_1080p: 0,
|
||
base_token_price_1080p_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(480P / 720P)</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(1080P)</p>
|
||
<div className={styles.formRow}>
|
||
<div className={styles.formGroup}>
|
||
<label>不含视频输入单价 (元/百万tokens)</label>
|
||
<input
|
||
type="number"
|
||
step="0.01"
|
||
value={settings.base_token_price_1080p}
|
||
onChange={(e) => setSettings({ ...settings, base_token_price_1080p: Number(e.target.value) })}
|
||
/>
|
||
</div>
|
||
<div className={styles.formGroup}>
|
||
<label>含视频输入单价 (元/百万tokens)</label>
|
||
<input
|
||
type="number"
|
||
step="0.01"
|
||
value={settings.base_token_price_1080p_video}
|
||
onChange={(e) => setSettings({ ...settings, base_token_price_1080p_video: Number(e.target.value) })}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<p className={styles.cardDesc}>Seedance 2.0 Fast(不支持 1080P)</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>
|
||
);
|
||
}
|