All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m5s
①公告改为弹窗(用户未读自动弹出,已读不再弹) ②生成页右上角小铃铛按钮可重新查看公告 ③公告支持 HTML 渲染(加粗/红字/蓝字/标题/分割线/列表) ④超管公告编辑器加格式工具栏 + 预览按钮 ⑤去掉旧的公告横幅 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
460 lines
21 KiB
TypeScript
460 lines
21 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,
|
||
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>
|
||
);
|
||
}
|