perf: optimize web data resources
This commit is contained in:
@@ -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'}`}>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user