perf: speed up app loading
This commit is contained in:
+5
-2
@@ -10,6 +10,7 @@ use include_dir::{include_dir, Dir};
|
|||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use tower::service_fn;
|
use tower::service_fn;
|
||||||
|
use tower_http::compression::CompressionLayer;
|
||||||
use tower_http::services::ServeDir;
|
use tower_http::services::ServeDir;
|
||||||
|
|
||||||
static EMBEDDED_WEB: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/dist/web");
|
static EMBEDDED_WEB: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/dist/web");
|
||||||
@@ -78,7 +79,7 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
let router = Router::new().merge(api_routes);
|
let router = Router::new().merge(api_routes);
|
||||||
match std::env::var("BOOSAPI_WEB_DIR")
|
let router = match std::env::var("BOOSAPI_WEB_DIR")
|
||||||
.ok()
|
.ok()
|
||||||
.map(|value| value.trim().to_string())
|
.map(|value| value.trim().to_string())
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
@@ -111,7 +112,9 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
router.fallback_service(static_service)
|
router.fallback_service(static_service)
|
||||||
}
|
}
|
||||||
None => router.fallback(embedded_static_handler),
|
None => router.fallback(embedded_static_handler),
|
||||||
}
|
};
|
||||||
|
|
||||||
|
router.layer(CompressionLayer::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn api_not_found_response(path: &str) -> Response {
|
fn api_not_found_response(path: &str) -> Response {
|
||||||
|
|||||||
+41
-30
@@ -54,6 +54,45 @@ async fn dashboard(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(q): Query<std::collections::HashMap<String, String>>,
|
Query(q): Query<std::collections::HashMap<String, String>>,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
|
match q.get("view").map(String::as_str) {
|
||||||
|
Some("summary") => return Ok(Json(build_dashboard_summary(&state).await?)),
|
||||||
|
Some("insights") => {
|
||||||
|
let site_availability = build_site_availability(&state).await?;
|
||||||
|
let model_analysis = build_model_analysis(&state, 7).await?;
|
||||||
|
return Ok(Json(json!({
|
||||||
|
"generatedAt": chrono::Utc::now().to_rfc3339(),
|
||||||
|
"insights": [],
|
||||||
|
"siteAvailability": site_availability,
|
||||||
|
"modelAnalysis": model_analysis,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let summary = build_dashboard_summary(&state).await?;
|
||||||
|
let site_availability = build_site_availability(&state).await?;
|
||||||
|
let model_analysis = build_model_analysis(&state, 7).await?;
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"generatedAt": summary["generatedAt"].clone(),
|
||||||
|
"totalBalance": summary["totalBalance"].clone(),
|
||||||
|
"totalUsed": summary["totalUsed"].clone(),
|
||||||
|
"todaySpend": summary["todaySpend"].clone(),
|
||||||
|
"todayReward": summary["todayReward"].clone(),
|
||||||
|
"activeAccounts": summary["activeAccounts"].clone(),
|
||||||
|
"totalAccounts": summary["totalAccounts"].clone(),
|
||||||
|
"todayCheckin": summary["todayCheckin"].clone(),
|
||||||
|
"proxy24h": summary["proxy24h"].clone(),
|
||||||
|
"performance": summary["performance"].clone(),
|
||||||
|
"siteAvailability": site_availability,
|
||||||
|
"modelAnalysis": model_analysis,
|
||||||
|
"summary": summary["summary"].clone(),
|
||||||
|
"insights": [],
|
||||||
|
"stats": summary["stats"].clone(),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn build_dashboard_summary(state: &AppState) -> AppResult<Value> {
|
||||||
let sites: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sites")
|
let sites: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sites")
|
||||||
.fetch_one(&*state.db)
|
.fetch_one(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -134,10 +173,8 @@ async fn dashboard(
|
|||||||
)
|
)
|
||||||
.fetch_one(&*state.db)
|
.fetch_one(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
let site_availability = build_site_availability(&state).await?;
|
|
||||||
let model_analysis = build_model_analysis(&state, 7).await?;
|
|
||||||
|
|
||||||
let payload = json!({
|
Ok(json!({
|
||||||
"generatedAt": chrono::Utc::now().to_rfc3339(),
|
"generatedAt": chrono::Utc::now().to_rfc3339(),
|
||||||
"totalBalance": total_balance,
|
"totalBalance": total_balance,
|
||||||
"totalUsed": total_used,
|
"totalUsed": total_used,
|
||||||
@@ -159,8 +196,6 @@ async fn dashboard(
|
|||||||
"requestsPerMinute": perf_requests as f64 * 60.0 / perf_window_secs as f64,
|
"requestsPerMinute": perf_requests as f64 * 60.0 / perf_window_secs as f64,
|
||||||
"tokensPerMinute": perf_tokens as f64 * 60.0 / perf_window_secs as f64,
|
"tokensPerMinute": perf_tokens as f64 * 60.0 / perf_window_secs as f64,
|
||||||
},
|
},
|
||||||
"siteAvailability": site_availability,
|
|
||||||
"modelAnalysis": model_analysis,
|
|
||||||
"summary": {
|
"summary": {
|
||||||
"sites": sites,
|
"sites": sites,
|
||||||
"accounts": accounts,
|
"accounts": accounts,
|
||||||
@@ -178,31 +213,7 @@ async fn dashboard(
|
|||||||
"successRequests": success_24h,
|
"successRequests": success_24h,
|
||||||
"totalCost": total_cost_24h,
|
"totalCost": total_cost_24h,
|
||||||
},
|
},
|
||||||
});
|
}))
|
||||||
|
|
||||||
match q.get("view").map(String::as_str) {
|
|
||||||
Some("summary") => Ok(Json(json!({
|
|
||||||
"generatedAt": payload["generatedAt"],
|
|
||||||
"summary": payload["summary"],
|
|
||||||
"stats": payload["stats"],
|
|
||||||
"totalBalance": payload["totalBalance"],
|
|
||||||
"totalUsed": payload["totalUsed"],
|
|
||||||
"todaySpend": payload["todaySpend"],
|
|
||||||
"todayReward": payload["todayReward"],
|
|
||||||
"activeAccounts": payload["activeAccounts"],
|
|
||||||
"totalAccounts": payload["totalAccounts"],
|
|
||||||
"todayCheckin": payload["todayCheckin"],
|
|
||||||
"proxy24h": payload["proxy24h"],
|
|
||||||
"performance": payload["performance"],
|
|
||||||
}))),
|
|
||||||
Some("insights") => Ok(Json(json!({
|
|
||||||
"generatedAt": payload["generatedAt"],
|
|
||||||
"insights": payload["insights"],
|
|
||||||
"siteAvailability": payload["siteAvailability"],
|
|
||||||
"modelAnalysis": payload["modelAnalysis"],
|
|
||||||
}))),
|
|
||||||
_ => Ok(Json(payload)),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn build_model_analysis(state: &AppState, days: i64) -> AppResult<Value> {
|
async fn build_model_analysis(state: &AppState, days: i64) -> AppResult<Value> {
|
||||||
|
|||||||
+87
-22
@@ -29,16 +29,22 @@ pub fn routes() -> Router<AppState> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn user_picture_log_items(state: &AppState, user_id: i64) -> AppResult<Vec<Value>> {
|
async fn user_picture_log_items_limited(
|
||||||
|
state: &AppState,
|
||||||
|
user_id: i64,
|
||||||
|
limit: i64,
|
||||||
|
) -> AppResult<Vec<Value>> {
|
||||||
let tasks: Vec<BackgroundTask> = sqlx::query_as(
|
let tasks: Vec<BackgroundTask> = sqlx::query_as(
|
||||||
"SELECT *
|
"SELECT *
|
||||||
FROM background_tasks
|
FROM background_tasks
|
||||||
WHERE task_type = ?
|
WHERE task_type = ?
|
||||||
AND json_extract(result_json, '$.userId') = ?
|
AND json_extract(result_json, '$.userId') = ?
|
||||||
ORDER BY created_at DESC, id DESC",
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT ?",
|
||||||
)
|
)
|
||||||
.bind("picture-agent-generation")
|
.bind("picture-agent-generation")
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
|
.bind(limit.clamp(1, 5000))
|
||||||
.fetch_all(&*state.db)
|
.fetch_all(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -102,7 +108,11 @@ async fn user_picture_log_items(state: &AppState, user_id: i64) -> AppResult<Vec
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn user_usage_log_items(state: &AppState, user_id: i64) -> AppResult<Vec<Value>> {
|
async fn user_usage_log_items_limited(
|
||||||
|
state: &AppState,
|
||||||
|
user_id: i64,
|
||||||
|
limit: i64,
|
||||||
|
) -> AppResult<Vec<Value>> {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
@@ -118,10 +128,11 @@ async fn user_usage_log_items(state: &AppState, user_id: i64) -> AppResult<Vec<V
|
|||||||
JOIN downstream_api_keys k ON k.id = l.downstream_key_id
|
JOIN downstream_api_keys k ON k.id = l.downstream_key_id
|
||||||
WHERE k.user_id = ?
|
WHERE k.user_id = ?
|
||||||
ORDER BY l.created_at DESC, l.id DESC
|
ORDER BY l.created_at DESC, l.id DESC
|
||||||
LIMIT 5000
|
LIMIT ?
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
|
.bind(limit.clamp(1, 5000))
|
||||||
.fetch_all(&*state.db)
|
.fetch_all(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -285,6 +296,16 @@ fn sort_user_logs_desc(items: &mut [Value]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn load_user_log_items(state: &AppState, user_id: i64) -> AppResult<Vec<Value>> {
|
async fn load_user_log_items(state: &AppState, user_id: i64) -> AppResult<Vec<Value>> {
|
||||||
|
load_user_log_items_limited(state, user_id, 5000, false).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_user_log_items_limited(
|
||||||
|
state: &AppState,
|
||||||
|
user_id: i64,
|
||||||
|
limit: i64,
|
||||||
|
truncate_combined: bool,
|
||||||
|
) -> AppResult<Vec<Value>> {
|
||||||
|
let limit = limit.clamp(1, 5000);
|
||||||
let mut items: Vec<Value> = sqlx::query_as::<_, crate::routes::stats::ProxyLogRow>(
|
let mut items: Vec<Value> = sqlx::query_as::<_, crate::routes::stats::ProxyLogRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
@@ -308,22 +329,78 @@ async fn load_user_log_items(state: &AppState, user_id: i64) -> AppResult<Vec<Va
|
|||||||
LEFT JOIN sites s ON s.id = a.site_id
|
LEFT JOIN sites s ON s.id = a.site_id
|
||||||
WHERE k.user_id = ?
|
WHERE k.user_id = ?
|
||||||
ORDER BY l.created_at DESC
|
ORDER BY l.created_at DESC
|
||||||
LIMIT 5000
|
LIMIT ?
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
|
.bind(limit)
|
||||||
.fetch_all(&*state.db)
|
.fetch_all(&*state.db)
|
||||||
.await?
|
.await?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|row| serde_json::to_value(row).unwrap_or(json!({})))
|
.map(|row| serde_json::to_value(row).unwrap_or(json!({})))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
items.extend(user_usage_log_items(state, user_id).await?);
|
items.extend(user_usage_log_items_limited(state, user_id, limit).await?);
|
||||||
items.extend(user_picture_log_items(state, user_id).await?);
|
items.extend(user_picture_log_items_limited(state, user_id, limit).await?);
|
||||||
sort_user_logs_desc(&mut items);
|
sort_user_logs_desc(&mut items);
|
||||||
|
if truncate_combined {
|
||||||
|
items.truncate(limit as usize);
|
||||||
|
}
|
||||||
Ok(items)
|
Ok(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn load_user_log_summary(state: &AppState, user_id: i64) -> AppResult<(i64, i64, f64)> {
|
||||||
|
let proxy_summary: (i64, i64, f64) = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
COUNT(l.id) AS total,
|
||||||
|
COALESCE(SUM(CASE WHEN l.status = 'success' THEN 1 ELSE 0 END), 0) AS success_count,
|
||||||
|
COALESCE(SUM(l.estimated_cost), 0.0) AS total_cost
|
||||||
|
FROM proxy_logs l
|
||||||
|
JOIN downstream_api_keys k ON l.downstream_api_key_id = k.id
|
||||||
|
WHERE k.user_id = ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(&*state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let usage_summary: (i64, f64) = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
COUNT(l.id) AS total,
|
||||||
|
COALESCE(SUM(l.cost), 0.0) AS total_cost
|
||||||
|
FROM usage_logs l
|
||||||
|
JOIN downstream_api_keys k ON k.id = l.downstream_key_id
|
||||||
|
WHERE k.user_id = ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(&*state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let picture_summary: (i64, i64) = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS total,
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'succeeded' THEN 1 ELSE 0 END), 0) AS success_count
|
||||||
|
FROM background_tasks
|
||||||
|
WHERE task_type = ?
|
||||||
|
AND json_extract(result_json, '$.userId') = ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind("picture-agent-generation")
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(&*state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
proxy_summary.0 + usage_summary.0 + picture_summary.0,
|
||||||
|
proxy_summary.1 + usage_summary.0 + picture_summary.1,
|
||||||
|
proxy_summary.2 + usage_summary.1,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct UpdateProfilePayload {
|
struct UpdateProfilePayload {
|
||||||
username: Option<String>,
|
username: Option<String>,
|
||||||
@@ -537,13 +614,7 @@ async fn get_user_summary(
|
|||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
) -> AppResult<Json<Value>> {
|
) -> AppResult<Json<Value>> {
|
||||||
let user_id = current_user_id(&headers, &state)?;
|
let user_id = current_user_id(&headers, &state)?;
|
||||||
let items = load_user_log_items(&state, user_id).await?;
|
let (total, success_count, total_cost) = load_user_log_summary(&state, user_id).await?;
|
||||||
let total = items.len() as i64;
|
|
||||||
let success_count = items
|
|
||||||
.iter()
|
|
||||||
.filter(|item| user_log_status(item).as_str() == "success")
|
|
||||||
.count() as i64;
|
|
||||||
let total_cost = items.iter().map(user_log_cost).sum::<f64>();
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"totalRequests": total,
|
"totalRequests": total,
|
||||||
@@ -571,14 +642,8 @@ async fn get_user_dashboard(
|
|||||||
.fetch_all(&*state.db)
|
.fetch_all(&*state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let items = load_user_log_items(&state, user_id).await?;
|
let (total, success_count, total_cost) = load_user_log_summary(&state, user_id).await?;
|
||||||
let total = items.len() as i64;
|
let recent_logs = load_user_log_items_limited(&state, user_id, 5, true).await?;
|
||||||
let success_count = items
|
|
||||||
.iter()
|
|
||||||
.filter(|item| user_log_status(item).as_str() == "success")
|
|
||||||
.count() as i64;
|
|
||||||
let total_cost = items.iter().map(user_log_cost).sum::<f64>();
|
|
||||||
let recent_logs = items.into_iter().take(5).collect::<Vec<_>>();
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"user": {
|
"user": {
|
||||||
|
|||||||
+83
-48
@@ -68,21 +68,17 @@ type UserAnnouncementPopupRow = {
|
|||||||
publishedAt?: string | null;
|
publishedAt?: string | null;
|
||||||
firstSeenAt?: string | null;
|
firstSeenAt?: string | null;
|
||||||
};
|
};
|
||||||
const DICEBEAR_STYLES = [
|
const AVATAR_STYLES = [
|
||||||
'pixel-art',
|
'aurora',
|
||||||
'pixel-art-neutral',
|
'skyline',
|
||||||
'bottts',
|
'lotus',
|
||||||
'bottts-neutral',
|
'ember',
|
||||||
'identicon',
|
'mint',
|
||||||
'initials',
|
'violet',
|
||||||
'avataaars',
|
'graphite',
|
||||||
'avataaars-neutral',
|
'cobalt',
|
||||||
'personas',
|
|
||||||
'lorelei',
|
|
||||||
'lorelei-neutral',
|
|
||||||
'fun-emoji',
|
|
||||||
] as const;
|
] as const;
|
||||||
type DicebearStyle = typeof DICEBEAR_STYLES[number];
|
type AvatarStyle = typeof AVATAR_STYLES[number];
|
||||||
function resolveStoredThemeMode(): ThemeMode {
|
function resolveStoredThemeMode(): ThemeMode {
|
||||||
const saved = localStorage.getItem(THEME_MODE_STORAGE_KEY);
|
const saved = localStorage.getItem(THEME_MODE_STORAGE_KEY);
|
||||||
if (saved === 'system' || saved === 'light' || saved === 'dark') return saved;
|
if (saved === 'system' || saved === 'light' || saved === 'dark') return saved;
|
||||||
@@ -104,21 +100,30 @@ function hashString(input: string): number {
|
|||||||
}
|
}
|
||||||
return Math.abs(hash);
|
return Math.abs(hash);
|
||||||
}
|
}
|
||||||
function pickDicebearStyle(seed: string): DicebearStyle {
|
function pickAvatarStyle(seed: string): AvatarStyle {
|
||||||
const index = hashString(seed || 'default') % DICEBEAR_STYLES.length;
|
const index = hashString(seed || 'default') % AVATAR_STYLES.length;
|
||||||
return DICEBEAR_STYLES[index];
|
return AVATAR_STYLES[index];
|
||||||
}
|
}
|
||||||
function buildDicebearAvatarUrl(style: string, seed: string): string {
|
function resolveAvatarStyle(style: string, seed: string): AvatarStyle {
|
||||||
const safeStyle = DICEBEAR_STYLES.includes(style as DicebearStyle)
|
return AVATAR_STYLES.includes(style as AvatarStyle)
|
||||||
? style
|
? (style as AvatarStyle)
|
||||||
: pickDicebearStyle(seed);
|
: pickAvatarStyle(seed);
|
||||||
const safeSeed = (seed || 'default').trim() || 'default';
|
}
|
||||||
return `https://api.dicebear.com/9.x/${safeStyle}/svg?seed=${encodeURIComponent(safeSeed)}`;
|
function buildLocalAvatarStyle(style: string, seed: string): React.CSSProperties {
|
||||||
|
const safeStyle = resolveAvatarStyle(style, seed);
|
||||||
|
const hue = hashString(`${safeStyle}:${seed || 'default'}`) % 360;
|
||||||
|
const secondHue = (hue + 46 + (hashString(seed || safeStyle) % 92)) % 360;
|
||||||
|
return {
|
||||||
|
background:
|
||||||
|
`radial-gradient(circle at 28% 24%, hsl(${secondHue} 86% 88%) 0 18%, transparent 19%), ` +
|
||||||
|
`linear-gradient(135deg, hsl(${hue} 74% 50%), hsl(${secondHue} 70% 48%))`,
|
||||||
|
color: '#fff',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultProfile(name: string): UserProfile {
|
function defaultProfile(name: string): UserProfile {
|
||||||
const avatarSeed = createRandomAvatarSeed();
|
const avatarSeed = createRandomAvatarSeed();
|
||||||
return { name, avatarSeed, avatarStyle: pickDicebearStyle(avatarSeed) };
|
return { name, avatarSeed, avatarStyle: pickAvatarStyle(avatarSeed) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseStoredProfile(raw: string | null, fallbackName: string): UserProfile | null {
|
function parseStoredProfile(raw: string | null, fallbackName: string): UserProfile | null {
|
||||||
@@ -136,9 +141,9 @@ function parseStoredProfile(raw: string | null, fallbackName: string): UserProfi
|
|||||||
return {
|
return {
|
||||||
name: name || fallbackName,
|
name: name || fallbackName,
|
||||||
avatarSeed: resolvedSeed,
|
avatarSeed: resolvedSeed,
|
||||||
avatarStyle: DICEBEAR_STYLES.includes(avatarStyle as DicebearStyle)
|
avatarStyle: AVATAR_STYLES.includes(avatarStyle as AvatarStyle)
|
||||||
? avatarStyle
|
? avatarStyle
|
||||||
: pickDicebearStyle(resolvedSeed),
|
: pickAvatarStyle(resolvedSeed),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@@ -171,16 +176,39 @@ function profileStorageKey(audience: 'admin' | 'user'): string {
|
|||||||
function profileFromUsername(username: string, current?: UserProfile, fallbackName = '用户'): UserProfile {
|
function profileFromUsername(username: string, current?: UserProfile, fallbackName = '用户'): UserProfile {
|
||||||
const name = username.trim();
|
const name = username.trim();
|
||||||
const avatarSeed = current?.avatarSeed?.trim() || createRandomAvatarSeed();
|
const avatarSeed = current?.avatarSeed?.trim() || createRandomAvatarSeed();
|
||||||
const avatarStyle = current?.avatarStyle?.trim() || pickDicebearStyle(avatarSeed);
|
const avatarStyle = current?.avatarStyle?.trim() || pickAvatarStyle(avatarSeed);
|
||||||
return {
|
return {
|
||||||
name: name || current?.name || fallbackName,
|
name: name || current?.name || fallbackName,
|
||||||
avatarSeed,
|
avatarSeed,
|
||||||
avatarStyle: DICEBEAR_STYLES.includes(avatarStyle as DicebearStyle)
|
avatarStyle: AVATAR_STYLES.includes(avatarStyle as AvatarStyle)
|
||||||
? avatarStyle
|
? avatarStyle
|
||||||
: pickDicebearStyle(avatarSeed),
|
: pickAvatarStyle(avatarSeed),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LocalAvatar({
|
||||||
|
profile,
|
||||||
|
label,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
profile: UserProfile;
|
||||||
|
label: string;
|
||||||
|
className: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}) {
|
||||||
|
const initial = (label || profile.name || 'B').trim().slice(0, 1).toUpperCase() || 'B';
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={className}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ ...buildLocalAvatarStyle(profile.avatarStyle, profile.avatarSeed), ...style }}
|
||||||
|
>
|
||||||
|
{initial}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function UserProfileModal({
|
function UserProfileModal({
|
||||||
open,
|
open,
|
||||||
profile,
|
profile,
|
||||||
@@ -205,7 +233,6 @@ function UserProfileModal({
|
|||||||
setAvatarStyle(profile.avatarStyle);
|
setAvatarStyle(profile.avatarStyle);
|
||||||
setError('');
|
setError('');
|
||||||
}, [open, profile]);
|
}, [open, profile]);
|
||||||
const avatarUrl = buildDicebearAvatarUrl(avatarStyle, avatarSeed);
|
|
||||||
const inputStyle: React.CSSProperties = {
|
const inputStyle: React.CSSProperties = {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
padding: '10px 14px',
|
padding: '10px 14px',
|
||||||
@@ -219,7 +246,7 @@ function UserProfileModal({
|
|||||||
const handleRandomAvatar = () => {
|
const handleRandomAvatar = () => {
|
||||||
const nextSeed = createRandomAvatarSeed();
|
const nextSeed = createRandomAvatarSeed();
|
||||||
setAvatarSeed(nextSeed);
|
setAvatarSeed(nextSeed);
|
||||||
setAvatarStyle(pickDicebearStyle(nextSeed));
|
setAvatarStyle(pickAvatarStyle(nextSeed));
|
||||||
};
|
};
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
const normalizedName = name.trim();
|
const normalizedName = name.trim();
|
||||||
@@ -234,11 +261,16 @@ function UserProfileModal({
|
|||||||
onSave({
|
onSave({
|
||||||
name: normalizedName,
|
name: normalizedName,
|
||||||
avatarSeed: avatarSeed.trim() || createRandomAvatarSeed(),
|
avatarSeed: avatarSeed.trim() || createRandomAvatarSeed(),
|
||||||
avatarStyle: DICEBEAR_STYLES.includes(avatarStyle as DicebearStyle)
|
avatarStyle: AVATAR_STYLES.includes(avatarStyle as AvatarStyle)
|
||||||
? avatarStyle
|
? avatarStyle
|
||||||
: pickDicebearStyle(avatarSeed),
|
: pickAvatarStyle(avatarSeed),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
const previewProfile: UserProfile = {
|
||||||
|
name: name.trim() || profile.name,
|
||||||
|
avatarSeed,
|
||||||
|
avatarStyle,
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<CenteredModal
|
<CenteredModal
|
||||||
open={open}
|
open={open}
|
||||||
@@ -256,13 +288,12 @@ function UserProfileModal({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 2 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 2 }}>
|
||||||
<div className="topbar-avatar" style={{ width: 40, height: 40, fontSize: 14 }}>
|
<LocalAvatar
|
||||||
<img
|
className="topbar-avatar"
|
||||||
src={avatarUrl}
|
profile={previewProfile}
|
||||||
alt={name.trim() || 'avatar'}
|
label={previewProfile.name}
|
||||||
style={{ width: '100%', height: '100%', borderRadius: '50%', objectFit: 'cover' }}
|
style={{ width: 40, height: 40, fontSize: 14 }}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{t('右上角头像实时预览')}</div>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{t('右上角头像实时预览')}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -279,7 +310,7 @@ function UserProfileModal({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>
|
||||||
{t('头像(Dicebear 随机) · 风格:')}{avatarStyle}
|
{t('头像 · 风格:')}{resolveAvatarStyle(avatarStyle, avatarSeed)}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={handleRandomAvatar}>
|
<button type="button" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={handleRandomAvatar}>
|
||||||
{t('换一个随机头像')}
|
{t('换一个随机头像')}
|
||||||
@@ -494,7 +525,6 @@ function AppShell() {
|
|||||||
const accountRoleLabel = profileAudience === 'admin' ? t('管理员') : t('普通用户');
|
const accountRoleLabel = profileAudience === 'admin' ? t('管理员') : t('普通用户');
|
||||||
const displayName = rawDisplayName ? (rawDisplayName === '管理员' ? t('管理员') : rawDisplayName) : accountRoleLabel;
|
const displayName = rawDisplayName ? (rawDisplayName === '管理员' ? t('管理员') : rawDisplayName) : accountRoleLabel;
|
||||||
const resolvedThemeLabel = resolvedTheme === 'dark' ? t('深色') : t('浅色');
|
const resolvedThemeLabel = resolvedTheme === 'dark' ? t('深色') : t('浅色');
|
||||||
const avatarUrl = buildDicebearAvatarUrl(userProfile.avatarStyle, userProfile.avatarSeed);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = topbarRef.current;
|
const root = topbarRef.current;
|
||||||
if (!root) return undefined;
|
if (!root) return undefined;
|
||||||
@@ -709,9 +739,9 @@ function AppShell() {
|
|||||||
const normalized = {
|
const normalized = {
|
||||||
name: nextProfile.name.trim() || t(profileAudience === 'admin' ? '管理员' : '用户'),
|
name: nextProfile.name.trim() || t(profileAudience === 'admin' ? '管理员' : '用户'),
|
||||||
avatarSeed: normalizedSeed,
|
avatarSeed: normalizedSeed,
|
||||||
avatarStyle: DICEBEAR_STYLES.includes(nextProfile.avatarStyle as DicebearStyle)
|
avatarStyle: AVATAR_STYLES.includes(nextProfile.avatarStyle as AvatarStyle)
|
||||||
? nextProfile.avatarStyle
|
? nextProfile.avatarStyle
|
||||||
: pickDicebearStyle(normalizedSeed),
|
: pickAvatarStyle(normalizedSeed),
|
||||||
};
|
};
|
||||||
setUserProfile(normalized);
|
setUserProfile(normalized);
|
||||||
localStorage.setItem(profileStorageKey(profileAudience), JSON.stringify(normalized));
|
localStorage.setItem(profileStorageKey(profileAudience), JSON.stringify(normalized));
|
||||||
@@ -914,9 +944,10 @@ function AppShell() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="topbar-user-avatar">
|
<span className="topbar-user-avatar">
|
||||||
<img
|
<LocalAvatar
|
||||||
src={avatarUrl}
|
className="topbar-user-avatar-inner"
|
||||||
alt={displayName}
|
profile={userProfile}
|
||||||
|
label={displayName}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span className="topbar-user-copy">
|
<span className="topbar-user-copy">
|
||||||
@@ -931,7 +962,11 @@ function AppShell() {
|
|||||||
<div className={`user-dropdown user-dropdown-profile ${userMenuPresence.isVisible ? '' : 'is-closing'}`.trim()}>
|
<div className={`user-dropdown user-dropdown-profile ${userMenuPresence.isVisible ? '' : 'is-closing'}`.trim()}>
|
||||||
<div className="user-dropdown-header">
|
<div className="user-dropdown-header">
|
||||||
<span className="user-dropdown-avatar">
|
<span className="user-dropdown-avatar">
|
||||||
<img src={avatarUrl} alt={displayName} />
|
<LocalAvatar
|
||||||
|
className="user-dropdown-avatar-inner"
|
||||||
|
profile={userProfile}
|
||||||
|
label={displayName}
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span className="user-dropdown-identity">
|
<span className="user-dropdown-identity">
|
||||||
<strong>{displayName}</strong>
|
<strong>{displayName}</strong>
|
||||||
|
|||||||
+24
-1
@@ -11724,12 +11724,35 @@ body::before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.topbar-user-avatar img,
|
.topbar-user-avatar img,
|
||||||
.user-dropdown-avatar img {
|
.user-dropdown-avatar img,
|
||||||
|
.topbar-user-avatar-inner,
|
||||||
|
.user-dropdown-avatar-inner {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topbar-avatar,
|
||||||
|
.topbar-user-avatar-inner,
|
||||||
|
.user-dropdown-avatar-inner {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
text-transform: uppercase;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-user-avatar-inner {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown-avatar-inner {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
.topbar-user-copy {
|
.topbar-user-copy {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -6,9 +6,6 @@
|
|||||||
<meta name="description" content="BossAI 聚合多模型多模态的 AI 应用平台" />
|
<meta name="description" content="BossAI 聚合多模型多模态的 AI 应用平台" />
|
||||||
<title>BoosAPI</title>
|
<title>BoosAPI</title>
|
||||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen">
|
<body class="min-h-screen">
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -15,6 +15,15 @@ const SiteTrendChart = lazy(
|
|||||||
() => import("../components/charts/SiteTrendChart.js"),
|
() => import("../components/charts/SiteTrendChart.js"),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function scheduleDashboardIdleWork(callback: () => void, timeout = 1600): () => void {
|
||||||
|
if (typeof requestIdleCallback === "function") {
|
||||||
|
const idleId = requestIdleCallback(callback, { timeout });
|
||||||
|
return () => cancelIdleCallback(idleId);
|
||||||
|
}
|
||||||
|
const timer = globalThis.setTimeout(callback, Math.min(timeout, 450));
|
||||||
|
return () => globalThis.clearTimeout(timer);
|
||||||
|
}
|
||||||
|
|
||||||
function getGreeting(): string {
|
function getGreeting(): string {
|
||||||
const hour = new Date().getHours();
|
const hour = new Date().getHours();
|
||||||
if (hour < 6) return "夜深了";
|
if (hour < 6) return "夜深了";
|
||||||
@@ -233,6 +242,7 @@ export default function Dashboard({
|
|||||||
const [siteTrend, setSiteTrend] = useState<any[]>([]);
|
const [siteTrend, setSiteTrend] = useState<any[]>([]);
|
||||||
const [siteLoading, setSiteLoading] = useState(true);
|
const [siteLoading, setSiteLoading] = useState(true);
|
||||||
const [sites, setSites] = useState<any[]>([]);
|
const [sites, setSites] = useState<any[]>([]);
|
||||||
|
const [chartsReady, setChartsReady] = useState(false);
|
||||||
const [siteSpeedStates, setSiteSpeedStates] = useState<
|
const [siteSpeedStates, setSiteSpeedStates] = useState<
|
||||||
Record<string, SiteSpeedState>
|
Record<string, SiteSpeedState>
|
||||||
>({});
|
>({});
|
||||||
@@ -318,6 +328,8 @@ export default function Dashboard({
|
|||||||
loadSiteStats();
|
loadSiteStats();
|
||||||
}, [loadSiteStats]);
|
}, [loadSiteStats]);
|
||||||
|
|
||||||
|
useEffect(() => scheduleDashboardIdleWork(() => setChartsReady(true)), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let timer: ReturnType<typeof setInterval> | null = null;
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
@@ -1011,17 +1023,25 @@ export default function Dashboard({
|
|||||||
|
|
||||||
<div className="dashboard-chart-grid">
|
<div className="dashboard-chart-grid">
|
||||||
<div className="chart-panel-enter animate-slide-up stagger-6">
|
<div className="chart-panel-enter animate-slide-up stagger-6">
|
||||||
<Suspense fallback={<ChartFallback height={320} />}>
|
{chartsReady ? (
|
||||||
<SiteDistributionChart
|
<Suspense fallback={<ChartFallback height={320} />}>
|
||||||
data={siteDistribution}
|
<SiteDistributionChart
|
||||||
loading={siteLoading}
|
data={siteDistribution}
|
||||||
/>
|
loading={siteLoading}
|
||||||
</Suspense>
|
/>
|
||||||
|
</Suspense>
|
||||||
|
) : (
|
||||||
|
<ChartFallback height={320} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="chart-panel-enter animate-slide-up stagger-7">
|
<div className="chart-panel-enter animate-slide-up stagger-7">
|
||||||
<Suspense fallback={<ChartFallback height={320} />}>
|
{chartsReady ? (
|
||||||
<SiteTrendChart data={siteTrend} loading={siteLoading} />
|
<Suspense fallback={<ChartFallback height={320} />}>
|
||||||
</Suspense>
|
<SiteTrendChart data={siteTrend} loading={siteLoading} />
|
||||||
|
</Suspense>
|
||||||
|
) : (
|
||||||
|
<ChartFallback height={320} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1266,6 +1286,8 @@ export default function Dashboard({
|
|||||||
</div>
|
</div>
|
||||||
{insightsLoading && !insightsData ? (
|
{insightsLoading && !insightsData ? (
|
||||||
<ChartFallback height={260} />
|
<ChartFallback height={260} />
|
||||||
|
) : !chartsReady ? (
|
||||||
|
<ChartFallback height={260} />
|
||||||
) : (
|
) : (
|
||||||
<Suspense fallback={<ChartFallback height={260} />}>
|
<Suspense fallback={<ChartFallback height={260} />}>
|
||||||
<ModelAnalysisPanel data={insightsData?.modelAnalysis} />
|
<ModelAnalysisPanel data={insightsData?.modelAnalysis} />
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { marked } from 'marked';
|
|
||||||
import { formatDateTimeLocal } from './checkinLogTime.js';
|
import { formatDateTimeLocal } from './checkinLogTime.js';
|
||||||
|
|
||||||
const VOID_TAGS = new Set(['br', 'hr', 'img']);
|
const VOID_TAGS = new Set(['br', 'hr', 'img']);
|
||||||
@@ -367,8 +366,7 @@ export function renderSiteAnnouncementHtml(content: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isLikelyMarkdown(raw)) {
|
if (isLikelyMarkdown(raw)) {
|
||||||
const rendered = String(marked.parse(raw, { gfm: true, breaks: true }));
|
return sanitizeAnnouncementHtml(renderMarkdown(raw));
|
||||||
return sanitizeAnnouncementHtml(rendered || renderMarkdown(raw));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return renderPlainText(raw);
|
return renderPlainText(raw);
|
||||||
|
|||||||
Reference in New Issue
Block a user