perf: finish web performance pass
This commit is contained in:
@@ -1,93 +0,0 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
||||
import {
|
||||
getAuth,
|
||||
loginWithEmail as doLogin,
|
||||
refreshAccessToken,
|
||||
logout as doLogout,
|
||||
clearAuth,
|
||||
redirectToLogin,
|
||||
type AuthUser,
|
||||
} from '../lib/auth';
|
||||
|
||||
interface AuthContextValue {
|
||||
user: AuthUser | null;
|
||||
isAuthenticated: boolean;
|
||||
loading: boolean;
|
||||
login: (email: string, token: string, language?: string, timezone?: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue>({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
loading: true,
|
||||
login: async () => {},
|
||||
logout: async () => {},
|
||||
});
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// recoverSession on mount
|
||||
useEffect(() => {
|
||||
const auth = getAuth();
|
||||
if (!auth?.refresh_token) {
|
||||
clearAuth();
|
||||
redirectToLogin();
|
||||
return;
|
||||
}
|
||||
refreshAccessToken()
|
||||
.then((data) => {
|
||||
setUser(data.user);
|
||||
})
|
||||
.catch(() => {
|
||||
clearAuth();
|
||||
setUser(null);
|
||||
redirectToLogin();
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const login = useCallback(
|
||||
async (email: string, token: string, language?: string, timezone?: string) => {
|
||||
const data = await doLogin(email, token, language, timezone);
|
||||
setUser(data.user);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
setUser(null);
|
||||
await doLogout();
|
||||
redirectToLogin();
|
||||
}, []);
|
||||
|
||||
if (loading || user === null) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex items-center justify-center px-6">
|
||||
<div className="h-10 w-10 rounded-full border-2 border-violet-200 border-t-violet-600 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
isAuthenticated: user !== null,
|
||||
loading,
|
||||
login,
|
||||
logout,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -436,10 +436,6 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
navigate(`/${locale}/dashboard`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={scrollContainerRef} className="relative flex min-h-full flex-col gap-[22px]">
|
||||
<div className="flex items-center justify-between gap-5">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { mapHistoryMessagesToItems } from '../lib/api';
|
||||
import { useHistorySummary, usePoints, useUnreadCount } from '../lib/resources';
|
||||
import { primeHistoryThreadFromSnapshot, useHistorySummary, usePoints, useUnreadCount } from '../lib/resources';
|
||||
import Icon from './Icon';
|
||||
|
||||
interface DashboardProps {
|
||||
@@ -130,7 +130,8 @@ export default function Dashboard({ locale, translations: i18n }: DashboardProps
|
||||
history.map((item) => (
|
||||
<a
|
||||
key={item.id}
|
||||
href={`/${locale}/history/${item.threadId}`}
|
||||
href={`/${locale}/history/result?threadId=${encodeURIComponent(item.threadId)}`}
|
||||
onClick={() => historyState.data && primeHistoryThreadFromSnapshot(item.threadId, historyState.data)}
|
||||
className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 bg-white rounded-xl p-4 md:p-5 border border-slate-100 hover:shadow-sm hover:border-violet-200 transition-all cursor-pointer"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-[10px] bg-blue-50 flex items-center justify-center shrink-0">
|
||||
|
||||
@@ -1,39 +1,27 @@
|
||||
import { useEffect } from 'react';
|
||||
import { lazy, Suspense, useEffect } from 'react';
|
||||
import { BrowserRouter, Navigate, Route, Routes, useNavigate } from 'react-router-dom';
|
||||
import type { Locale, Translations } from '../i18n/utils';
|
||||
import AppShell from './AppShell';
|
||||
import Dashboard from './Dashboard';
|
||||
import StorePage from './StorePage';
|
||||
import HistoryListPage from './HistoryListPage';
|
||||
import DivinationResultPage from './DivinationResultPage';
|
||||
import HistoryFollowUpPage from './HistoryFollowUpPage';
|
||||
import NotificationPage from './NotificationPage';
|
||||
import ProfileDetailPage from './ProfileDetailPage';
|
||||
import SettingsPage from './SettingsPage';
|
||||
import GeneralSettingsPage from './GeneralSettingsPage';
|
||||
import FeedbackPage from './FeedbackPage';
|
||||
import ManualDivinationPage from './ManualDivinationPage';
|
||||
import AutoDivinationPage from './AutoDivinationPage';
|
||||
import { getNavConfig } from './navConfig';
|
||||
|
||||
type TranslationMap = Record<string, string>;
|
||||
|
||||
interface DashboardAppProps {
|
||||
locale: string;
|
||||
translations: {
|
||||
dashboard: TranslationMap;
|
||||
store: TranslationMap;
|
||||
pricing: TranslationMap;
|
||||
history: TranslationMap;
|
||||
notifications: TranslationMap;
|
||||
profile: TranslationMap;
|
||||
settings: TranslationMap;
|
||||
divination: TranslationMap;
|
||||
general: TranslationMap;
|
||||
feedback: TranslationMap;
|
||||
result: TranslationMap;
|
||||
};
|
||||
locale: Locale;
|
||||
translations: Pick<Translations, 'dashboard' | 'store' | 'pricing' | 'history' | 'notifications' | 'profile' | 'settings' | 'divination' | 'general' | 'feedback' | 'result'>;
|
||||
}
|
||||
|
||||
const Dashboard = lazy(() => import('./Dashboard'));
|
||||
const StorePage = lazy(() => import('./StorePage'));
|
||||
const HistoryListPage = lazy(() => import('./HistoryListPage'));
|
||||
const DivinationResultPage = lazy(() => import('./DivinationResultPage'));
|
||||
const HistoryFollowUpPage = lazy(() => import('./HistoryFollowUpPage'));
|
||||
const NotificationPage = lazy(() => import('./NotificationPage'));
|
||||
const ProfileDetailPage = lazy(() => import('./ProfileDetailPage'));
|
||||
const SettingsPage = lazy(() => import('./SettingsPage'));
|
||||
const GeneralSettingsPage = lazy(() => import('./GeneralSettingsPage'));
|
||||
const FeedbackPage = lazy(() => import('./FeedbackPage'));
|
||||
const ManualDivinationPage = lazy(() => import('./ManualDivinationPage'));
|
||||
const AutoDivinationPage = lazy(() => import('./AutoDivinationPage'));
|
||||
|
||||
const APP_PATHS = [
|
||||
'/dashboard',
|
||||
'/store',
|
||||
@@ -77,6 +65,14 @@ function AppLinkInterceptor({ locale }: { locale: string }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function RouteFallback() {
|
||||
return (
|
||||
<div className="flex min-h-[320px] items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-violet-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardRoutes({ locale, translations }: DashboardAppProps) {
|
||||
const dashboard = translations.dashboard;
|
||||
const navItems = getNavConfig(locale, dashboard);
|
||||
@@ -84,24 +80,26 @@ function DashboardRoutes({ locale, translations }: DashboardAppProps) {
|
||||
return (
|
||||
<AppShell locale={locale} brandName={dashboard.brandName} navItems={navItems}>
|
||||
<AppLinkInterceptor locale={locale} />
|
||||
<Routes>
|
||||
<Route path={`/${locale}/dashboard`} element={<Dashboard locale={locale} translations={dashboard} />} />
|
||||
<Route path={`/${locale}/store`} element={<StorePage locale={locale} dashboard={dashboard} store={translations.store} pricing={translations.pricing} />} />
|
||||
<Route path={`/${locale}/history`} element={<HistoryListPage locale={locale} dashboard={dashboard} history={translations.history} />} />
|
||||
<Route path={`/${locale}/history/:id`} element={<DivinationResultPage locale={locale} translations={translations.result} />} />
|
||||
<Route path={`/${locale}/history/:id/followup`} element={<HistoryFollowUpPage locale={locale} dashboard={dashboard} history={translations.history} />} />
|
||||
<Route path={`/${locale}/history/result`} element={<DivinationResultPage locale={locale} translations={translations.result} />} />
|
||||
<Route path={`/${locale}/history/followup`} element={<HistoryFollowUpPage locale={locale} dashboard={dashboard} history={translations.history} />} />
|
||||
<Route path={`/${locale}/notifications`} element={<NotificationPage locale={locale} dashboard={dashboard} notifications={translations.notifications} />} />
|
||||
<Route path={`/${locale}/profile`} element={<ProfileDetailPage locale={locale} dashboard={dashboard} profile={translations.profile} />} />
|
||||
<Route path={`/${locale}/settings`} element={<SettingsPage locale={locale} dashboard={dashboard} settings={translations.settings} />} />
|
||||
<Route path={`/${locale}/settings/general`} element={<GeneralSettingsPage locale={locale} general={translations.general} />} />
|
||||
<Route path={`/${locale}/settings/feedback`} element={<FeedbackPage locale={locale} feedback={translations.feedback} />} />
|
||||
<Route path={`/${locale}/divination/manual`} element={<ManualDivinationPage locale={locale} dashboard={dashboard} divination={translations.divination} />} />
|
||||
<Route path={`/${locale}/divination/auto`} element={<AutoDivinationPage locale={locale} dashboard={dashboard} divination={translations.divination} />} />
|
||||
<Route path={`/${locale}/divination/result`} element={<DivinationResultPage locale={locale} translations={translations.result} />} />
|
||||
<Route path="*" element={<Navigate to={`/${locale}/dashboard`} replace />} />
|
||||
</Routes>
|
||||
<Suspense fallback={<RouteFallback />}>
|
||||
<Routes>
|
||||
<Route path={`/${locale}/dashboard`} element={<Dashboard locale={locale} translations={dashboard} />} />
|
||||
<Route path={`/${locale}/store`} element={<StorePage locale={locale} dashboard={dashboard} store={translations.store} pricing={translations.pricing} />} />
|
||||
<Route path={`/${locale}/history`} element={<HistoryListPage locale={locale} dashboard={dashboard} history={translations.history} />} />
|
||||
<Route path={`/${locale}/history/:id`} element={<DivinationResultPage locale={locale} translations={translations.result} />} />
|
||||
<Route path={`/${locale}/history/:id/followup`} element={<HistoryFollowUpPage locale={locale} dashboard={dashboard} history={translations.history} />} />
|
||||
<Route path={`/${locale}/history/result`} element={<DivinationResultPage locale={locale} translations={translations.result} />} />
|
||||
<Route path={`/${locale}/history/followup`} element={<HistoryFollowUpPage locale={locale} dashboard={dashboard} history={translations.history} />} />
|
||||
<Route path={`/${locale}/notifications`} element={<NotificationPage locale={locale} dashboard={dashboard} notifications={translations.notifications} />} />
|
||||
<Route path={`/${locale}/profile`} element={<ProfileDetailPage locale={locale} dashboard={dashboard} profile={translations.profile} />} />
|
||||
<Route path={`/${locale}/settings`} element={<SettingsPage locale={locale} dashboard={dashboard} settings={translations.settings} />} />
|
||||
<Route path={`/${locale}/settings/general`} element={<GeneralSettingsPage locale={locale} general={translations.general} />} />
|
||||
<Route path={`/${locale}/settings/feedback`} element={<FeedbackPage locale={locale} feedback={translations.feedback} />} />
|
||||
<Route path={`/${locale}/divination/manual`} element={<ManualDivinationPage locale={locale} dashboard={dashboard} divination={translations.divination} />} />
|
||||
<Route path={`/${locale}/divination/auto`} element={<AutoDivinationPage locale={locale} dashboard={dashboard} divination={translations.divination} />} />
|
||||
<Route path={`/${locale}/divination/result`} element={<DivinationResultPage locale={locale} translations={translations.result} />} />
|
||||
<Route path="*" element={<Navigate to={`/${locale}/dashboard`} replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { historyMessageToResultData, type DivinationResultData, type YaoType } from '../lib/api';
|
||||
import { useHistoryThread } from '../lib/resources';
|
||||
import Icon from './Icon';
|
||||
@@ -339,7 +339,9 @@ const RESULT_STORAGE_KEY = 'divination_result_data';
|
||||
export default function DivinationResultPage({ locale, translations: t }: Props) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { id: threadId } = useParams<{ id: string }>();
|
||||
const { id: routeThreadId } = useParams<{ id: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const threadId = routeThreadId ?? searchParams.get('threadId') ?? undefined;
|
||||
const threadState = useHistoryThread(threadId);
|
||||
const [data, setData] = useState<DivinationResultData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -403,7 +405,7 @@ export default function DivinationResultPage({ locale, translations: t }: Props)
|
||||
const handleFollowUp = () => {
|
||||
const effectiveThreadId = data?.threadId || threadId;
|
||||
if (effectiveThreadId) {
|
||||
navigate(`/${locale}/history/${effectiveThreadId}/followup`, { state: { result: data } });
|
||||
navigate(`/${locale}/history/followup?threadId=${encodeURIComponent(effectiveThreadId)}`, { state: { result: data } });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { authFetch } from '../lib/auth';
|
||||
import { API_ROUTES } from '../lib/api-routes';
|
||||
import { apiUrl, jsonHeaders } from '../lib/api-client';
|
||||
import { apiUrl } from '../lib/api-client';
|
||||
|
||||
interface Props {
|
||||
locale: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
historyMessageToResultData,
|
||||
enqueueFollowUpRun,
|
||||
@@ -63,7 +63,9 @@ const CATEGORY_COLORS: Record<string, string> = {
|
||||
export default function HistoryFollowUpPage({ locale, history: h }: Props) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { id: threadId } = useParams<{ id: string }>();
|
||||
const { id: routeThreadId } = useParams<{ id: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const threadId = routeThreadId ?? searchParams.get('threadId') ?? undefined;
|
||||
|
||||
const [resultData, setResultData] = useState<DivinationResultData | null>(null);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
@@ -373,7 +375,7 @@ export default function HistoryFollowUpPage({ locale, history: h }: Props) {
|
||||
<div className="bg-white rounded-2xl p-4 border border-slate-200 flex flex-col gap-2.5">
|
||||
<h4 className="text-slate-900 text-sm font-bold">{h.relatedActions}</h4>
|
||||
<button
|
||||
onClick={() => navigate(`/${locale}/history/${threadId}`)}
|
||||
onClick={() => threadId && navigate(`/${locale}/history/result?threadId=${encodeURIComponent(threadId)}`)}
|
||||
className="flex items-center gap-2 text-sm text-slate-600 hover:text-violet-600 transition-colors py-1"
|
||||
>
|
||||
<Icon name="auto_awesome" className="w-4 h-4" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { mapHistoryMessagesToItems, type HistoryItem } from '../lib/api';
|
||||
import { useHistoryList } from '../lib/resources';
|
||||
import { primeHistoryThreadFromSnapshot, useHistoryList } from '../lib/resources';
|
||||
import Icon from './Icon';
|
||||
|
||||
interface Props {
|
||||
@@ -126,7 +126,8 @@ export default function HistoryListPage({ locale, history: i18n }: Props) {
|
||||
// 点击卡片跳转
|
||||
const handleItemClick = (item: HistoryItem) => {
|
||||
setSelectedId(item.id);
|
||||
navigate(`/${locale}/history/${item.threadId}`);
|
||||
if (historyState.data) primeHistoryThreadFromSnapshot(item.threadId, historyState.data);
|
||||
navigate(`/${locale}/history/result?threadId=${encodeURIComponent(item.threadId)}`);
|
||||
};
|
||||
|
||||
// 返回首页
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { sendOtp, loginWithEmail, getAuth, refreshAccessToken, ApiError, localeToBackendLanguage, backendLanguageToLocale } from '../lib/auth';
|
||||
import { getUserProfile } from '../lib/api';
|
||||
import { getProfileResource } from '../lib/resources';
|
||||
|
||||
interface LoginFormProps {
|
||||
locale: string;
|
||||
@@ -58,7 +58,7 @@ export default function LoginForm({ locale, translations: i18n, privacyUrl, term
|
||||
try {
|
||||
await refreshAccessToken();
|
||||
// Token valid, get profile language and redirect
|
||||
const profile = await getUserProfile();
|
||||
const profile = await getProfileResource();
|
||||
const userLocale = backendLanguageToLocale(profile.settings?.preferences?.language || 'zh-CN');
|
||||
window.location.href = `/${userLocale}/dashboard`;
|
||||
} catch {
|
||||
@@ -89,8 +89,7 @@ export default function LoginForm({ locale, translations: i18n, privacyUrl, term
|
||||
}
|
||||
}, [email, countdown, locale]);
|
||||
|
||||
const handleSubmit = useCallback(async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!email || !code || !agreed) return;
|
||||
setError('');
|
||||
setLoading(true);
|
||||
@@ -98,9 +97,9 @@ export default function LoginForm({ locale, translations: i18n, privacyUrl, term
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const language = localeToBackendLanguage(locale);
|
||||
await loginWithEmail(email, code, language, timezone);
|
||||
// Get profile language and redirect to correct locale
|
||||
const profile = await getUserProfile();
|
||||
const userLocale = backendLanguageToLocale(profile.settings?.preferences?.language || language);
|
||||
// Fresh login just sent this language to the backend. Avoid fetching profile
|
||||
// before a full-page navigation that would lose the in-memory resource cache.
|
||||
const userLocale = backendLanguageToLocale(language);
|
||||
window.location.href = `/${userLocale}/dashboard`;
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, locale));
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface NavItem {
|
||||
id: string;
|
||||
icon: string;
|
||||
|
||||
Reference in New Issue
Block a user