feat: polish console shell and notification settings

This commit is contained in:
boos
2026-06-06 19:56:56 +08:00
parent cd843465b2
commit 5ecea182bc
10 changed files with 790 additions and 78 deletions
@@ -38,6 +38,8 @@ describe('NotificationSettings', () => {
barkUrl: '',
webhookEnabled: true,
barkEnabled: true,
feishuEnabled: false,
feishuWebhookMasked: '',
serverChanEnabled: false,
telegramEnabled: true,
telegramApiBaseUrl: 'https://tg-proxy.example.com',
@@ -123,6 +125,8 @@ describe('NotificationSettings', () => {
barkUrl: '',
webhookEnabled: true,
barkEnabled: true,
feishuEnabled: false,
feishuWebhookMasked: '',
serverChanEnabled: false,
telegramEnabled: true,
telegramApiBaseUrl: 'https://api.telegram.org',
+57 -8
View File
@@ -8,6 +8,8 @@ type RuntimeSettings = {
barkUrl: string;
webhookEnabled: boolean;
barkEnabled: boolean;
feishuEnabled: boolean;
feishuWebhookMasked?: string;
serverChanEnabled: boolean;
telegramEnabled: boolean;
telegramApiBaseUrl: string;
@@ -33,6 +35,7 @@ export default function NotificationSettings() {
barkUrl: '',
webhookEnabled: true,
barkEnabled: true,
feishuEnabled: false,
serverChanEnabled: false,
telegramEnabled: false,
telegramApiBaseUrl: 'https://api.telegram.org',
@@ -50,6 +53,7 @@ export default function NotificationSettings() {
});
const [serverChanKey, setServerChanKey] = useState('');
const [feishuWebhook, setFeishuWebhook] = useState('');
const [telegramBotToken, setTelegramBotToken] = useState('');
const [smtpPass, setSmtpPass] = useState('');
const [loading, setLoading] = useState(true);
@@ -78,6 +82,8 @@ export default function NotificationSettings() {
barkUrl: runtimeInfo.barkUrl || '',
webhookEnabled: runtimeInfo.webhookEnabled ?? true,
barkEnabled: runtimeInfo.barkEnabled ?? true,
feishuEnabled: !!runtimeInfo.feishuEnabled,
feishuWebhookMasked: runtimeInfo.feishuWebhookMasked || '',
serverChanEnabled: !!runtimeInfo.serverChanEnabled,
telegramEnabled: !!runtimeInfo.telegramEnabled,
telegramApiBaseUrl: runtimeInfo.telegramApiBaseUrl || 'https://api.telegram.org',
@@ -117,6 +123,7 @@ export default function NotificationSettings() {
barkUrl: runtime.barkUrl,
webhookEnabled: runtime.webhookEnabled,
barkEnabled: runtime.barkEnabled,
feishuEnabled: runtime.feishuEnabled,
serverChanEnabled: runtime.serverChanEnabled,
telegramEnabled: runtime.telegramEnabled,
telegramApiBaseUrl: runtime.telegramApiBaseUrl,
@@ -132,6 +139,7 @@ export default function NotificationSettings() {
smtpTo: runtime.smtpTo,
notifyCooldownSec: Math.max(0, Math.trunc(Number(runtime.notifyCooldownSec) || 0)),
};
if (feishuWebhook.trim()) payload.feishuWebhook = feishuWebhook.trim();
if (serverChanKey.trim()) payload.serverChanKey = serverChanKey.trim();
if (telegramBotToken.trim()) payload.telegramBotToken = telegramBotToken.trim();
if (smtpPass.trim()) payload.smtpPass = smtpPass.trim();
@@ -140,9 +148,11 @@ export default function NotificationSettings() {
setRuntime((prev) => ({
...prev,
serverChanKeyMasked: res.serverChanKeyMasked || prev.serverChanKeyMasked,
feishuWebhookMasked: res.feishuWebhookMasked || prev.feishuWebhookMasked,
telegramBotTokenMasked: res.telegramBotTokenMasked || prev.telegramBotTokenMasked,
smtpPassMasked: res.smtpPassMasked || prev.smtpPassMasked,
}));
setFeishuWebhook('');
setServerChanKey('');
setTelegramBotToken('');
setSmtpPass('');
@@ -176,7 +186,7 @@ export default function NotificationSettings() {
}
return (
<div className="animate-fade-in" style={{ paddingBottom: 40 }}>
<div className="animate-fade-in notification-settings-page" style={{ paddingBottom: 40 }}>
{/* 头部标题与操作 */}
<div className="page-header">
<h2 className="page-title">{tr('通知设置')}</h2>
@@ -190,7 +200,7 @@ export default function NotificationSettings() {
</div>
</div>
<div style={{ maxWidth: 860, display: 'flex', flexDirection: 'column', gap: 20 }}>
<div className="notification-settings-layout" style={{ maxWidth: 980, display: 'flex', flexDirection: 'column', gap: 20 }}>
<div className="card animate-slide-up stagger-1" style={{ padding: 20 }}>
<div style={{ fontWeight: 600, fontSize: 15, marginBottom: 8 }}>告警去噪与冷静期</div>
@@ -223,7 +233,7 @@ export default function NotificationSettings() {
</div>
<div>
<div style={{ fontWeight: 600, fontSize: 15 }}>Webhook & Bark</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>通过 HTTP URL 推送消息通知(自动识别企业微信、飞书格式)</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>通过 HTTP URL 或 Bark 推送消息通知</div>
</div>
</div>
@@ -273,8 +283,47 @@ export default function NotificationSettings() {
</div>
</div>
{/* 卡片:飞书 */}
<div className="card notification-channel-card animate-slide-up stagger-3" style={{ padding: 24, border: runtime.feishuEnabled ? '1px solid var(--color-primary)' : '1px solid var(--color-border-light)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'color-mix(in srgb, var(--color-info) 14%, var(--color-bg-card))', color: 'var(--color-info)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 5h14v10H8l-3 3V5zM9 9h6M9 12h4" /></svg>
</div>
<div>
<div style={{ fontWeight: 600, fontSize: 15 }}>飞书机器人</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>管理员告警、任务状态和系统事件可发送到飞书群</div>
</div>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<span style={{ fontSize: 13, fontWeight: 500, color: runtime.feishuEnabled ? 'var(--color-primary)' : 'var(--color-text-muted)' }}>启用飞书</span>
<input
type="checkbox"
style={{ width: 16, height: 16, cursor: 'pointer' }}
checked={runtime.feishuEnabled}
onChange={(e) => setRuntime((prev) => ({ ...prev, feishuEnabled: e.target.checked }))}
/>
</label>
</div>
<div style={{ opacity: runtime.feishuEnabled ? 1 : 0.6, transition: 'opacity 0.2s' }}>
<code style={{ display: 'block', padding: '10px 14px', background: 'var(--color-bg)', borderRadius: 'var(--radius-sm)', fontSize: 13, fontFamily: 'var(--font-mono)', color: 'var(--color-text-secondary)', border: '1px solid var(--color-border-light)', marginBottom: 10 }}>
当前配置: {runtime.feishuWebhookMasked || '未设置'}
</code>
<input
type="password"
value={feishuWebhook}
onChange={(e) => setFeishuWebhook(e.target.value)}
placeholder="输入新的飞书机器人 Webhook(留空则不改)"
style={inputStyle}
disabled={!runtime.feishuEnabled}
/>
</div>
</div>
{/* 卡片:Server酱 */}
<div className="card animate-slide-up stagger-3" style={{ padding: 24, border: runtime.serverChanEnabled ? '1px solid var(--color-primary)' : '1px solid var(--color-border-light)' }}>
<div className="card notification-channel-card animate-slide-up stagger-4" style={{ padding: 24, border: runtime.serverChanEnabled ? '1px solid var(--color-primary)' : '1px solid var(--color-border-light)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'var(--color-warning-soft)', color: 'var(--color-warning)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
@@ -313,7 +362,7 @@ export default function NotificationSettings() {
</div>
{/* 卡片:Telegram */}
<div className="card animate-slide-up stagger-4" style={{ padding: 24, border: runtime.telegramEnabled ? '1px solid var(--color-primary)' : '1px solid var(--color-border-light)' }}>
<div className="card notification-channel-card animate-slide-up stagger-4" style={{ padding: 24, border: runtime.telegramEnabled ? '1px solid var(--color-primary)' : '1px solid var(--color-border-light)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'var(--color-primary-light)', color: 'var(--color-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
@@ -399,15 +448,15 @@ export default function NotificationSettings() {
</div>
{/* 卡片:SMTP 邮件设置 */}
<div className="card animate-slide-up stagger-4" style={{ padding: 24, border: runtime.smtpEnabled ? '1px solid var(--color-primary)' : '1px solid var(--color-border-light)' }}>
<div className="card notification-channel-card animate-slide-up stagger-4" style={{ padding: 24, border: runtime.smtpEnabled ? '1px solid var(--color-primary)' : '1px solid var(--color-border-light)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'var(--color-primary-light)', color: 'var(--color-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
</div>
<div>
<div style={{ fontWeight: 600, fontSize: 15 }}>邮件服务 (SMTP)</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>通过电子邮件推送提醒</div>
<div style={{ fontWeight: 600, fontSize: 15 }}>内置邮件服务 (SMTP)</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>用于邮件通知,后续注册邮箱验证码会复用这里的发信配置</div>
</div>
</div>
+48 -26
View File
@@ -179,6 +179,27 @@ function statusLabel(status: UserStatus): string {
return status === 'active' ? '正常' : '已禁用';
}
function hashString(input: string): number {
let hash = 0;
for (let i = 0; i < input.length; i += 1) {
hash = (hash << 5) - hash + input.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash);
}
function userAvatarStyle(user: Pick<User, 'id' | 'username' | 'email'>): React.CSSProperties {
const seed = `${user.id}:${user.username}:${user.email}`;
const hue = hashString(seed) % 360;
const secondHue = (hue + 42 + (hashString(user.email || seed) % 80)) % 360;
return {
background:
`radial-gradient(circle at 30% 24%, hsl(${secondHue} 86% 90%) 0 18%, transparent 19%), ` +
`linear-gradient(135deg, hsl(${hue} 72% 48%), hsl(${secondHue} 68% 46%))`,
color: '#fff',
};
}
export default function UserManagement() {
const [users, setUsers] = useState<User[]>([]);
const [stats, setStats] = useState<UserStats>({ total: 0, active: 0, disabled: 0, admins: 0 });
@@ -464,10 +485,10 @@ export default function UserManagement() {
};
const statCards = [
{ label: '总用户', value: stats.total, tone: 'primary' },
{ label: '正常用户', value: stats.active, tone: 'success' },
{ label: '管理员', value: stats.admins, tone: 'info' },
{ label: '已禁用', value: stats.disabled, tone: 'danger' },
{ label: '总用户', value: stats.total, tone: 'primary', desc: '平台账号总量' },
{ label: '正常用户', value: stats.active, tone: 'success', desc: '可登录并调用 API' },
{ label: '管理员', value: stats.admins, tone: 'info', desc: '具备后台权限' },
{ label: '已禁用', value: stats.disabled, tone: 'danger', desc: '保留数据但禁止访问' },
];
return (
@@ -509,7 +530,10 @@ export default function UserManagement() {
<div className="user-management-stats">
{statCards.map((stat) => (
<div className={`user-stat-card user-stat-${stat.tone}`} key={stat.label}>
<div className="user-stat-label">{stat.label}</div>
<div>
<div className="user-stat-label">{stat.label}</div>
<div className="user-stat-desc">{stat.desc}</div>
</div>
<div className="user-stat-value">{loading ? '-' : stat.value}</div>
</div>
))}
@@ -608,7 +632,7 @@ export default function UserManagement() {
</td>
<td>
<div className="user-identity-cell">
<div className="user-avatar">{user.username.slice(0, 1).toUpperCase() || 'U'}</div>
<div className="user-avatar" style={userAvatarStyle(user)}>{user.username.slice(0, 1).toUpperCase() || 'U'}</div>
<div className="user-identity-text">
<div className="user-name-line">
<span className="user-name">{user.username}</span>
@@ -653,27 +677,25 @@ export default function UserManagement() {
{expandedUserId === user.id && (
<tr className="usage-detail-row">
<td colSpan={9}>
<div className="usage-detail-container animate-fade-in" style={{ padding: '16px 20px', background: 'var(--color-bg-alt)', borderBottom: '1px solid var(--color-border)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<div style={{ display: 'flex', gap: 20 }}>
<div className="usage-detail-container animate-fade-in">
<div className="usage-detail-header">
<div className="usage-detail-tabs">
<button
type="button"
className={`btn-link ${expandedTab === 'usage' ? 'btn-link-primary' : ''}`}
style={{ fontWeight: expandedTab === 'usage' ? 600 : 400, padding: 0, fontSize: 14 }}
className={`usage-detail-tab ${expandedTab === 'usage' ? 'active' : ''}`}
onClick={(e) => { e.stopPropagation(); handleExpandedTabChange('usage', user.id); }}
>
用量详情
</button>
<button
type="button"
className={`btn-link ${expandedTab === 'keys' ? 'btn-link-primary' : ''}`}
style={{ fontWeight: expandedTab === 'keys' ? 600 : 400, padding: 0, fontSize: 14 }}
className={`usage-detail-tab ${expandedTab === 'keys' ? 'active' : ''}`}
onClick={(e) => { e.stopPropagation(); handleExpandedTabChange('keys', user.id); }}
>
API 密钥管理
</button>
</div>
<div style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>用户: {user.username} (ID: {user.id})</div>
<div className="usage-detail-user">用户: {user.username} (ID: {user.id})</div>
</div>
{expandedTab === 'usage' ? (
@@ -681,18 +703,18 @@ export default function UserManagement() {
<div style={{ padding: '20px 0', textAlign: 'center', color: 'var(--color-text-muted)' }}>加载中...</div>
) : usageData ? (
<div className="usage-detail-content">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16, marginBottom: 20 }}>
<div style={{ padding: '12px 16px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 8 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>总请求数</div>
<div style={{ fontSize: 20, fontWeight: 600 }}>{usageData.totalRequests.toLocaleString()}</div>
<div className="usage-metric-grid">
<div className="usage-metric-card">
<div>总请求数</div>
<strong>{usageData.totalRequests.toLocaleString()}</strong>
</div>
<div style={{ padding: '12px 16px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 8 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>总消耗</div>
<div style={{ fontSize: 20, fontWeight: 600, color: 'var(--color-primary)' }}>${usageData.totalCost.toFixed(4)}</div>
<div className="usage-metric-card">
<div>总消耗</div>
<strong className="accent">${usageData.totalCost.toFixed(4)}</strong>
</div>
<div style={{ padding: '12px 16px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 8 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>近7天消耗</div>
<div style={{ fontSize: 20, fontWeight: 600 }}>${usageData.last7DaysCost.toFixed(4)}</div>
<div className="usage-metric-card">
<div>近7天消耗</div>
<strong>${usageData.last7DaysCost.toFixed(4)}</strong>
</div>
</div>
@@ -701,7 +723,7 @@ export default function UserManagement() {
{usageData.trend.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无趋势数据</div>
) : (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 2, height: 100, borderBottom: '1px solid var(--color-border)', paddingBottom: 4 }}>
<div className="usage-trend-bars">
{usageData.trend.map((t: any) => (
<div
key={t.day}
@@ -710,7 +732,7 @@ export default function UserManagement() {
flex: 1,
background: 'var(--color-primary)',
height: `${Math.max(4, (t.cost / (Math.max(...usageData.trend.map((x: any) => x.cost)) || 1)) * 100)}%`,
borderRadius: '2px 2px 0 0',
borderRadius: '4px 4px 0 0',
minWidth: 4
}}
/>