perf: speed up app loading
This commit is contained in:
+83
-48
@@ -68,21 +68,17 @@ type UserAnnouncementPopupRow = {
|
||||
publishedAt?: string | null;
|
||||
firstSeenAt?: string | null;
|
||||
};
|
||||
const DICEBEAR_STYLES = [
|
||||
'pixel-art',
|
||||
'pixel-art-neutral',
|
||||
'bottts',
|
||||
'bottts-neutral',
|
||||
'identicon',
|
||||
'initials',
|
||||
'avataaars',
|
||||
'avataaars-neutral',
|
||||
'personas',
|
||||
'lorelei',
|
||||
'lorelei-neutral',
|
||||
'fun-emoji',
|
||||
const AVATAR_STYLES = [
|
||||
'aurora',
|
||||
'skyline',
|
||||
'lotus',
|
||||
'ember',
|
||||
'mint',
|
||||
'violet',
|
||||
'graphite',
|
||||
'cobalt',
|
||||
] as const;
|
||||
type DicebearStyle = typeof DICEBEAR_STYLES[number];
|
||||
type AvatarStyle = typeof AVATAR_STYLES[number];
|
||||
function resolveStoredThemeMode(): ThemeMode {
|
||||
const saved = localStorage.getItem(THEME_MODE_STORAGE_KEY);
|
||||
if (saved === 'system' || saved === 'light' || saved === 'dark') return saved;
|
||||
@@ -104,21 +100,30 @@ function hashString(input: string): number {
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
function pickDicebearStyle(seed: string): DicebearStyle {
|
||||
const index = hashString(seed || 'default') % DICEBEAR_STYLES.length;
|
||||
return DICEBEAR_STYLES[index];
|
||||
function pickAvatarStyle(seed: string): AvatarStyle {
|
||||
const index = hashString(seed || 'default') % AVATAR_STYLES.length;
|
||||
return AVATAR_STYLES[index];
|
||||
}
|
||||
function buildDicebearAvatarUrl(style: string, seed: string): string {
|
||||
const safeStyle = DICEBEAR_STYLES.includes(style as DicebearStyle)
|
||||
? style
|
||||
: pickDicebearStyle(seed);
|
||||
const safeSeed = (seed || 'default').trim() || 'default';
|
||||
return `https://api.dicebear.com/9.x/${safeStyle}/svg?seed=${encodeURIComponent(safeSeed)}`;
|
||||
function resolveAvatarStyle(style: string, seed: string): AvatarStyle {
|
||||
return AVATAR_STYLES.includes(style as AvatarStyle)
|
||||
? (style as AvatarStyle)
|
||||
: pickAvatarStyle(seed);
|
||||
}
|
||||
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 {
|
||||
const avatarSeed = createRandomAvatarSeed();
|
||||
return { name, avatarSeed, avatarStyle: pickDicebearStyle(avatarSeed) };
|
||||
return { name, avatarSeed, avatarStyle: pickAvatarStyle(avatarSeed) };
|
||||
}
|
||||
|
||||
function parseStoredProfile(raw: string | null, fallbackName: string): UserProfile | null {
|
||||
@@ -136,9 +141,9 @@ function parseStoredProfile(raw: string | null, fallbackName: string): UserProfi
|
||||
return {
|
||||
name: name || fallbackName,
|
||||
avatarSeed: resolvedSeed,
|
||||
avatarStyle: DICEBEAR_STYLES.includes(avatarStyle as DicebearStyle)
|
||||
avatarStyle: AVATAR_STYLES.includes(avatarStyle as AvatarStyle)
|
||||
? avatarStyle
|
||||
: pickDicebearStyle(resolvedSeed),
|
||||
: pickAvatarStyle(resolvedSeed),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
@@ -171,16 +176,39 @@ function profileStorageKey(audience: 'admin' | 'user'): string {
|
||||
function profileFromUsername(username: string, current?: UserProfile, fallbackName = '用户'): UserProfile {
|
||||
const name = username.trim();
|
||||
const avatarSeed = current?.avatarSeed?.trim() || createRandomAvatarSeed();
|
||||
const avatarStyle = current?.avatarStyle?.trim() || pickDicebearStyle(avatarSeed);
|
||||
const avatarStyle = current?.avatarStyle?.trim() || pickAvatarStyle(avatarSeed);
|
||||
return {
|
||||
name: name || current?.name || fallbackName,
|
||||
avatarSeed,
|
||||
avatarStyle: DICEBEAR_STYLES.includes(avatarStyle as DicebearStyle)
|
||||
avatarStyle: AVATAR_STYLES.includes(avatarStyle as 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({
|
||||
open,
|
||||
profile,
|
||||
@@ -205,7 +233,6 @@ function UserProfileModal({
|
||||
setAvatarStyle(profile.avatarStyle);
|
||||
setError('');
|
||||
}, [open, profile]);
|
||||
const avatarUrl = buildDicebearAvatarUrl(avatarStyle, avatarSeed);
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
@@ -219,7 +246,7 @@ function UserProfileModal({
|
||||
const handleRandomAvatar = () => {
|
||||
const nextSeed = createRandomAvatarSeed();
|
||||
setAvatarSeed(nextSeed);
|
||||
setAvatarStyle(pickDicebearStyle(nextSeed));
|
||||
setAvatarStyle(pickAvatarStyle(nextSeed));
|
||||
};
|
||||
const handleSubmit = () => {
|
||||
const normalizedName = name.trim();
|
||||
@@ -234,11 +261,16 @@ function UserProfileModal({
|
||||
onSave({
|
||||
name: normalizedName,
|
||||
avatarSeed: avatarSeed.trim() || createRandomAvatarSeed(),
|
||||
avatarStyle: DICEBEAR_STYLES.includes(avatarStyle as DicebearStyle)
|
||||
avatarStyle: AVATAR_STYLES.includes(avatarStyle as AvatarStyle)
|
||||
? avatarStyle
|
||||
: pickDicebearStyle(avatarSeed),
|
||||
: pickAvatarStyle(avatarSeed),
|
||||
});
|
||||
};
|
||||
const previewProfile: UserProfile = {
|
||||
name: name.trim() || profile.name,
|
||||
avatarSeed,
|
||||
avatarStyle,
|
||||
};
|
||||
return (
|
||||
<CenteredModal
|
||||
open={open}
|
||||
@@ -256,13 +288,12 @@ function UserProfileModal({
|
||||
)}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 2 }}>
|
||||
<div className="topbar-avatar" style={{ width: 40, height: 40, fontSize: 14 }}>
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={name.trim() || 'avatar'}
|
||||
style={{ width: '100%', height: '100%', borderRadius: '50%', objectFit: 'cover' }}
|
||||
/>
|
||||
</div>
|
||||
<LocalAvatar
|
||||
className="topbar-avatar"
|
||||
profile={previewProfile}
|
||||
label={previewProfile.name}
|
||||
style={{ width: 40, height: 40, fontSize: 14 }}
|
||||
/>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{t('右上角头像实时预览')}</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -279,7 +310,7 @@ function UserProfileModal({
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>
|
||||
{t('头像(Dicebear 随机) · 风格:')}{avatarStyle}
|
||||
{t('头像 · 风格:')}{resolveAvatarStyle(avatarStyle, avatarSeed)}
|
||||
</div>
|
||||
<button type="button" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={handleRandomAvatar}>
|
||||
{t('换一个随机头像')}
|
||||
@@ -494,7 +525,6 @@ function AppShell() {
|
||||
const accountRoleLabel = profileAudience === 'admin' ? t('管理员') : t('普通用户');
|
||||
const displayName = rawDisplayName ? (rawDisplayName === '管理员' ? t('管理员') : rawDisplayName) : accountRoleLabel;
|
||||
const resolvedThemeLabel = resolvedTheme === 'dark' ? t('深色') : t('浅色');
|
||||
const avatarUrl = buildDicebearAvatarUrl(userProfile.avatarStyle, userProfile.avatarSeed);
|
||||
useEffect(() => {
|
||||
const root = topbarRef.current;
|
||||
if (!root) return undefined;
|
||||
@@ -709,9 +739,9 @@ function AppShell() {
|
||||
const normalized = {
|
||||
name: nextProfile.name.trim() || t(profileAudience === 'admin' ? '管理员' : '用户'),
|
||||
avatarSeed: normalizedSeed,
|
||||
avatarStyle: DICEBEAR_STYLES.includes(nextProfile.avatarStyle as DicebearStyle)
|
||||
avatarStyle: AVATAR_STYLES.includes(nextProfile.avatarStyle as AvatarStyle)
|
||||
? nextProfile.avatarStyle
|
||||
: pickDicebearStyle(normalizedSeed),
|
||||
: pickAvatarStyle(normalizedSeed),
|
||||
};
|
||||
setUserProfile(normalized);
|
||||
localStorage.setItem(profileStorageKey(profileAudience), JSON.stringify(normalized));
|
||||
@@ -914,9 +944,10 @@ function AppShell() {
|
||||
}}
|
||||
>
|
||||
<span className="topbar-user-avatar">
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={displayName}
|
||||
<LocalAvatar
|
||||
className="topbar-user-avatar-inner"
|
||||
profile={userProfile}
|
||||
label={displayName}
|
||||
/>
|
||||
</span>
|
||||
<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-header">
|
||||
<span className="user-dropdown-avatar">
|
||||
<img src={avatarUrl} alt={displayName} />
|
||||
<LocalAvatar
|
||||
className="user-dropdown-avatar-inner"
|
||||
profile={userProfile}
|
||||
label={displayName}
|
||||
/>
|
||||
</span>
|
||||
<span className="user-dropdown-identity">
|
||||
<strong>{displayName}</strong>
|
||||
|
||||
+24
-1
@@ -11724,12 +11724,35 @@ body::before {
|
||||
}
|
||||
|
||||
.topbar-user-avatar img,
|
||||
.user-dropdown-avatar img {
|
||||
.user-dropdown-avatar img,
|
||||
.topbar-user-avatar-inner,
|
||||
.user-dropdown-avatar-inner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
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 {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
|
||||
@@ -6,9 +6,6 @@
|
||||
<meta name="description" content="BossAI 聚合多模型多模态的 AI 应用平台" />
|
||||
<title>BoosAPI</title>
|
||||
<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>
|
||||
<body class="min-h-screen">
|
||||
<script>
|
||||
|
||||
@@ -15,6 +15,15 @@ const SiteTrendChart = lazy(
|
||||
() => 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 {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 6) return "夜深了";
|
||||
@@ -233,6 +242,7 @@ export default function Dashboard({
|
||||
const [siteTrend, setSiteTrend] = useState<any[]>([]);
|
||||
const [siteLoading, setSiteLoading] = useState(true);
|
||||
const [sites, setSites] = useState<any[]>([]);
|
||||
const [chartsReady, setChartsReady] = useState(false);
|
||||
const [siteSpeedStates, setSiteSpeedStates] = useState<
|
||||
Record<string, SiteSpeedState>
|
||||
>({});
|
||||
@@ -318,6 +328,8 @@ export default function Dashboard({
|
||||
loadSiteStats();
|
||||
}, [loadSiteStats]);
|
||||
|
||||
useEffect(() => scheduleDashboardIdleWork(() => setChartsReady(true)), []);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let disposed = false;
|
||||
@@ -1011,17 +1023,25 @@ export default function Dashboard({
|
||||
|
||||
<div className="dashboard-chart-grid">
|
||||
<div className="chart-panel-enter animate-slide-up stagger-6">
|
||||
<Suspense fallback={<ChartFallback height={320} />}>
|
||||
<SiteDistributionChart
|
||||
data={siteDistribution}
|
||||
loading={siteLoading}
|
||||
/>
|
||||
</Suspense>
|
||||
{chartsReady ? (
|
||||
<Suspense fallback={<ChartFallback height={320} />}>
|
||||
<SiteDistributionChart
|
||||
data={siteDistribution}
|
||||
loading={siteLoading}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
<ChartFallback height={320} />
|
||||
)}
|
||||
</div>
|
||||
<div className="chart-panel-enter animate-slide-up stagger-7">
|
||||
<Suspense fallback={<ChartFallback height={320} />}>
|
||||
<SiteTrendChart data={siteTrend} loading={siteLoading} />
|
||||
</Suspense>
|
||||
{chartsReady ? (
|
||||
<Suspense fallback={<ChartFallback height={320} />}>
|
||||
<SiteTrendChart data={siteTrend} loading={siteLoading} />
|
||||
</Suspense>
|
||||
) : (
|
||||
<ChartFallback height={320} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1266,6 +1286,8 @@ export default function Dashboard({
|
||||
</div>
|
||||
{insightsLoading && !insightsData ? (
|
||||
<ChartFallback height={260} />
|
||||
) : !chartsReady ? (
|
||||
<ChartFallback height={260} />
|
||||
) : (
|
||||
<Suspense fallback={<ChartFallback height={260} />}>
|
||||
<ModelAnalysisPanel data={insightsData?.modelAnalysis} />
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { marked } from 'marked';
|
||||
import { formatDateTimeLocal } from './checkinLogTime.js';
|
||||
|
||||
const VOID_TAGS = new Set(['br', 'hr', 'img']);
|
||||
@@ -367,8 +366,7 @@ export function renderSiteAnnouncementHtml(content: string): string {
|
||||
}
|
||||
|
||||
if (isLikelyMarkdown(raw)) {
|
||||
const rendered = String(marked.parse(raw, { gfm: true, breaks: true }));
|
||||
return sanitizeAnnouncementHtml(rendered || renderMarkdown(raw));
|
||||
return sanitizeAnnouncementHtml(renderMarkdown(raw));
|
||||
}
|
||||
|
||||
return renderPlainText(raw);
|
||||
|
||||
Reference in New Issue
Block a user