用户反馈三点: 1. 公告里"红字"/"蓝字"工具按钮硬编码颜色 #ff4d4f / #00b8e6 在浅色下糊 2. 消息中心列表里公告直接渲染完整 HTML,行被撑得很大 3. 点行就自动 navigate(link_url),用户希望"只看不跳",看完主动决定要不要跳转 改动: a) SettingsPage 公告编辑器颜色按钮(2 个): - 红字: <span style="color:#ff4d4f"> → var(--color-danger) - 蓝字: <span style="color:#00b8e6"> → var(--color-primary) - 分割线 border-top 颜色 #333 → var(--color-border-card) - 三个都改成 CSS var,自动适配浅/深主题 - title 文案加"(自适应主题)"提示超管 b) NotificationsPage accordion 模式: - 加 expandedId: number | null state,始终最多 1 条展开 - 折叠态:chip + title + 时间 + 一行剥 HTML 后的纯文本预览(stripAndTruncate, 60 字 '…') - 展开态:头部 + 下方完整内容(announcement 用 DOMPurify+HTML / 其他 plain) + link_url 非空时显示【前往查看】按钮(蓝底白字,带箭头 icon) - chevron icon 旋转 0deg/180deg 视觉指示折叠/展开 - 同 id 再点 → 收起;不同 id → 切换(前一个自动收起) - 切页时自动重置 expandedId 为 null c) 点击行为: - handleRowClick(自动跳) → handleToggle(只 markRead + 切 expandedId) - 新加 handleJump(url):用户主动点【前往查看】才触发 navigate / window.open(http url) - 展开区域 onClick 加 stopPropagation 防误触收起 d) smoke test 更新: - 测试公告内容做长用 EXPANDED-ONLY-MARKER 末尾标记,preview 截断后看不到 - 7.2.0 折叠态 preview 截断验证(marker 不可见) - 7.2 展开后 marker 可见 - 7.3 再点收起 marker 不再可见 - 用 chip [公告] 文字作为稳定点击锚点(只在头部出现不在展开内容里) 验证: - typecheck 0 error - announcement-integration-smoke 13/13(从 10 项扩到 13,加 accordion 路径) - v0.20.1-smoke 11/11 + v2-smoke 25/25 + modal-interaction 8/8 全过 - vitest 71 fail / 162 pass 与基线一致 GlobalAnnouncementGate 强弹 modal 行为不变(plan §一 7 — 公告强制阅读语义保留)。 重看路径走 sidebar 大铃铛 → 消息中心 → accordion 展开看全文 → 可点【前往查看】跳。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
520 lines
24 KiB
TypeScript
520 lines
24 KiB
TypeScript
import { useEffect, useState, useCallback, useRef } from 'react';
|
||
import { adminApi, announcementApi } 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 handlePublishAnnouncement = async () => {
|
||
const content = (settings.announcement || '').trim();
|
||
if (!content) {
|
||
showToast('公告内容不能为空');
|
||
return;
|
||
}
|
||
if (!window.confirm('确认发送给所有用户?发送后所有人打开页面会强制看到这条公告,无法撤回。')) {
|
||
return;
|
||
}
|
||
setSaving(true);
|
||
try {
|
||
const { data } = await announcementApi.publish(content);
|
||
showToast(data.message || `已发送给 ${data.sent_to} 个用户`);
|
||
} catch (e: any) {
|
||
showToast(e?.response?.data?.error || '发送失败');
|
||
} 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>
|
||
</div>
|
||
<div className={styles.formGroup}>
|
||
<label>公告内容(支持 HTML 格式)</label>
|
||
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
|
||
{[
|
||
{ label: 'B', tag: 'b', title: '加粗' },
|
||
// 用 CSS var 而非硬编码颜色 — 浅色 / 深色主题自动适配,避免浅色下糊
|
||
{ label: '红字', wrap: ['<span style="color:var(--color-danger)">', '</span>'], title: '红色文字(自适应主题)' },
|
||
{ label: '蓝字', wrap: ['<span style="color:var(--color-primary)">', '</span>'], title: '蓝色文字(自适应主题)' },
|
||
{ label: 'H3', wrap: ['<h3 style="margin:8px 0 4px">', '</h3>'], title: '标题' },
|
||
{ label: '分割线', insert: '<hr style="border:none;border-top:1px solid var(--color-border-card);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: 'var(--color-bg-card)',
|
||
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 ? 'var(--color-purple-bg)' : 'var(--color-bg-card)',
|
||
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={handlePublishAnnouncement} 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>
|
||
);
|
||
}
|