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
+75 -11
View File
@@ -59,6 +59,7 @@ type UserProfile = {
name: string;
avatarSeed: string;
avatarStyle: string;
avatarUrl?: string;
};
type UserAnnouncementPopupRow = {
id: number;
@@ -121,6 +122,21 @@ function buildLocalAvatarStyle(style: string, seed: string): React.CSSProperties
};
}
function normalizeAvatarUrl(value: unknown): string {
if (typeof value !== 'string') return '';
const trimmed = value.trim();
if (!trimmed) return '';
if (
trimmed.startsWith('https://')
|| trimmed.startsWith('http://')
|| trimmed.startsWith('/')
|| trimmed.startsWith('data:image/')
) {
return trimmed;
}
return '';
}
function defaultProfile(name: string): UserProfile {
const avatarSeed = createRandomAvatarSeed();
return { name, avatarSeed, avatarStyle: pickAvatarStyle(avatarSeed) };
@@ -144,6 +160,7 @@ function parseStoredProfile(raw: string | null, fallbackName: string): UserProfi
avatarStyle: AVATAR_STYLES.includes(avatarStyle as AvatarStyle)
? avatarStyle
: pickAvatarStyle(resolvedSeed),
avatarUrl: normalizeAvatarUrl((parsed as any)?.avatarUrl),
};
} catch {
return null;
@@ -183,6 +200,7 @@ function profileFromUsername(username: string, current?: UserProfile, fallbackNa
avatarStyle: AVATAR_STYLES.includes(avatarStyle as AvatarStyle)
? avatarStyle
: pickAvatarStyle(avatarSeed),
avatarUrl: current?.avatarUrl,
};
}
@@ -197,14 +215,21 @@ function LocalAvatar({
className: string;
style?: React.CSSProperties;
}) {
const [imageBroken, setImageBroken] = useState(false);
useEffect(() => {
setImageBroken(false);
}, [profile.avatarUrl]);
const initial = (label || profile.name || 'B').trim().slice(0, 1).toUpperCase() || 'B';
const avatarUrl = normalizeAvatarUrl(profile.avatarUrl);
return (
<span
className={className}
aria-hidden="true"
style={{ ...buildLocalAvatarStyle(profile.avatarStyle, profile.avatarSeed), ...style }}
>
{initial}
{avatarUrl && !imageBroken ? (
<img src={avatarUrl} alt="" onError={() => setImageBroken(true)} />
) : initial}
</span>
);
}
@@ -225,12 +250,14 @@ function UserProfileModal({
const [name, setName] = useState(profile.name);
const [avatarSeed, setAvatarSeed] = useState(profile.avatarSeed);
const [avatarStyle, setAvatarStyle] = useState(profile.avatarStyle);
const [avatarUrl, setAvatarUrl] = useState(profile.avatarUrl || '');
const [error, setError] = useState('');
useEffect(() => {
if (!open) return;
setName(profile.name);
setAvatarSeed(profile.avatarSeed);
setAvatarStyle(profile.avatarStyle);
setAvatarUrl(profile.avatarUrl || '');
setError('');
}, [open, profile]);
const inputStyle: React.CSSProperties = {
@@ -247,6 +274,7 @@ function UserProfileModal({
const nextSeed = createRandomAvatarSeed();
setAvatarSeed(nextSeed);
setAvatarStyle(pickAvatarStyle(nextSeed));
setAvatarUrl('');
};
const handleSubmit = () => {
const normalizedName = name.trim();
@@ -258,18 +286,25 @@ function UserProfileModal({
setError(t('用户名最多 24 个字符'));
return;
}
const normalizedAvatarUrl = normalizeAvatarUrl(avatarUrl);
if (avatarUrl.trim() && !normalizedAvatarUrl) {
setError(t('头像地址需要是 http(s)、站内路径或 data:image'));
return;
}
onSave({
name: normalizedName,
avatarSeed: avatarSeed.trim() || createRandomAvatarSeed(),
avatarStyle: AVATAR_STYLES.includes(avatarStyle as AvatarStyle)
? avatarStyle
: pickAvatarStyle(avatarSeed),
avatarUrl: normalizedAvatarUrl || undefined,
});
};
const previewProfile: UserProfile = {
name: name.trim() || profile.name,
avatarSeed,
avatarStyle,
avatarUrl: normalizeAvatarUrl(avatarUrl) || undefined,
};
return (
<CenteredModal
@@ -308,13 +343,35 @@ function UserProfileModal({
style={inputStyle}
/>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>{t('自定义头像地址')}</div>
<input
value={avatarUrl}
onChange={(e) => {
setAvatarUrl(e.target.value);
setError('');
}}
placeholder={t('https://... 或 /avatar.png')}
style={inputStyle}
/>
<div style={{ marginTop: 6, fontSize: 11.5, color: 'var(--color-text-muted)' }}>
{t('留空时使用本地生成头像,不会上传文件。')}
</div>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>
{t('头像 · 风格:')}{resolveAvatarStyle(avatarStyle, avatarSeed)}
</div>
<button type="button" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={handleRandomAvatar}>
{t('换一个随机头像')}
</button>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<button type="button" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={handleRandomAvatar}>
{t('换一个随机头像')}
</button>
{avatarUrl.trim() && (
<button type="button" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={() => setAvatarUrl('')}>
{t('清除图片头像')}
</button>
)}
</div>
</div>
{error && (
<div className="alert alert-error">
@@ -465,11 +522,7 @@ export const userSidebarGroups = [
const topNavItems = [
{ label: '控制台', to: '/dashboard' },
{ label: '模型广场', to: '/models' },
{ label: '生图助手', to: '/picture-agent' },
{ label: '短视频工厂', to: '/money-printer' },
{ label: '工作流', to: '/workflows' },
{ label: '视频助手', to: '/comfyui-agent' },
{ label: '工作空间', to: '/workspace' },
{ label: '关于', to: '/about' },
];
function PageTransition({ children }: { children: React.ReactNode }) {
@@ -742,6 +795,7 @@ function AppShell() {
avatarStyle: AVATAR_STYLES.includes(nextProfile.avatarStyle as AvatarStyle)
? nextProfile.avatarStyle
: pickAvatarStyle(normalizedSeed),
avatarUrl: normalizeAvatarUrl(nextProfile.avatarUrl) || undefined,
};
setUserProfile(normalized);
localStorage.setItem(profileStorageKey(profileAudience), JSON.stringify(normalized));
@@ -825,10 +879,7 @@ function AppShell() {
const currentTopNavItems = isUserPage ? [
{ label: '个人中心', to: '/user/dashboard' },
{ label: '模型广场', to: '/user/models' },
{ label: '用户公告', to: '/user/announcements' },
{ label: '工作流', to: '/user/workflows' },
{ label: '视频助手', to: '/user/comfyui-agent' },
{ label: '工作空间', to: '/user/workspace' },
{ label: '关于', to: '/user/about' },
] : topNavItems;
return (
@@ -1045,6 +1096,19 @@ function AppShell() {
</MobileDrawer>
) : (
<aside className={`sidebar ${sidebarCollapsed ? 'collapsed' : ''}`}>
{!sidebarCollapsed && (
<div className="sidebar-context-card">
<LocalAvatar
className="sidebar-context-avatar"
profile={userProfile}
label={displayName}
/>
<div className="sidebar-context-copy">
<span>{isUserPage ? t('用户工作台') : t('管理控制台')}</span>
<strong>{displayName}</strong>
</div>
</div>
)}
{currentSidebarGroups.map((group) => (
<div key={group.label} className="sidebar-group">
{!sidebarCollapsed && <div className="sidebar-group-label">{t(group.label)}</div>}
+2
View File
@@ -695,6 +695,8 @@ export type RuntimeSettingsPayload = {
barkUrl?: string;
webhookEnabled?: boolean;
barkEnabled?: boolean;
feishuEnabled?: boolean;
feishuWebhook?: string;
serverChanEnabled?: boolean;
serverChanKey?: string;
telegramEnabled?: boolean;
+289 -9
View File
@@ -6881,17 +6881,38 @@ textarea {
min-height: 82px;
border-radius: var(--radius-md);
border: 1px solid var(--color-border-light);
background: var(--color-bg-card);
background:
linear-gradient(180deg, color-mix(in srgb, var(--color-bg-card) 94%, transparent), color-mix(in srgb, var(--color-bg-card) 78%, var(--color-bg)));
box-shadow: var(--shadow-card);
padding: 14px 16px;
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
position: relative;
overflow: hidden;
}
.user-stat-card::before {
position: absolute;
inset: 0 auto 0 0;
width: 3px;
content: "";
background: var(--color-primary);
opacity: 0.8;
}
.user-stat-label {
font-size: 12px;
color: var(--color-text-muted);
font-weight: 720;
}
.user-stat-desc {
margin-top: 5px;
color: var(--color-text-muted);
font-size: 11.5px;
line-height: 1.35;
}
.user-stat-value {
@@ -6909,17 +6930,30 @@ textarea {
color: var(--color-success);
}
.user-stat-success::before {
background: var(--color-success);
}
.user-stat-info .user-stat-value {
color: var(--color-info);
}
.user-stat-info::before {
background: var(--color-info);
}
.user-stat-danger .user-stat-value {
color: var(--color-danger);
}
.user-stat-danger::before {
background: var(--color-danger);
}
.user-management-toolbar-card {
padding: 14px;
padding: 16px;
margin-bottom: 14px;
border-radius: 16px;
}
.user-management-toolbar {
@@ -6955,12 +6989,16 @@ textarea {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--color-border-light);
background: color-mix(in srgb, var(--color-primary) 5%, transparent);
border-radius: 12px;
padding: 12px;
font-size: 13px;
color: var(--color-text-secondary);
}
.user-management-table-card {
overflow: hidden;
border-radius: 16px;
}
.user-management-table-wrap {
@@ -6996,17 +7034,16 @@ textarea {
}
.user-avatar {
width: 32px;
height: 32px;
border-radius: 8px;
width: 36px;
height: 36px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
background: color-mix(in srgb, var(--color-primary) 14%, var(--color-bg-card));
color: var(--color-primary);
font-size: 13px;
font-weight: 700;
font-weight: 820;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.36), 0 8px 16px rgba(20, 38, 68, 0.12);
}
.user-identity-text {
@@ -7042,6 +7079,94 @@ textarea {
max-width: 280px;
}
.usage-detail-container {
padding: 18px 20px;
border-block: 1px solid var(--color-border-light);
background:
linear-gradient(180deg, color-mix(in srgb, var(--color-primary) 4%, var(--color-bg-card)), color-mix(in srgb, var(--color-bg) 82%, var(--color-bg-card)));
}
.usage-detail-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.usage-detail-tabs {
display: inline-flex;
gap: 6px;
padding: 4px;
border: 1px solid var(--color-border-light);
border-radius: 999px;
background: color-mix(in srgb, var(--color-bg-card) 76%, var(--color-bg));
}
.usage-detail-tab {
border: 0;
border-radius: 999px;
padding: 7px 12px;
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
font-size: 13px;
font-weight: 720;
}
.usage-detail-tab.active {
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 10%, var(--color-bg-card));
}
.usage-detail-user {
color: var(--color-text-muted);
font-size: 12.5px;
}
.usage-metric-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.usage-metric-card {
padding: 13px 15px;
border: 1px solid var(--color-border-light);
border-radius: 12px;
background: color-mix(in srgb, var(--color-bg-card) 86%, var(--color-bg));
}
.usage-metric-card div {
color: var(--color-text-muted);
font-size: 12px;
}
.usage-metric-card strong {
display: block;
margin-top: 6px;
color: var(--color-text-primary);
font-size: 20px;
line-height: 1;
}
.usage-metric-card strong.accent {
color: var(--color-primary);
}
.usage-trend-bars {
height: 104px;
display: flex;
align-items: flex-end;
gap: 3px;
padding: 8px 4px 4px;
border: 1px solid var(--color-border-light);
border-radius: 12px;
background: color-mix(in srgb, var(--color-bg-card) 80%, var(--color-bg));
}
.user-management-pagination {
display: flex;
justify-content: space-between;
@@ -12429,9 +12554,164 @@ textarea:focus {
background: color-mix(in srgb, var(--color-primary) 10%, var(--color-bg-card));
}
.topbar {
border-bottom-color: color-mix(in srgb, var(--console-line) 86%, transparent);
background:
linear-gradient(180deg, color-mix(in srgb, var(--console-panel-solid) 94%, transparent), color-mix(in srgb, var(--console-panel-solid) 80%, transparent));
}
.topbar-logo {
min-width: 188px;
padding-right: 14px;
border-right: 1px solid var(--console-line);
}
.topbar-nav {
max-width: 430px;
padding: 3px;
border-radius: 999px;
background: color-mix(in srgb, var(--color-bg) 68%, transparent);
border-color: color-mix(in srgb, var(--console-line) 74%, transparent);
}
.topbar-nav a,
.topbar-nav-item {
min-height: 30px;
padding: 7px 12px;
color: color-mix(in srgb, var(--color-text-secondary) 88%, var(--console-ink));
font-size: 12px;
}
.topbar-nav a.active,
.topbar-nav-item.active {
color: var(--color-primary);
background: var(--color-bg-card);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 18%, var(--console-line)), 0 7px 16px rgba(20, 38, 68, 0.07);
}
.topbar-right {
min-width: 0;
}
.topbar-user-trigger {
min-width: 166px;
background:
linear-gradient(180deg, color-mix(in srgb, var(--color-bg-card) 92%, transparent), color-mix(in srgb, var(--color-bg-card) 72%, var(--color-bg)));
}
.topbar-user-avatar-inner img,
.user-dropdown-avatar-inner img,
.sidebar-context-avatar img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
}
.user-dropdown-profile {
padding: 8px;
border-radius: 16px;
}
.user-dropdown-header {
border: 1px solid var(--console-line);
border-radius: 13px;
background: color-mix(in srgb, var(--color-bg-card) 72%, var(--color-bg));
}
.sidebar {
padding-top: 12px;
background:
linear-gradient(180deg, color-mix(in srgb, var(--color-bg-sidebar) 76%, var(--console-panel-solid)), color-mix(in srgb, var(--color-bg-sidebar) 88%, var(--color-bg)));
}
.sidebar-context-card {
display: grid;
grid-template-columns: 34px minmax(0, 1fr);
gap: 10px;
align-items: center;
margin: 2px 0 12px;
padding: 10px;
border: 1px solid var(--console-line);
border-radius: 16px;
background:
linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-card)), color-mix(in srgb, var(--color-bg-card) 72%, transparent));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.52);
}
[data-theme="dark"] .sidebar-context-card {
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.sidebar-context-avatar {
width: 34px;
height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 12px;
color: #fff;
font-size: 13px;
font-weight: 820;
text-transform: uppercase;
}
.sidebar-context-copy {
min-width: 0;
display: grid;
gap: 2px;
}
.sidebar-context-copy span,
.sidebar-context-copy strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-context-copy span {
color: var(--color-text-muted);
font-size: 11px;
font-weight: 720;
}
.sidebar-context-copy strong {
color: var(--color-text-primary);
font-size: 13px;
font-weight: 820;
}
.sidebar-group {
padding-top: 4px;
}
.sidebar-item {
border-radius: 10px;
box-shadow: none;
}
.sidebar-item.active {
box-shadow: inset 4px 0 0 color-mix(in srgb, var(--color-primary) 80%, transparent), 0 8px 18px rgba(20, 38, 68, 0.06);
}
.notification-settings-page .page-header {
align-items: flex-start;
}
.notification-channel-card {
border-radius: 18px;
}
.notification-channel-card code {
overflow-wrap: anywhere;
}
@media (max-width: 1180px) {
.topbar-logo {
min-width: auto;
border-right: 0;
}
.topbar-logo-subtitle {
@@ -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
}}
/>