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
@@ -1,3 +1,3 @@
ALTER TABLE proxy_logs ADD COLUMN content_summary TEXT;
ALTER TABLE proxy_logs ADD COLUMN request_content TEXT;
ALTER TABLE proxy_logs ADD COLUMN response_content TEXT;
-- No-op migration.
-- The proxy log content columns are present in the baseline schema and are
-- also protected by src/db/schema_compat.rs for older SQLite databases.
+3
View File
@@ -42,6 +42,9 @@ pub async fn ensure_sqlite_upstream_compat(pool: &SqlitePool) -> anyhow::Result<
("client_confidence", "TEXT"),
("is_stream", "INTEGER"),
("first_byte_latency_ms", "INTEGER"),
("content_summary", "TEXT"),
("request_content", "TEXT"),
("response_content", "TEXT"),
],
)
.await?;
+237 -12
View File
@@ -1,6 +1,7 @@
use crate::{
db::models::{route_channel::RouteChannel, token_route::TokenRoute},
error::{AppError, AppResult},
services::notification::NotificationService,
AppState,
};
use axum::{
@@ -12,6 +13,7 @@ use chrono::{DateTime, Duration, Utc};
use serde::Deserialize;
use serde_json::{json, Value};
use sqlx::Row;
use std::collections::HashMap;
pub fn routes() -> Router<AppState> {
Router::new()
@@ -914,6 +916,88 @@ fn mask_downstream_key(key: &str) -> String {
format!("••••••••{}", last4)
}
fn mask_optional_secret(value: Option<&str>) -> Option<String> {
let value = value?.trim();
if value.is_empty() {
return None;
}
let chars: Vec<char> = value.chars().collect();
let keep = chars.len().min(4);
let suffix: String = chars[chars.len().saturating_sub(keep)..].iter().collect();
Some(format!("••••{}", suffix))
}
async fn load_settings_map(
state: &AppState,
prefix: Option<&str>,
) -> AppResult<HashMap<String, String>> {
let rows: Vec<(String, Option<String>)> = match prefix {
Some(prefix) => sqlx::query_as("SELECT key, value FROM settings WHERE key LIKE ?")
.bind(format!("{prefix}%"))
.fetch_all(&*state.db)
.await?,
None => sqlx::query_as("SELECT key, value FROM settings")
.fetch_all(&*state.db)
.await?,
};
Ok(rows
.into_iter()
.filter_map(|(key, value)| value.map(|value| (key, value)))
.collect())
}
fn setting_string(map: &HashMap<String, String>, key: &str) -> Option<String> {
map.get(key)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn setting_bool(map: &HashMap<String, String>, key: &str, default: bool) -> bool {
map.get(key)
.map(|value| parse_runtime_bool(value))
.unwrap_or(default)
}
fn setting_i64(map: &HashMap<String, String>, key: &str, default: i64) -> i64 {
map.get(key)
.and_then(|value| value.trim().parse::<i64>().ok())
.unwrap_or(default)
}
fn parse_runtime_bool(value: &str) -> bool {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on" | "enabled"
)
}
async fn upsert_setting(state: &AppState, key: &str, value: impl Into<String>) -> AppResult<()> {
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
.bind(key)
.bind(value.into())
.execute(&*state.db)
.await?;
Ok(())
}
async fn upsert_optional_secret(
state: &AppState,
key: &str,
value: Option<String>,
) -> AppResult<()> {
let Some(value) = value.map(|value| value.trim().to_string()) else {
return Ok(());
};
if value.is_empty() || value.chars().all(|ch| ch == '•' || ch == '*') {
return Ok(());
}
upsert_setting(state, key, value).await
}
fn bool_setting_value(value: bool) -> &'static str {
if value { "true" } else { "false" }
}
fn json_text_array(raw: Option<&str>) -> Vec<Value> {
let Some(raw) = raw.map(str::trim).filter(|value| !value.is_empty()) else {
return Vec::new();
@@ -964,10 +1048,45 @@ async fn change_auth_token() -> Json<Value> {
Json(json!({ "success": false, "message": "auth token is managed by config.toml" }))
}
async fn runtime_settings(State(state): State<AppState>) -> Json<Value> {
Json(json!({
async fn runtime_settings(State(state): State<AppState>) -> AppResult<Json<Value>> {
let settings = load_settings_map(&state, Some("notify_")).await?;
let smtp_port = setting_i64(&settings, "notify_smtp_port", 587).clamp(1, 65_535);
let has_feishu = setting_string(&settings, "notify_feishu_webhook").is_some();
let has_serverchan = setting_string(&settings, "notify_serverchan_key").is_some();
let has_telegram = setting_string(&settings, "notify_telegram_bot_token").is_some()
&& setting_string(&settings, "notify_telegram_chat_id").is_some();
let has_smtp = setting_string(&settings, "notify_smtp_host").is_some()
&& setting_string(&settings, "notify_smtp_user").is_some()
&& setting_string(&settings, "notify_smtp_pass").is_some()
&& setting_string(&settings, "notify_smtp_from").is_some();
Ok(Json(json!({
"proxyTokenMasked": state.runtime_auth.proxy_token_masked(),
"proxyTokenSource": if state.runtime_auth.using_default_proxy_token() { "config" } else { "runtime" },
"webhookEnabled": setting_bool(&settings, "notify_webhook_enabled", true),
"webhookUrl": setting_string(&settings, "notify_webhook_url").unwrap_or_default(),
"barkEnabled": setting_bool(&settings, "notify_bark_enabled", true),
"barkUrl": setting_string(&settings, "notify_bark_url").unwrap_or_default(),
"feishuEnabled": setting_bool(&settings, "notify_feishu_enabled", has_feishu),
"feishuWebhook": "",
"feishuWebhookMasked": mask_optional_secret(setting_string(&settings, "notify_feishu_webhook").as_deref()),
"serverChanEnabled": setting_bool(&settings, "notify_serverchan_enabled", has_serverchan),
"serverChanKeyMasked": mask_optional_secret(setting_string(&settings, "notify_serverchan_key").as_deref()),
"telegramEnabled": setting_bool(&settings, "notify_telegram_enabled", has_telegram),
"telegramApiBaseUrl": setting_string(&settings, "notify_telegram_api_base_url")
.unwrap_or_else(|| "https://api.telegram.org".to_string()),
"telegramBotTokenMasked": mask_optional_secret(setting_string(&settings, "notify_telegram_bot_token").as_deref()),
"telegramChatId": setting_string(&settings, "notify_telegram_chat_id").unwrap_or_default(),
"telegramUseSystemProxy": setting_bool(&settings, "notify_telegram_use_system_proxy", false),
"telegramMessageThreadId": setting_string(&settings, "notify_telegram_thread_id").unwrap_or_default(),
"smtpEnabled": setting_bool(&settings, "notify_smtp_enabled", has_smtp),
"smtpHost": setting_string(&settings, "notify_smtp_host").unwrap_or_default(),
"smtpPort": smtp_port,
"smtpSecure": setting_bool(&settings, "notify_smtp_secure", true),
"smtpUser": setting_string(&settings, "notify_smtp_user").unwrap_or_default(),
"smtpPassMasked": mask_optional_secret(setting_string(&settings, "notify_smtp_pass").as_deref()),
"smtpFrom": setting_string(&settings, "notify_smtp_from").unwrap_or_default(),
"smtpTo": setting_string(&settings, "notify_smtp_to").unwrap_or_default(),
"notifyCooldownSec": setting_i64(&settings, "notify_cooldown_sec", 300).max(0),
"server": {
"host": state.config.server.host,
"port": state.config.server.port,
@@ -988,13 +1107,36 @@ async fn runtime_settings(State(state): State<AppState>) -> Json<Value> {
"externalUrl": state.config.comfyui.external_url,
"agentModel": state.config.comfyui.agent_model,
},
}))
})))
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RuntimeSettingsUpdateRequest {
proxy_token: Option<String>,
webhook_url: Option<String>,
bark_url: Option<String>,
webhook_enabled: Option<bool>,
bark_enabled: Option<bool>,
feishu_enabled: Option<bool>,
feishu_webhook: Option<String>,
server_chan_enabled: Option<bool>,
server_chan_key: Option<String>,
telegram_enabled: Option<bool>,
telegram_api_base_url: Option<String>,
telegram_bot_token: Option<String>,
telegram_chat_id: Option<String>,
telegram_use_system_proxy: Option<bool>,
telegram_message_thread_id: Option<String>,
smtp_enabled: Option<bool>,
smtp_host: Option<String>,
smtp_port: Option<i64>,
smtp_secure: Option<bool>,
smtp_user: Option<String>,
smtp_pass: Option<String>,
smtp_from: Option<String>,
smtp_to: Option<String>,
notify_cooldown_sec: Option<i64>,
}
async fn update_runtime_settings(
@@ -1014,11 +1156,88 @@ async fn update_runtime_settings(
})));
}
Ok(Json(json!({
"success": true,
"proxyTokenMasked": state.runtime_auth.proxy_token_masked(),
"proxyTokenSource": if state.runtime_auth.using_default_proxy_token() { "config" } else { "runtime" },
})))
if let Some(enabled) = req.webhook_enabled {
upsert_setting(&state, "notify_webhook_enabled", bool_setting_value(enabled)).await?;
}
if let Some(value) = req.webhook_url {
upsert_setting(&state, "notify_webhook_url", value.trim().to_string()).await?;
}
if let Some(enabled) = req.bark_enabled {
upsert_setting(&state, "notify_bark_enabled", bool_setting_value(enabled)).await?;
}
if let Some(value) = req.bark_url {
upsert_setting(&state, "notify_bark_url", value.trim().to_string()).await?;
}
if let Some(enabled) = req.feishu_enabled {
upsert_setting(&state, "notify_feishu_enabled", bool_setting_value(enabled)).await?;
}
upsert_optional_secret(&state, "notify_feishu_webhook", req.feishu_webhook).await?;
if let Some(enabled) = req.server_chan_enabled {
upsert_setting(
&state,
"notify_serverchan_enabled",
bool_setting_value(enabled),
)
.await?;
}
upsert_optional_secret(&state, "notify_serverchan_key", req.server_chan_key).await?;
if let Some(enabled) = req.telegram_enabled {
upsert_setting(
&state,
"notify_telegram_enabled",
bool_setting_value(enabled),
)
.await?;
}
if let Some(value) = req.telegram_api_base_url {
upsert_setting(&state, "notify_telegram_api_base_url", value.trim().to_string()).await?;
}
upsert_optional_secret(&state, "notify_telegram_bot_token", req.telegram_bot_token).await?;
if let Some(value) = req.telegram_chat_id {
upsert_setting(&state, "notify_telegram_chat_id", value.trim().to_string()).await?;
}
if let Some(enabled) = req.telegram_use_system_proxy {
upsert_setting(
&state,
"notify_telegram_use_system_proxy",
bool_setting_value(enabled),
)
.await?;
}
if let Some(value) = req.telegram_message_thread_id {
upsert_setting(&state, "notify_telegram_thread_id", value.trim().to_string()).await?;
}
if let Some(enabled) = req.smtp_enabled {
upsert_setting(&state, "notify_smtp_enabled", bool_setting_value(enabled)).await?;
}
if let Some(value) = req.smtp_host {
upsert_setting(&state, "notify_smtp_host", value.trim().to_string()).await?;
}
if let Some(value) = req.smtp_port {
upsert_setting(&state, "notify_smtp_port", value.clamp(1, 65_535).to_string()).await?;
}
if let Some(enabled) = req.smtp_secure {
upsert_setting(&state, "notify_smtp_secure", bool_setting_value(enabled)).await?;
}
if let Some(value) = req.smtp_user {
upsert_setting(&state, "notify_smtp_user", value.trim().to_string()).await?;
}
upsert_optional_secret(&state, "notify_smtp_pass", req.smtp_pass).await?;
if let Some(value) = req.smtp_from {
upsert_setting(&state, "notify_smtp_from", value.trim().to_string()).await?;
}
if let Some(value) = req.smtp_to {
upsert_setting(&state, "notify_smtp_to", value.trim().to_string()).await?;
}
if let Some(value) = req.notify_cooldown_sec {
upsert_setting(&state, "notify_cooldown_sec", value.max(0).to_string()).await?;
}
let Json(mut body) = runtime_settings(State(state.clone())).await?;
if let Value::Object(ref mut map) = body {
map.insert("success".to_string(), Value::Bool(true));
}
Ok(Json(body))
}
async fn brand_list() -> Json<Value> {
@@ -1191,8 +1410,13 @@ async fn clear_usage_data(State(state): State<AppState>) -> AppResult<Json<Value
Ok(Json(json!({ "success": true })))
}
async fn test_notification() -> Json<Value> {
Json(json!({ "success": true, "message": "notification endpoint available" }))
async fn test_notification(State(state): State<AppState>) -> AppResult<Json<Value>> {
let channels = NotificationService::new(state.db.clone()).send_test().await?;
Ok(Json(json!({
"success": true,
"message": format!("测试通知已发送:{}", channels.join(", ")),
"channels": channels,
})))
}
#[cfg(test)]
@@ -1260,7 +1484,7 @@ mod tests {
#[tokio::test]
async fn runtime_settings_reports_and_updates_proxy_token() {
let state = test_state().await;
let Json(initial) = runtime_settings(State(state.clone())).await;
let Json(initial) = runtime_settings(State(state.clone())).await.unwrap();
assert_eq!(initial["proxyTokenMasked"], "••••oken");
assert_eq!(initial["proxyTokenSource"], "config");
@@ -1268,6 +1492,7 @@ mod tests {
State(state.clone()),
Json(RuntimeSettingsUpdateRequest {
proxy_token: Some("sk-new-runtime-token".into()),
..Default::default()
}),
)
.await
+72 -9
View File
@@ -1,4 +1,4 @@
use crate::error::AppResult;
use crate::error::{AppError, AppResult};
use chrono::{FixedOffset, SecondsFormat, TimeZone, Utc};
use lettre::{
message::Mailbox, transport::smtp::authentication::Credentials, Message, SmtpTransport,
@@ -36,13 +36,19 @@ pub struct Notification {
#[derive(Debug, Clone, Default)]
struct NotificationConfig {
telegram_enabled: bool,
telegram_bot_token: Option<String>,
telegram_chat_id: Option<String>,
telegram_thread_id: Option<i64>,
wecom_enabled: bool,
wecom_webhook: Option<String>,
feishu_enabled: bool,
feishu_webhook: Option<String>,
webhook_enabled: bool,
webhook_url: Option<String>,
bark_enabled: bool,
bark_url: Option<String>,
serverchan_enabled: bool,
serverchan_key: Option<String>,
smtp: SmtpConfig,
cooldown_sec: i64,
@@ -50,8 +56,10 @@ struct NotificationConfig {
#[derive(Debug, Clone, Default)]
struct SmtpConfig {
enabled: bool,
host: Option<String>,
port: Option<u16>,
secure: Option<bool>,
user: Option<String>,
pass: Option<String>,
from: Option<String>,
@@ -100,6 +108,33 @@ impl NotificationService {
Ok(())
}
pub async fn send_test(&self) -> AppResult<Vec<String>> {
Self::ensure_schema(&self.db).await?;
let config = self.load_config().await?;
let channels = self.enabled_channels(&config);
if channels.is_empty() {
return Err(AppError::BadRequest(
"请至少配置并启用一个通知通道".into(),
));
}
let notif = Notification {
title: "BossAI 通知测试".to_string(),
message: "通知通道配置正常。这条消息来自管理后台的测试发送。".to_string(),
level: NotificationLevel::Info,
channel: None,
signature: None,
};
for channel in &channels {
self.send_channel(channel, &notif, &config)
.await
.map_err(|err| AppError::BadRequest(format!("{channel} 通知测试失败: {err}")))?;
}
Ok(channels)
}
async fn load_config(&self) -> AppResult<NotificationConfig> {
let rows: Vec<(String, Option<String>)> =
sqlx::query_as("SELECT key, value FROM settings WHERE key LIKE 'notify_%'")
@@ -112,18 +147,26 @@ impl NotificationService {
.collect();
Ok(NotificationConfig {
telegram_enabled: setting_bool(&map, "notify_telegram_enabled", true),
telegram_bot_token: setting(&map, "notify_telegram_bot_token"),
telegram_chat_id: setting(&map, "notify_telegram_chat_id"),
telegram_thread_id: setting(&map, "notify_telegram_thread_id")
.and_then(|value| value.parse().ok()),
wecom_enabled: setting_bool(&map, "notify_wecom_enabled", true),
wecom_webhook: setting(&map, "notify_wecom_webhook"),
feishu_enabled: setting_bool(&map, "notify_feishu_enabled", true),
feishu_webhook: setting(&map, "notify_feishu_webhook"),
webhook_enabled: setting_bool(&map, "notify_webhook_enabled", true),
webhook_url: setting(&map, "notify_webhook_url"),
bark_enabled: setting_bool(&map, "notify_bark_enabled", true),
bark_url: setting(&map, "notify_bark_url"),
serverchan_enabled: setting_bool(&map, "notify_serverchan_enabled", true),
serverchan_key: setting(&map, "notify_serverchan_key"),
smtp: SmtpConfig {
enabled: setting_bool(&map, "notify_smtp_enabled", true),
host: setting(&map, "notify_smtp_host"),
port: setting(&map, "notify_smtp_port").and_then(|value| value.parse().ok()),
secure: setting(&map, "notify_smtp_secure").map(|value| parse_bool(&value)),
user: setting(&map, "notify_smtp_user"),
pass: setting(&map, "notify_smtp_pass"),
from: setting(&map, "notify_smtp_from"),
@@ -188,25 +231,28 @@ impl NotificationService {
fn enabled_channels(&self, config: &NotificationConfig) -> Vec<String> {
let mut channels = Vec::new();
if config.telegram_bot_token.is_some() && config.telegram_chat_id.is_some() {
if config.telegram_enabled
&& config.telegram_bot_token.is_some()
&& config.telegram_chat_id.is_some()
{
channels.push("telegram".to_string());
}
if config.wecom_webhook.is_some() {
if config.wecom_enabled && config.wecom_webhook.is_some() {
channels.push("wecom".to_string());
}
if config.feishu_webhook.is_some() {
if config.feishu_enabled && config.feishu_webhook.is_some() {
channels.push("feishu".to_string());
}
if config.webhook_url.is_some() {
if config.webhook_enabled && config.webhook_url.is_some() {
channels.push("webhook".to_string());
}
if config.bark_url.is_some() {
if config.bark_enabled && config.bark_url.is_some() {
channels.push("bark".to_string());
}
if config.serverchan_key.is_some() {
if config.serverchan_enabled && config.serverchan_key.is_some() {
channels.push("serverchan".to_string());
}
if config.smtp.is_configured() {
if config.smtp.enabled && config.smtp.is_configured() {
channels.push("smtp".to_string());
}
channels
@@ -385,6 +431,7 @@ impl SmtpConfig {
fn send(self, notif: &Notification) -> anyhow::Result<()> {
let host = required(self.host.as_deref(), "smtp host")?;
let port = self.port.unwrap_or(587);
let secure = self.secure.unwrap_or(true);
let user = required(self.user.as_deref(), "smtp user")?;
let pass = required(self.pass.as_deref(), "smtp pass")?;
let from = required(self.from.as_deref(), "smtp from")?;
@@ -400,7 +447,12 @@ impl SmtpConfig {
))
.body(format_notification_text(notif))?;
let mailer = SmtpTransport::relay(host)?
let builder = if secure {
SmtpTransport::relay(host)?
} else {
SmtpTransport::builder_dangerous(host)
};
let mailer = builder
.port(port)
.credentials(Credentials::new(user.to_string(), pass.to_string()))
.build();
@@ -413,6 +465,17 @@ fn setting(map: &HashMap<String, String>, key: &str) -> Option<String> {
map.get(key).map(|value| value.trim().to_string())
}
fn setting_bool(map: &HashMap<String, String>, key: &str, default: bool) -> bool {
map.get(key).map(|value| parse_bool(value)).unwrap_or(default)
}
fn parse_bool(value: &str) -> bool {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on" | "enabled"
)
}
fn required<'a>(value: Option<&'a str>, name: &str) -> anyhow::Result<&'a str> {
value.ok_or_else(|| anyhow::anyhow!("{name} is not configured"))
}
+72 -8
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>
<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>
+47 -25
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>
<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
}}
/>