video-shuoshan/web/src/pages/SettingsPage.tsx
seaislee1209 bd3e80fd58 feat(notification): 消息中心改 accordion 模式 + 跳转按钮 + 公告颜色 CSS var 自适应
用户反馈三点:
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>
2026-05-15 16:24:49 +08:00

520 lines
24 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, 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.0480P / 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.01080P</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>
);
}