import { BeamLoading } from "./components/BeamLoading"; import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react"; import { createRoot, type Root } from "react-dom/client"; import "tdesign-react/es/_util/react-19-adapter"; import { Button, Loading, NotificationPlugin } from "tdesign-react"; import { Provider } from "react-redux"; import { createBrowserRouter, RouterProvider, useLocation, useNavigate, useRouteError } from "react-router-dom"; import { AUTH_EXPIRED_EVENT } from "./services/api"; import { store, useAppDispatch, useAppSelector, bootstrapSession, isSessionBootstrapping, logoutThunk, sessionExpired } from "./store"; import { setAppTimezone } from "./utils/date"; import { AppLayout } from "./layouts"; import { DEFAULT_ROUTE_ID, isRouteId, routeIdFromPath, routePath, routeTitle, type RouteId } from "./router"; import { LoginPage, ChangePasswordPage } from "./pages/auth"; import ExpensesPage from "./pages/expenses"; import DashboardPage from "./pages/dashboard"; import { TrashPage } from "./pages/trash"; import { AdminsPage } from "./pages/admins"; import { AuditPage } from "./pages/audit"; import { UpdatePage } from "./pages/update"; import { UnsavedChangesProvider, useUnsavedActions } from "./contexts/UnsavedChanges"; import { useDialogAccessibility } from "./hooks/useDialogAccessibility"; import "./styles/theme.css"; function App() { const dispatch = useAppDispatch(); const session = useAppSelector(state => state.session); const location = useLocation(); const navigate = useNavigate(); const { requestDiscard } = useUnsavedActions(); const page = routeIdFromPath(location.pathname) ?? DEFAULT_ROUTE_ID; const [passwordOpen, setPasswordOpen] = useState(false); const logoutInFlight = useRef(false); useDialogAccessibility(); const notify = useCallback((message: string, kind: "success" | "error" | "info" = "info") => { // Keep notices in the lower-right safe area so they do not compete with // the header controls or obscure the page title. const options = { content: message, duration: 4200, placement: "bottom-right" as const, offset: [24, 24] as [number, number], zIndex: 6000 }; const show = kind === "success" ? NotificationPlugin.success : kind === "error" ? NotificationPlugin.error : NotificationPlugin.info; void show(options); }, []); useEffect(() => { void dispatch(bootstrapSession()); }, [dispatch]); useEffect(() => { const handleExpired = (event: Event) => { dispatch(sessionExpired((event as CustomEvent).detail)); setPasswordOpen(false); }; window.addEventListener(AUTH_EXPIRED_EVENT, handleExpired); return () => window.removeEventListener(AUTH_EXPIRED_EVENT, handleExpired); }, [dispatch]); useEffect(() => { if (session.timezone) setAppTimezone(session.timezone); }, [session.timezone]); useEffect(() => { setPasswordOpen(false); }, [location.pathname]); useEffect(() => { if (!routeIdFromPath(location.pathname)) navigate(routePath(DEFAULT_ROUTE_ID), { replace: true }); }, [location.pathname, navigate]); useEffect(() => { const title = session.status === "error" ? "连接失败" : !session.admin ? "登录" : session.admin.mustChangePassword ? "设置密码" : passwordOpen ? "修改密码" : routeTitle(page); document.title = `${title} | TallyNote`; }, [page, passwordOpen, session.admin, session.status]); const onLogout = useCallback(() => { requestDiscard(() => { if (logoutInFlight.current) return; logoutInFlight.current = true; void dispatch(logoutThunk()).then(() => { navigate(routePath(DEFAULT_ROUTE_ID), { replace: true }); notify("已退出登录", "info"); }).finally(() => { logoutInFlight.current = false; }); }); }, [dispatch, navigate, notify, requestDiscard]); const onNavigate = useCallback((id: RouteId, search = "") => { if (!isRouteId(id)) return; const targetPath = routePath(id); const targetSearch = search && !search.startsWith("?") ? `?${search}` : search; if (location.pathname === targetPath && !search) { if (passwordOpen) requestDiscard(() => setPasswordOpen(false)); return; } if (location.pathname !== targetPath || location.search !== targetSearch) navigate(`${targetPath}${targetSearch}`); }, [location.pathname, location.search, navigate, passwordOpen, requestDiscard]); // `status` is also used while a login or password request is in flight. // Keep the login form mounted for those requests so the user sees the // button's busy state instead of losing the entire form to a bootstrap // spinner. `bootstrapRequestId` is only set by the initial session check. if (isSessionBootstrapping(session)) return
; if (session.status === "error") return

无法连接服务

{session.error || "服务暂时无法连接,请稍后重试或检查网络状态。"}

; if (!session.admin) return setPasswordOpen(false)} />; if (session.admin.mustChangePassword) return notify("密码已更新", "success")} />; const content = passwordOpen ? requestDiscard(() => setPasswordOpen(false))} onSuccess={() => { setPasswordOpen(false); notify("密码已更新", "success"); }} /> : page === "dashboard" ? : page === "expenses" ? : page === "trash" ? : page === "admins" ? : page === "audit" ? : ; return { if (!passwordOpen) requestDiscard(() => setPasswordOpen(true)); }}>}>
{content}
; } function RouteErrorPage() { const error = useRouteError(); useEffect(() => { document.title = "页面出现问题 | TallyNote"; }, []); const message = error instanceof Error && error.message.includes("网络") ? "服务连接暂时不可用。" : "页面暂时无法打开,请重新加载后再试。"; return

页面出现问题

{message}

; } const router = createBrowserRouter([{ path: "*", element: , errorElement: }]); // Vite can re-evaluate this module during Fast Refresh while the existing DOM // root is still mounted. Reusing the root prevents duplicate createRoot calls // and the removeChild errors that follow a hot update. type RootRegistry = Window & { __tallynoteReactRoot?: Root }; const rootRegistry = window as RootRegistry; const rootElement = document.getElementById("root"); if (!rootElement) throw new Error("TallyNote root element is missing"); const reactRoot = rootRegistry.__tallynoteReactRoot ?? createRoot(rootElement); // Keep the same root across route and component hot updates. rootRegistry.__tallynoteReactRoot = reactRoot; reactRoot.render();