feat: polish console shell and notification settings
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user