perf: optimize web data resources

This commit is contained in:
ZL-Q
2026-05-10 20:01:14 +08:00
parent a9739cddce
commit 1e4871e337
24 changed files with 1304 additions and 252 deletions
+22 -3
View File
@@ -2,7 +2,9 @@ import { useState, useEffect, createContext, useContext, type ReactNode } from '
import { useLocation, useNavigate } from 'react-router-dom';
import Icon from './Icon';
import { getAuth, refreshAccessToken, redirectToLogin, backendLanguageToLocale, type AuthUser } from '../lib/auth';
import { getUserProfile, type UserProfile } from '../lib/api';
import type { UserProfile } from '../lib/api';
import { getCachedProfile, getProfileResource, prefetchAppBasics, prefetchForPath, profileKey } from '../lib/resources';
import { subscribe } from '../lib/data-client';
// User settings context
interface UserSettingsContextValue {
@@ -80,11 +82,12 @@ export default function AppShell({ locale, brandName, navItems, userName, userEm
refreshAccessToken()
.then((data) => {
if (alive) setAuthUser(data.user);
return getUserProfile();
return getProfileResource();
})
.then((profile) => {
if (!alive) return;
setUserProfile(profile);
prefetchAppBasics();
// Check if URL locale matches user's language preference
const userLocale = backendLanguageToLocale(profile.settings?.preferences?.language || 'zh-CN');
@@ -107,6 +110,14 @@ export default function AppShell({ locale, brandName, navItems, userName, userEm
};
}, [locale]);
useEffect(() => {
const cached = getCachedProfile();
if (cached) setUserProfile(cached);
return subscribe(profileKey, () => {
setUserProfile(getCachedProfile() ?? null);
});
}, []);
useEffect(() => {
if (activeNav === 'manual' || activeNav === 'auto') setExpandedNav('divination');
}, [activeNav]);
@@ -116,6 +127,10 @@ export default function AppShell({ locale, brandName, navItems, userName, userEm
routerNavigate(href);
};
const prefetchNav = (href: string) => {
prefetchForPath(href, locale);
};
if (checkingAuth || authUser === null) {
return (
<div className="min-h-screen bg-slate-50 flex items-center justify-center px-6">
@@ -125,7 +140,7 @@ export default function AppShell({ locale, brandName, navItems, userName, userEm
}
const shellUserName = userName || userProfile?.display_name || authUser?.email?.split('@')[0] || '';
const shellUserEmail = userEmail || userProfile?.email || authUser?.email || '';
const shellUserEmail = userEmail || authUser?.email || '';
const shellAvatarUrl = userProfile?.avatar_url;
return (
@@ -169,6 +184,8 @@ export default function AppShell({ locale, brandName, navItems, userName, userEm
</button>
{!sidebarCollapsed && isExpanded && item.sub.map((sub) => (
<a key={sub.id} href={sub.href}
onMouseEnter={() => prefetchNav(sub.href)}
onFocus={() => prefetchNav(sub.href)}
onClick={(e) => { e.preventDefault(); navigate(sub.href); setSidebarOpen(false); }}
className={`flex items-center gap-2 pl-8 pr-2.5 py-2.5 rounded-md text-sm transition-colors border ${activeNav === sub.id ? 'bg-[#F0E6FF] border-violet-600 text-violet-700 font-bold' : 'border-transparent text-slate-500 hover:bg-slate-50'}`}>
<span className="text-xs">{activeNav === sub.id ? '●' : '○'}</span>{sub.label}
@@ -183,6 +200,8 @@ export default function AppShell({ locale, brandName, navItems, userName, userEm
if (item.href) {
return (
<a key={item.id} href={item.href}
onMouseEnter={() => prefetchNav(item.href)}
onFocus={() => prefetchNav(item.href)}
onClick={(e) => { e.preventDefault(); navigate(item.href); setSidebarOpen(false); }}
title={sidebarCollapsed ? item.label : undefined}
className={`flex items-center gap-3 px-2.5 py-2.5 rounded-lg text-sm transition-colors ${sidebarCollapsed ? 'md:justify-center' : ''} ${isActive ? 'bg-violet-700 text-white' : 'text-slate-500 hover:bg-slate-50'}`}>
+7 -9
View File
@@ -2,7 +2,8 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import Icon from './Icon';
import DivinationProcessingOverlay from './DivinationProcessingOverlay';
import { getPointsBalance, getUserProfile, updateUserSettings, type PointsBalance, type DivinationResultData, type YaoType } from '../lib/api';
import { type DivinationResultData, type YaoType } from '../lib/api';
import { updateSettingsResource, usePoints } from '../lib/resources';
import { useUserSettings } from './AppShell';
interface Props {
@@ -180,12 +181,13 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
const text = copy[locale as keyof typeof copy] ?? copy.zh;
const cats = useMemo(() => d.categories.split(','), [d.categories]);
const navigate = useNavigate();
const [category, setCategory] = useState(cats[0]);
const [question, setQuestion] = useState(text.defaultQuestion);
const [category, setCategory] = useState<string>(cats[0]);
const [question, setQuestion] = useState<string>(text.defaultQuestion);
const [selectedTime, setSelectedTime] = useState(() => formatDateTimeInput(new Date()));
const [yaoResults, setYaoResults] = useState<YaoType[]>([]);
const [guideStep, setGuideStep] = useState<number | null>(null);
const [points, setPoints] = useState<PointsBalance | null>(null);
const pointsState = usePoints();
const points = pointsState.data ?? null;
const [showProcessing, setShowProcessing] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const { userProfile, setUserProfile } = useUserSettings();
@@ -237,7 +239,7 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
},
};
try {
const updated = await updateUserSettings({ settings: updatedSettings });
const updated = await updateSettingsResource(updatedSettings);
setUserProfile(updated);
} catch {
// Silently fail
@@ -364,10 +366,6 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
setCategory(cats[0]);
}, [cats]);
useEffect(() => {
getPointsBalance().then(setPoints).catch(() => {});
}, []);
useEffect(() => {
const handleResize = () => setIsMobile(window.innerWidth < 1280);
window.addEventListener('resize', handleResize);
+11 -38
View File
@@ -1,7 +1,5 @@
import { useState, useEffect } from 'react';
import { getPointsBalance, type PointsBalance } from '../lib/api';
import { getUnreadNotificationCount, type UnreadCount } from '../lib/api';
import { getAgentHistory, mapHistoryMessagesToItems, type HistoryItem } from '../lib/api';
import { mapHistoryMessagesToItems } from '../lib/api';
import { useHistorySummary, usePoints, useUnreadCount } from '../lib/resources';
import Icon from './Icon';
interface DashboardProps {
@@ -53,40 +51,15 @@ const RATING_COLORS: Record<string, string> = {
};
export default function Dashboard({ locale, translations: i18n }: DashboardProps) {
const [points, setPoints] = useState<PointsBalance | null>(null);
const [unreadCount, setUnreadCount] = useState<UnreadCount | null>(null);
const [history, setHistory] = useState<HistoryItem[]>([]);
const [loadingData, setLoadingData] = useState(true);
const [loadError, setLoadError] = useState('');
useEffect(() => {
let alive = true;
setLoadingData(true);
setLoadError('');
Promise.all([
getPointsBalance(),
getUnreadNotificationCount(),
getAgentHistory(),
])
.then(([nextPoints, nextUnreadCount, nextHistory]) => {
if (!alive) return;
setPoints(nextPoints);
setUnreadCount(nextUnreadCount);
setHistory(mapHistoryMessagesToItems(nextHistory.messages).slice(0, 4));
})
.catch((error: unknown) => {
if (!alive) return;
setLoadError(error instanceof Error ? error.message : 'Failed to load dashboard data');
})
.finally(() => {
if (alive) setLoadingData(false);
});
return () => {
alive = false;
};
}, []);
const pointsState = usePoints();
const unreadState = useUnreadCount();
const historyState = useHistorySummary();
const points = pointsState.data;
const unreadCount = unreadState.data;
const history = historyState.data ? mapHistoryMessagesToItems(historyState.data.messages).slice(0, 4) : [];
const loadingData = historyState.loading;
const loadErrorSource = pointsState.error || unreadState.error || historyState.error;
const loadError = loadErrorSource instanceof Error ? loadErrorSource.message : loadErrorSource ? 'Failed to load dashboard data' : '';
const unreadNum = unreadCount?.count ?? 0;
const availablePoints = points?.availableBalance;
@@ -14,6 +14,7 @@ import {
type YaoType,
type DivinationResultData,
} from '../lib/api';
import { invalidateHistory, invalidatePoints } from '../lib/resources';
// 八卦卡片数据 - 使用 Flutter 的文本
const I_CHING_CARDS = {
@@ -154,6 +155,7 @@ export default function DivinationProcessingOverlay({
try {
// 1. 提交起卦请求
const { threadId, runId } = await enqueueDivinationRun(params, yaoStates);
invalidatePoints();
if (aborted) return;
@@ -173,7 +175,7 @@ export default function DivinationProcessingOverlay({
let conclusion = '';
let focusPoints: string[] = [];
let advice: string[] = [];
let keywords: string[] = [];
let keywords = '';
let answer = '';
let status: 'success' | 'failed' | 'refused' = 'success';
@@ -253,6 +255,8 @@ export default function DivinationProcessingOverlay({
setResult(result);
}
setStep('done');
invalidatePoints();
invalidateHistory(threadId);
break;
}
}
+14 -16
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { getAgentHistoryByThread, historyMessageToResultData, type DivinationResultData, type YaoType } from '../lib/api';
import { historyMessageToResultData, type DivinationResultData, type YaoType } from '../lib/api';
import { useHistoryThread } from '../lib/resources';
import Icon from './Icon';
interface Props {
@@ -339,13 +340,12 @@ export default function DivinationResultPage({ locale, translations: t }: Props)
const location = useLocation();
const navigate = useNavigate();
const { id: threadId } = useParams<{ id: string }>();
const threadState = useHistoryThread(threadId);
const [data, setData] = useState<DivinationResultData | null>(null);
const [loading, setLoading] = useState(true);
const [canFollowUp, setCanFollowUp] = useState(true);
useEffect(() => {
let alive = true;
// 1. Try router state (from divination flow)
const state = location.state as { result?: DivinationResultData } | null;
if (state?.result) {
@@ -369,24 +369,22 @@ export default function DivinationResultPage({ locale, translations: t }: Props)
// 3. Fetch by threadId (from history flow)
if (threadId) {
setLoading(true);
getAgentHistoryByThread(threadId)
.then((snapshot) => {
if (!alive) return;
const assistantMsg = snapshot.messages.find((m) => m.role === 'assistant');
if (!assistantMsg) return;
if (threadState.data) {
const assistantMsg = threadState.data.messages.find((m) => m.role === 'assistant');
if (assistantMsg) {
const resultData = historyMessageToResultData(assistantMsg);
if (resultData) setData(resultData);
const userCount = snapshot.messages.filter((m) => m.role === 'user').length;
setCanFollowUp(userCount < 2);
})
.catch(() => { /* ignore */ })
.finally(() => { if (alive) setLoading(false); });
}
const userCount = threadState.data.messages.filter((m) => m.role === 'user').length;
setCanFollowUp(userCount < 2);
setLoading(false);
} else if (!threadState.loading) {
setLoading(false);
}
} else {
setLoading(false);
}
return () => { alive = false; };
}, [location.state, threadId]);
}, [location.state, threadId, threadState.data, threadState.loading]);
// Redirect if no data and not loading
useEffect(() => {
+8 -20
View File
@@ -1,7 +1,8 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { getUserProfile, updateUserSettings, type UserProfile, type ProfileSettings } from '../lib/api';
import { localeToBackendLanguage, backendLanguageToLocale } from '../lib/auth';
import type { ProfileSettings } from '../lib/api';
import { backendLanguageToLocale } from '../lib/auth';
import { updateSettingsResource, useProfile } from '../lib/resources';
interface Props {
locale: string;
@@ -40,9 +41,8 @@ function getDefaultSettings(): ProfileSettings {
export default function GeneralSettingsPage({ locale, general: g }: Props) {
const navigate = useNavigate();
const [profile, setProfile] = useState<UserProfile | null>(null);
const profileState = useProfile();
const [settings, setSettings] = useState<ProfileSettings>(getDefaultSettings());
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [showLanguageModal, setShowLanguageModal] = useState(false);
const [toast, setToast] = useState<{ type: 'error'; message: string } | null>(null);
@@ -52,26 +52,14 @@ export default function GeneralSettingsPage({ locale, general: g }: Props) {
const allowNotifications = settings.notification.allow_notifications;
useEffect(() => {
setLoading(true);
getUserProfile()
.then((data) => {
setProfile(data);
if (data.settings) {
setSettings(data.settings);
}
})
.catch(() => {
// Use defaults
})
.finally(() => setLoading(false));
}, []);
if (profileState.data?.settings) setSettings(profileState.data.settings);
}, [profileState.data]);
const saveSettings = async (newSettings: ProfileSettings): Promise<boolean> => {
setSaving(true);
try {
const updated = await updateUserSettings({ settings: newSettings });
const updated = await updateSettingsResource(newSettings);
setSettings(updated.settings);
setProfile(updated);
return true;
} catch {
return false;
@@ -135,7 +123,7 @@ export default function GeneralSettingsPage({ locale, general: g }: Props) {
const currentLangLabel = languageOptions.find(l => l.tag === selectedLanguage)?.label || selectedLanguage;
if (loading) {
if (profileState.loading) {
return (
<div className="flex flex-col gap-6 min-h-full">
<div className="text-slate-500">{locale === 'en' ? 'Loading...' : '加载中...'}</div>
+7 -4
View File
@@ -1,12 +1,12 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import {
getAgentHistoryByThread,
historyMessageToResultData,
enqueueFollowUpRun,
streamDivinationEvents,
type DivinationResultData,
} from '../lib/api';
import { getHistoryThreadResource, invalidateHistory, invalidatePoints } from '../lib/resources';
import Icon from './Icon';
interface Props {
@@ -91,7 +91,7 @@ export default function HistoryFollowUpPage({ locale, history: h }: Props) {
}
try {
const snapshot = await getAgentHistoryByThread(threadId);
const snapshot = await getHistoryThreadResource(threadId);
if (!alive) return;
// Extract result data from first assistant message
@@ -162,6 +162,7 @@ export default function HistoryFollowUpPage({ locale, history: h }: Props) {
try {
const { runId } = await enqueueFollowUpRun(threadId, text, resultData);
invalidatePoints();
let answer = '';
for await (const event of streamDivinationEvents(threadId, runId)) {
@@ -182,7 +183,9 @@ export default function HistoryFollowUpPage({ locale, history: h }: Props) {
);
// Reload history to get server-side message IDs
const snapshot = await getAgentHistoryByThread(threadId);
invalidateHistory(threadId);
invalidatePoints();
const snapshot = await getHistoryThreadResource(threadId, true);
const chatMessages: ChatMessage[] = snapshot.messages
.filter((m) => m.role === 'user' || m.role === 'assistant')
.map((m) => ({
@@ -195,7 +198,7 @@ export default function HistoryFollowUpPage({ locale, history: h }: Props) {
setError(err instanceof Error ? err.message : 'Failed to send follow-up');
// Reload history to restore correct state
try {
const snapshot = await getAgentHistoryByThread(threadId);
const snapshot = await getHistoryThreadResource(threadId, true);
const chatMessages: ChatMessage[] = snapshot.messages
.filter((m) => m.role === 'user' || m.role === 'assistant')
.map((m) => ({
+7 -29
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useMemo } from 'react';
import { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { getAgentHistory, mapHistoryMessagesToItems, type HistoryItem } from '../lib/api';
import { mapHistoryMessagesToItems, type HistoryItem } from '../lib/api';
import { useHistoryList } from '../lib/resources';
import Icon from './Icon';
interface Props {
@@ -58,36 +59,13 @@ const RATING_COLORS: Record<string, string> = {
export default function HistoryListPage({ locale, history: i18n }: Props) {
const navigate = useNavigate();
const [allItems, setAllItems] = useState<HistoryItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const historyState = useHistoryList();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [activeFilter, setActiveFilter] = useState<CategoryKey | 'all'>('all');
const [searchQuery, setSearchQuery] = useState('');
// 获取历史数据
useEffect(() => {
let alive = true;
setLoading(true);
setError('');
getAgentHistory()
.then((data) => {
if (!alive) return;
setAllItems(mapHistoryMessagesToItems(data.messages));
})
.catch((err) => {
if (!alive) return;
setError(err instanceof Error ? err.message : 'Failed to load history');
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, []);
const allItems = useMemo(() => historyState.data ? mapHistoryMessagesToItems(historyState.data.messages) : [], [historyState.data]);
const loading = historyState.loading;
const error = historyState.error instanceof Error ? historyState.error.message : historyState.error ? 'Failed to load history' : '';
const stats = useMemo(() => {
const total = allItems.length;
+7 -9
View File
@@ -2,7 +2,8 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import Icon from './Icon';
import DivinationProcessingOverlay from './DivinationProcessingOverlay';
import { getPointsBalance, getUserProfile, updateUserSettings, type PointsBalance, type DivinationResultData } from '../lib/api';
import type { DivinationResultData } from '../lib/api';
import { updateSettingsResource, usePoints } from '../lib/resources';
import { useUserSettings } from './AppShell';
interface Props {
@@ -192,13 +193,14 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
const text = copy[locale as keyof typeof copy] ?? copy.zh;
const cats = useMemo(() => d.categories.split(','), [d.categories]);
const navigate = useNavigate();
const [category, setCategory] = useState(cats[0]);
const [question, setQuestion] = useState(text.defaultQuestion);
const [category, setCategory] = useState<string>(cats[0]);
const [question, setQuestion] = useState<string>(text.defaultQuestion);
const [selectedTime, setSelectedTime] = useState(() => formatDateTimeInput(new Date()));
const [coins, setCoins] = useState<[CoinFace, CoinFace, CoinFace]>(['zi', 'zi', 'zi']);
const [yaoResults, setYaoResults] = useState<YaoType[]>([]);
const [guideStep, setGuideStep] = useState<number | null>(null);
const [points, setPoints] = useState<PointsBalance | null>(null);
const pointsState = usePoints();
const points = pointsState.data ?? null;
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [showProcessing, setShowProcessing] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
@@ -249,7 +251,7 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
},
};
try {
const updated = await updateUserSettings({ settings: updatedSettings });
const updated = await updateSettingsResource(updatedSettings);
setUserProfile(updated);
} catch {
// Silently fail - tutorial shown state is non-critical
@@ -390,10 +392,6 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
setCategory(cats[0]);
}, [cats]);
useEffect(() => {
getPointsBalance().then(setPoints).catch(() => {});
}, []);
// Track mobile state on resize
useEffect(() => {
const handleResize = () => setIsMobile(window.innerWidth < 1280);
+8 -22
View File
@@ -1,6 +1,6 @@
import { useEffect, useState, useCallback } from 'react';
import { useEffect, useMemo, useState } from 'react';
import type { NotificationItem } from '../lib/api';
import { getNotifications, markNotificationRead, markAllNotificationsRead } from '../lib/api';
import { markAllNotificationsReadResource, markNotificationReadResource, useNotifications } from '../lib/resources';
interface Props {
locale: string;
@@ -52,25 +52,13 @@ function formatFullTime(dateStr: string, locale: string): string {
}
export default function NotificationPage({ locale, notifications: n }: Props) {
const [items, setItems] = useState<NotificationItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const notificationsState = useNotifications(locale);
const [selectedItem, setSelectedItem] = useState<NotificationItem | null>(null);
const [markingAll, setMarkingAll] = useState(false);
const [toast, setToast] = useState<string | null>(null);
const fetchNotifications = useCallback(() => {
setLoading(true);
setError(null);
getNotifications(locale)
.then((res) => setItems(res.items))
.catch((err) => setError(err.message || n.error))
.finally(() => setLoading(false));
}, [locale, n.error]);
useEffect(() => {
fetchNotifications();
}, [fetchNotifications]);
const items = useMemo(() => notificationsState.data?.items ?? [], [notificationsState.data]);
const loading = notificationsState.loading;
const error = notificationsState.error instanceof Error ? notificationsState.error.message : notificationsState.error ? n.error : null;
useEffect(() => {
if (toast) {
@@ -83,8 +71,7 @@ export default function NotificationPage({ locale, notifications: n }: Props) {
setSelectedItem(item);
if (!item.isRead) {
try {
const updated = await markNotificationRead(item.id, locale);
setItems((prev) => prev.map((i) => (i.id === item.id ? updated : i)));
await markNotificationReadResource(item.id, locale);
} catch {
// ignore mark read error
}
@@ -97,8 +84,7 @@ export default function NotificationPage({ locale, notifications: n }: Props) {
setMarkingAll(true);
try {
await markAllNotificationsRead();
setItems((prev) => prev.map((i) => ({ ...i, isRead: true })));
await markAllNotificationsReadResource(locale);
setToast(n.markAllReadDone);
} catch {
// ignore error
+14 -18
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { getUserProfile, updateUserProfile, uploadAvatar, type UserProfile } from '../lib/api';
import { getAuth } from '../lib/auth';
import { updateProfileResource, uploadAvatarResource, useProfile } from '../lib/resources';
interface Props {
locale: string;
@@ -57,28 +57,25 @@ async function compressImage(file: File, maxWidth = 512, maxHeight = 512, qualit
export default function ProfileDetailPage({ locale, profile: p }: Props) {
const navigate = useNavigate();
const [profile, setProfile] = useState<UserProfile | null>(null);
const profileState = useProfile();
const profile = profileState.data ?? null;
const [displayName, setDisplayName] = useState('');
const [bio, setBio] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Load profile on mount
useEffect(() => {
setLoading(true);
getUserProfile()
.then((data) => {
setProfile(data);
setDisplayName(data.display_name || '');
setBio(data.bio || '');
})
.catch((err) => setError(err.message || 'Failed to load profile'))
.finally(() => setLoading(false));
}, []);
if (!profileState.data) return;
setDisplayName(profileState.data.display_name || '');
setBio(profileState.data.bio || '');
}, [profileState.data]);
useEffect(() => {
if (profileState.error instanceof Error) setError(profileState.error.message || 'Failed to load profile');
}, [profileState.error]);
// Clear messages after 3 seconds
useEffect(() => {
@@ -92,7 +89,7 @@ export default function ProfileDetailPage({ locale, profile: p }: Props) {
setSaving(true);
setError(null);
try {
await updateUserProfile({
await updateProfileResource({
display_name: displayName || undefined,
bio: bio || undefined,
});
@@ -134,8 +131,7 @@ export default function ProfileDetailPage({ locale, profile: p }: Props) {
throw new Error(locale === 'en' ? 'Image too large, please choose a smaller one' : '图片太大,请选择更小的图片');
}
const updated = await uploadAvatar(compressedFile);
setProfile(updated);
await uploadAvatarResource(compressedFile);
setSuccess(locale === 'en' ? 'Avatar updated' : '头像已更新');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to upload');
@@ -146,7 +142,7 @@ export default function ProfileDetailPage({ locale, profile: p }: Props) {
}
};
if (loading) {
if (profileState.loading) {
return (
<div className="flex flex-col gap-6 min-h-full">
<div className="text-slate-500">{locale === 'en' ? 'Loading...' : '加载中...'}</div>
+8 -23
View File
@@ -1,6 +1,5 @@
import { useEffect, useState } from 'react';
import { logout, getAuth } from '../lib/auth';
import { getUserProfile, getPointsBalance, type UserProfile, type PointsBalance } from '../lib/api';
import { usePoints, useProfile } from '../lib/resources';
interface Props {
locale: string;
@@ -9,25 +8,11 @@ interface Props {
}
export default function SettingsPage({ locale, settings: s }: Props) {
const [profile, setProfile] = useState<UserProfile | null>(null);
const [points, setPoints] = useState<PointsBalance | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
Promise.all([
getUserProfile(),
getPointsBalance(),
])
.then(([profileData, pointsData]) => {
setProfile(profileData);
setPoints(pointsData);
})
.catch(() => {
// ignore errors
})
.finally(() => setLoading(false));
}, []);
const profileState = useProfile();
const pointsState = usePoints();
const profile = profileState.data ?? null;
const points = pointsState.data ?? null;
const loading = profileState.loading || pointsState.loading;
const handleLogout = () => {
if (confirm(s.logoutConfirm)) {
@@ -38,8 +23,8 @@ export default function SettingsPage({ locale, settings: s }: Props) {
};
const authEmail = getAuth()?.user?.email;
const displayName = loading ? '' : (profile?.display_name || profile?.email?.split('@')[0] || authEmail?.split('@')[0] || '');
const email = loading ? '' : (profile?.email || authEmail || '');
const displayName = loading ? '' : (profile?.display_name || authEmail?.split('@')[0] || '');
const email = loading ? '' : (authEmail || '');
const bio = profile?.bio || '';
return (
+31 -46
View File
@@ -1,6 +1,5 @@
import { useEffect, useState } from 'react';
import type { PointsBalance, PackageInfo } from '../lib/api';
import { getPointsBalance, getPackages, invalidatePointsCache } from '../lib/api';
import { useMemo } from 'react';
import { usePackages, usePoints } from '../lib/resources';
interface Props {
locale: string;
@@ -50,49 +49,35 @@ function SidePanel({ s }: { s: Props['store'] }) {
}
export default function StorePage({ store: s, pricing: p }: Props) {
const [points, setPoints] = useState<PointsBalance | null>(null);
const [packages, setPackages] = useState<PackageDisplay[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
Promise.all([
getPointsBalance(),
getPackages(),
])
.then(([pointsData, packagesData]) => {
setPoints(pointsData);
// Map backend packages to display format
const displayPkgs: PackageDisplay[] = packagesData.packages.map((pkg) => {
const key = PRODUCT_CODE_MAP[pkg.productCode] || 'p1';
return {
name: p[`${key}Name` as keyof typeof p] || pkg.productCode,
badge: pkg.isStarter ? (pkg.starterEligible ? p.p1Badge : '') : '',
price: p[`${key}Price` as keyof typeof p] || '',
credits: `${pkg.credits} ${s.pointsLabel}`,
desc: p[`${key}Desc` as keyof typeof p] || '',
featured: pkg.productCode === 'popular_pack', // 只有常用加量包是推荐
productCode: pkg.productCode,
appStoreProductId: pkg.appStoreProductId,
starterEligible: pkg.starterEligible,
isStarter: pkg.isStarter,
};
});
// Sort by sortOrder
displayPkgs.sort((a, b) => {
const pkgA = packagesData.packages.find(pkg => pkg.productCode === a.productCode);
const pkgB = packagesData.packages.find(pkg => pkg.productCode === b.productCode);
return (pkgA?.sortOrder || 0) - (pkgB?.sortOrder || 0);
});
setPackages(displayPkgs);
})
.catch(() => {
// Fallback to static data if API fails
setPoints({ balance: 0, frozenBalance: 0, availableBalance: 0, runCost: 20, canRun: false });
setPackages([]);
})
.finally(() => setLoading(false));
}, [p, s.pointsLabel]);
const pointsState = usePoints();
const packagesState = usePackages();
const points = pointsState.data ?? null;
const packages = useMemo<PackageDisplay[]>(() => {
const packagesData = packagesState.data;
if (!packagesData) return [];
const displayPkgs: PackageDisplay[] = packagesData.packages.map((pkg) => {
const key = PRODUCT_CODE_MAP[pkg.productCode] || 'p1';
return {
name: p[`${key}Name` as keyof typeof p] || pkg.productCode,
badge: pkg.isStarter ? (pkg.starterEligible ? p.p1Badge : '') : '',
price: p[`${key}Price` as keyof typeof p] || '',
credits: `${pkg.credits} ${s.pointsLabel}`,
desc: p[`${key}Desc` as keyof typeof p] || '',
featured: pkg.productCode === 'popular_pack',
productCode: pkg.productCode,
appStoreProductId: pkg.appStoreProductId,
starterEligible: pkg.starterEligible,
isStarter: pkg.isStarter,
};
});
displayPkgs.sort((a, b) => {
const pkgA = packagesData.packages.find(pkg => pkg.productCode === a.productCode);
const pkgB = packagesData.packages.find(pkg => pkg.productCode === b.productCode);
return (pkgA?.sortOrder || 0) - (pkgB?.sortOrder || 0);
});
return displayPkgs;
}, [packagesState.data, p, s.pointsLabel]);
const loading = pointsState.loading || packagesState.loading;
return (
<div className="flex flex-col gap-5 min-h-full">
+3 -14
View File
@@ -139,23 +139,12 @@ export interface PackagesResponse {
packages: PackageInfo[];
}
// Points cache with TTL
let pointsCache: { data: PointsBalance; expiry: number } | null = null;
const POINTS_CACHE_TTL = 60 * 1000; // 1 minute
export function getPointsBalance(useCache = true): Promise<PointsBalance> {
const now = Date.now();
if (useCache && pointsCache && pointsCache.expiry > now) {
return Promise.resolve(pointsCache.data);
}
return authFetch<PointsBalance>(API_ROUTES.points.balance).then((data) => {
pointsCache = { data, expiry: now + POINTS_CACHE_TTL };
return data;
});
export function getPointsBalance(): Promise<PointsBalance> {
return authFetch<PointsBalance>(API_ROUTES.points.balance);
}
export function invalidatePointsCache(): void {
pointsCache = null;
// Points caching lives in resources.ts. Kept for older imports during rollout.
}
export function getPackages(): Promise<PackagesResponse> {
+2
View File
@@ -5,6 +5,7 @@
import { apiRequest, apiUrl, jsonHeaders, toApiError, ApiError } from './api-client';
import { API_ROUTES } from './api-routes';
import { clearAll as clearDataCache } from './data-client';
const STORAGE_KEY = 'meeyao_auth';
@@ -78,6 +79,7 @@ export function setAuth(data: AuthData): void {
export function clearAuth(): void {
localStorage.removeItem(STORAGE_KEY);
clearDataCache();
}
// --- Token status ---
+149
View File
@@ -0,0 +1,149 @@
export type CacheKey = readonly string[];
interface CacheEntry<T> {
data?: T;
error?: unknown;
updatedAt: number;
expiresAt: number;
promise?: Promise<T>;
}
export interface QueryOptions<T> {
key: CacheKey;
ttlMs: number;
fetcher: () => Promise<T>;
staleWhileRevalidate?: boolean;
force?: boolean;
}
type Listener = () => void;
const cache = new Map<string, CacheEntry<unknown>>();
const listeners = new Map<string, Set<Listener>>();
function keyToString(key: CacheKey): string {
return JSON.stringify(key);
}
function isPrefix(key: CacheKey, prefix: CacheKey): boolean {
return prefix.every((part, index) => key[index] === part);
}
function notify(serializedKey: string): void {
listeners.get(serializedKey)?.forEach((listener) => listener());
}
function notifyPrefix(prefix: CacheKey): void {
for (const serializedKey of listeners.keys()) {
const parsedKey = JSON.parse(serializedKey) as string[];
if (isPrefix(parsedKey, prefix)) notify(serializedKey);
}
}
function startFetch<T>(serializedKey: string, ttlMs: number, fetcher: () => Promise<T>): Promise<T> {
const now = Date.now();
const existing = cache.get(serializedKey) as CacheEntry<T> | undefined;
const promise = fetcher()
.then((data) => {
cache.set(serializedKey, {
data,
updatedAt: Date.now(),
expiresAt: Date.now() + ttlMs,
});
notify(serializedKey);
return data;
})
.catch((error) => {
cache.set(serializedKey, {
data: existing?.data,
error,
updatedAt: existing?.updatedAt ?? now,
expiresAt: existing?.data === undefined ? now : existing.expiresAt,
});
notify(serializedKey);
throw error;
});
cache.set(serializedKey, {
...existing,
updatedAt: existing?.updatedAt ?? now,
expiresAt: existing?.expiresAt ?? now,
promise,
});
notify(serializedKey);
return promise;
}
export function query<T>({
key,
ttlMs,
fetcher,
staleWhileRevalidate = true,
force = false,
}: QueryOptions<T>): Promise<T> {
const serializedKey = keyToString(key);
const entry = cache.get(serializedKey) as CacheEntry<T> | undefined;
const now = Date.now();
if (!force && entry?.promise) return entry.promise;
if (!force && entry?.data !== undefined && entry.expiresAt > now) return Promise.resolve(entry.data);
if (!force && staleWhileRevalidate && entry?.data !== undefined) {
void startFetch(serializedKey, ttlMs, fetcher).catch((error) => {
console.debug('[data-client] Background refresh failed', error);
});
return Promise.resolve(entry.data);
}
return startFetch(serializedKey, ttlMs, fetcher);
}
export function prefetch<T>(options: QueryOptions<T>): void {
void query(options).catch((error) => {
console.debug('[data-client] Prefetch failed', error);
});
}
export function peek<T>(key: CacheKey): T | undefined {
return (cache.get(keyToString(key)) as CacheEntry<T> | undefined)?.data;
}
export function getEntry<T>(key: CacheKey): CacheEntry<T> | undefined {
return cache.get(keyToString(key)) as CacheEntry<T> | undefined;
}
export function set<T>(key: CacheKey, data: T, ttlMs: number): void {
cache.set(keyToString(key), {
data,
updatedAt: Date.now(),
expiresAt: Date.now() + ttlMs,
});
notify(keyToString(key));
}
export function invalidate(prefix: CacheKey): void {
for (const serializedKey of Array.from(cache.keys())) {
const parsedKey = JSON.parse(serializedKey) as string[];
if (isPrefix(parsedKey, prefix)) {
cache.delete(serializedKey);
notify(serializedKey);
}
}
notifyPrefix(prefix);
}
export function clearAll(): void {
cache.clear();
for (const serializedKey of listeners.keys()) notify(serializedKey);
}
export function subscribe(key: CacheKey, listener: Listener): () => void {
const serializedKey = keyToString(key);
const keyListeners = listeners.get(serializedKey) ?? new Set<Listener>();
keyListeners.add(listener);
listeners.set(serializedKey, keyListeners);
return () => {
keyListeners.delete(listener);
if (keyListeners.size === 0) listeners.delete(serializedKey);
};
}
+397
View File
@@ -0,0 +1,397 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
getAgentHistory,
getAgentHistoryByThread,
getNotifications,
getPackages,
getPointsBalance,
getUnreadNotificationCount,
getUserProfile,
markAllNotificationsRead,
markNotificationRead,
updateUserProfile,
updateUserSettings,
uploadAvatar,
type HistorySnapshot,
type NotificationItem,
type NotificationListResponse,
type PackageInfo,
type PackagesResponse,
type PointsBalance,
type ProfileSettings,
type UnreadCount,
type UpdateProfileRequest,
type UserProfile,
} from './api';
import {
getEntry,
invalidate,
peek,
prefetch,
query,
set,
subscribe,
type CacheKey,
type QueryOptions,
} from './data-client';
const PROFILE_TTL = 5 * 60_000;
const POINTS_TTL = 60_000;
const PACKAGES_TTL = 30 * 60_000;
const HISTORY_TTL = 60_000;
const HISTORY_THREAD_TTL = 5 * 60_000;
const NOTIFICATIONS_TTL = 60_000;
const UNREAD_TTL = 30_000;
export const profileKey = ['profile'] as const;
export const pointsBalanceKey = ['points', 'balance'] as const;
export const packagesKey = ['points', 'packages'] as const;
export const historyListKey = ['history', 'list'] as const;
export const historySummaryKey = historyListKey;
export const historyThreadKey = (threadId: string) => ['history', 'thread', threadId] as const;
export const notificationsKey = (locale: string) => ['notifications', 'list', locale] as const;
export const unreadCountKey = ['notifications', 'unread-count'] as const;
interface ResourceState<T> {
data: T | undefined;
loading: boolean;
refreshing: boolean;
error: unknown;
reload: () => Promise<T>;
}
type ResourceOptions<T> = QueryOptions<T> & {
enabled?: boolean;
};
export function useResource<T>(options: ResourceOptions<T>): ResourceState<T> {
const optionsRef = useRef(options);
optionsRef.current = options;
const keyId = useMemo(() => JSON.stringify(options.key), [options.key]);
const enabled = options.enabled ?? true;
const [data, setDataState] = useState<T | undefined>(() => enabled ? peek<T>(options.key) : undefined);
const [loading, setLoading] = useState(() => enabled && peek<T>(options.key) === undefined);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<unknown>(() => enabled ? getEntry<T>(options.key)?.error : undefined);
const load = useCallback((force = false) => {
const currentOptions = optionsRef.current;
if (currentOptions.enabled === false) {
return Promise.reject(new Error('Resource is disabled'));
}
const hasCachedData = peek<T>(currentOptions.key) !== undefined;
setLoading(!hasCachedData);
setRefreshing(hasCachedData);
return query({ ...currentOptions, force })
.then((next) => {
setDataState(next);
setError(undefined);
return next;
})
.catch((err) => {
setError(err);
throw err;
})
.finally(() => {
setLoading(false);
setRefreshing(false);
});
}, []);
const syncFromCache = useCallback(() => {
const entry = getEntry<T>(optionsRef.current.key);
if (!entry) {
setDataState(undefined);
setError(undefined);
setRefreshing(false);
void load(false).catch(() => undefined);
return;
}
setDataState(entry.data);
setError(entry.error);
setRefreshing(Boolean(entry.promise && entry.data !== undefined));
}, [load]);
useEffect(() => {
if (!enabled) return undefined;
return subscribe(optionsRef.current.key, syncFromCache);
}, [enabled, keyId, syncFromCache]);
useEffect(() => {
if (!enabled) {
setDataState(undefined);
setLoading(false);
setRefreshing(false);
setError(undefined);
return;
}
setDataState(peek<T>(optionsRef.current.key));
setError(getEntry<T>(optionsRef.current.key)?.error);
void load(false).catch(() => undefined);
}, [enabled, keyId, load]);
return {
data,
loading,
refreshing,
error,
reload: () => load(true),
};
}
function fetchProfileResource(force = false): Promise<UserProfile> {
return query({
key: profileKey,
ttlMs: PROFILE_TTL,
fetcher: getUserProfile,
staleWhileRevalidate: true,
force,
});
}
export function getProfileResource(): Promise<UserProfile> {
return fetchProfileResource(false);
}
export function useProfile(): ResourceState<UserProfile> {
return useResource({
key: profileKey,
ttlMs: PROFILE_TTL,
fetcher: getUserProfile,
staleWhileRevalidate: true,
});
}
export function setProfileResource(profile: UserProfile): void {
set(profileKey, profile, PROFILE_TTL);
}
export async function updateProfileResource(input: UpdateProfileRequest): Promise<UserProfile> {
const updated = await updateUserProfile(input);
setProfileResource(updated);
return updated;
}
export async function uploadAvatarResource(file: File): Promise<UserProfile> {
const updated = await uploadAvatar(file);
setProfileResource(updated);
return updated;
}
export async function updateSettingsResource(settings: ProfileSettings): Promise<UserProfile> {
const updated = await updateUserSettings({ settings });
setProfileResource(updated);
return updated;
}
export function getPointsResource(force = false): Promise<PointsBalance> {
return query({
key: pointsBalanceKey,
ttlMs: POINTS_TTL,
fetcher: getPointsBalance,
staleWhileRevalidate: true,
force,
});
}
export function usePoints(): ResourceState<PointsBalance> {
return useResource({
key: pointsBalanceKey,
ttlMs: POINTS_TTL,
fetcher: getPointsBalance,
staleWhileRevalidate: true,
});
}
export function invalidatePoints(): void {
invalidate(pointsBalanceKey);
}
export function getPackagesResource(force = false): Promise<PackagesResponse> {
return query({
key: packagesKey,
ttlMs: PACKAGES_TTL,
fetcher: getPackages,
staleWhileRevalidate: true,
force,
});
}
export function usePackages(): ResourceState<PackagesResponse> {
return useResource({
key: packagesKey,
ttlMs: PACKAGES_TTL,
fetcher: getPackages,
staleWhileRevalidate: true,
});
}
export function getHistoryListResource(force = false): Promise<HistorySnapshot> {
return query({
key: historyListKey,
ttlMs: HISTORY_TTL,
fetcher: getAgentHistory,
staleWhileRevalidate: true,
force,
});
}
export function useHistoryList(): ResourceState<HistorySnapshot> {
return useResource({
key: historyListKey,
ttlMs: HISTORY_TTL,
fetcher: getAgentHistory,
staleWhileRevalidate: true,
});
}
export function getHistorySummaryResource(force = false): Promise<HistorySnapshot> {
return query({
key: historySummaryKey,
ttlMs: HISTORY_TTL,
fetcher: getAgentHistory,
staleWhileRevalidate: true,
force,
});
}
export function useHistorySummary(): ResourceState<HistorySnapshot> {
return useResource({
key: historySummaryKey,
ttlMs: HISTORY_TTL,
fetcher: getAgentHistory,
staleWhileRevalidate: true,
});
}
export function getHistoryThreadResource(threadId: string, force = false): Promise<HistorySnapshot> {
return query({
key: historyThreadKey(threadId),
ttlMs: HISTORY_THREAD_TTL,
fetcher: () => getAgentHistoryByThread(threadId),
staleWhileRevalidate: true,
force,
});
}
export function useHistoryThread(threadId?: string): ResourceState<HistorySnapshot> {
return useResource({
key: threadId ? historyThreadKey(threadId) : ['history', 'thread', 'missing'],
ttlMs: HISTORY_THREAD_TTL,
fetcher: () => {
if (!threadId) return Promise.reject(new Error('Missing history thread id'));
return getAgentHistoryByThread(threadId);
},
staleWhileRevalidate: true,
enabled: Boolean(threadId),
});
}
export function invalidateHistory(threadId?: string): void {
invalidate(historySummaryKey);
invalidate(historyListKey);
if (threadId) invalidate(historyThreadKey(threadId));
}
export function getNotificationsResource(locale: string, force = false): Promise<NotificationListResponse> {
return query({
key: notificationsKey(locale),
ttlMs: NOTIFICATIONS_TTL,
fetcher: () => getNotifications(locale),
staleWhileRevalidate: true,
force,
});
}
export function useNotifications(locale: string): ResourceState<NotificationListResponse> {
return useResource({
key: notificationsKey(locale),
ttlMs: NOTIFICATIONS_TTL,
fetcher: () => getNotifications(locale),
staleWhileRevalidate: true,
});
}
export function getUnreadCountResource(force = false): Promise<UnreadCount> {
return query({
key: unreadCountKey,
ttlMs: UNREAD_TTL,
fetcher: getUnreadNotificationCount,
staleWhileRevalidate: true,
force,
});
}
export function useUnreadCount(): ResourceState<UnreadCount> {
return useResource({
key: unreadCountKey,
ttlMs: UNREAD_TTL,
fetcher: getUnreadNotificationCount,
staleWhileRevalidate: true,
});
}
export async function markNotificationReadResource(id: string, locale: string): Promise<NotificationItem> {
const updated = await markNotificationRead(id, locale);
const listKey = notificationsKey(locale);
const list = peek<NotificationListResponse>(listKey);
if (list) {
const previous = list.items.find((item) => item.id === id);
set(listKey, {
...list,
items: list.items.map((item) => (item.id === id ? updated : item)),
}, NOTIFICATIONS_TTL);
if (previous && !previous.isRead && updated.isRead) {
const unread = peek<UnreadCount>(unreadCountKey);
if (unread) set(unreadCountKey, { count: Math.max(0, unread.count - 1) }, UNREAD_TTL);
}
} else {
invalidate(unreadCountKey);
}
return updated;
}
export async function markAllNotificationsReadResource(locale: string): Promise<{ updatedCount: number }> {
const result = await markAllNotificationsRead();
const listKey = notificationsKey(locale);
const list = peek<NotificationListResponse>(listKey);
if (list) {
const readAt = new Date().toISOString();
set(listKey, {
...list,
items: list.items.map((item) => ({ ...item, isRead: true, readAt: item.readAt ?? readAt })),
}, NOTIFICATIONS_TTL);
}
set(unreadCountKey, { count: 0 }, UNREAD_TTL);
return result;
}
export function prefetchAppBasics(): void {
prefetch({ key: pointsBalanceKey, ttlMs: POINTS_TTL, fetcher: getPointsBalance, staleWhileRevalidate: true });
prefetch({ key: unreadCountKey, ttlMs: UNREAD_TTL, fetcher: getUnreadNotificationCount, staleWhileRevalidate: true });
prefetch({ key: historySummaryKey, ttlMs: HISTORY_TTL, fetcher: getAgentHistory, staleWhileRevalidate: true });
}
export function prefetchForPath(pathname: string, locale: string): void {
if (pathname.includes('/store')) {
prefetch({ key: packagesKey, ttlMs: PACKAGES_TTL, fetcher: getPackages, staleWhileRevalidate: true });
prefetch({ key: pointsBalanceKey, ttlMs: POINTS_TTL, fetcher: getPointsBalance, staleWhileRevalidate: true });
} else if (pathname.includes('/history')) {
prefetch({ key: historyListKey, ttlMs: HISTORY_TTL, fetcher: getAgentHistory, staleWhileRevalidate: true });
} else if (pathname.includes('/notifications')) {
prefetch({ key: notificationsKey(locale), ttlMs: NOTIFICATIONS_TTL, fetcher: () => getNotifications(locale), staleWhileRevalidate: true });
prefetch({ key: unreadCountKey, ttlMs: UNREAD_TTL, fetcher: getUnreadNotificationCount, staleWhileRevalidate: true });
} else if (pathname.includes('/settings') || pathname.includes('/profile')) {
prefetch({ key: profileKey, ttlMs: PROFILE_TTL, fetcher: getUserProfile, staleWhileRevalidate: true });
} else if (pathname.includes('/divination')) {
prefetch({ key: pointsBalanceKey, ttlMs: POINTS_TTL, fetcher: getPointsBalance, staleWhileRevalidate: true });
}
}
export function getCachedProfile(): UserProfile | undefined {
return peek<UserProfile>(profileKey);
}
export function getCachedPackages(): PackageInfo[] | undefined {
return peek<PackagesResponse>(packagesKey)?.packages;
}