This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
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";
|
||||
const ExpensesPage = lazy(() => import("./pages/expenses"));
|
||||
const DashboardPage = lazy(() => import("./pages/dashboard"));
|
||||
const TrashPage = lazy(() => import("./pages/trash").then(module => ({ default: module.TrashPage })));
|
||||
const AdminsPage = lazy(() => import("./pages/admins").then(module => ({ default: module.AdminsPage })));
|
||||
const AuditPage = lazy(() => import("./pages/audit").then(module => ({ default: module.AuditPage })));
|
||||
const UpdatePage = lazy(() => import("./pages/update").then(module => ({ default: module.UpdatePage })));
|
||||
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") => {
|
||||
// The placement container owns the responsive right inset. Keeping the
|
||||
// item offset at zero avoids pushing narrow-screen notices off canvas.
|
||||
const options = { content: message, duration: 4200, placement: "top-right" as const, offset: [0, 76] as [number, number], zIndex: 5000 };
|
||||
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<string>).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 <main className="tn-auth-shell" role="status" aria-live="polite"><Loading text="正在连接本地账本…" /></main>;
|
||||
if (session.status === "error") return <main className="tn-auth-shell"><div className="tn-auth-panel"><h1 className="tn-page-title">无法连接 TallyNote</h1><p className="tn-page-subtitle">{session.error || "请确认本地服务正在运行。"}</p><Button theme="primary" onClick={() => void dispatch(bootstrapSession())}>重新连接</Button></div></main>;
|
||||
if (!session.admin) return <LoginPage
|
||||
notice={session.initialized ? undefined : "尚未初始化管理员,请先在服务器执行 pnpm admin:init。"}
|
||||
onSuccess={() => setPasswordOpen(false)}
|
||||
/>;
|
||||
if (session.admin.mustChangePassword) return <ChangePasswordPage admin={session.admin} firstLogin onSuccess={() => notify("密码已更新", "success")} />;
|
||||
|
||||
const content = passwordOpen
|
||||
? <ChangePasswordPage admin={session.admin} returnLabel={`返回${routeTitle(page)}`} onCancel={() => requestDiscard(() => setPasswordOpen(false))} onSuccess={() => { setPasswordOpen(false); notify("密码已更新", "success"); }} />
|
||||
: page === "dashboard" ? <DashboardPage timezone={session.timezone} onNavigate={onNavigate} />
|
||||
: page === "expenses" ? <ExpensesPage timezone={session.timezone} sessionKey={session.admin.id} notify={notify} />
|
||||
: page === "trash" ? <TrashPage timezone={session.timezone} notify={notify} />
|
||||
: page === "admins" ? <AdminsPage currentAdmin={session.admin} timezone={session.timezone} notify={notify} />
|
||||
: page === "audit" ? <AuditPage timezone={session.timezone} />
|
||||
: <UpdatePage timezone={session.timezone} notify={notify} />;
|
||||
|
||||
return <AppLayout activeId={page} adminName={session.admin.displayName} adminUsername={session.admin.username} onNavigate={onNavigate} onLogout={onLogout} onOpenPassword={() => { if (!passwordOpen) requestDiscard(() => setPasswordOpen(true)); }}><Suspense fallback={<main className="tn-page-loading" role="status" aria-live="polite"><Loading text="正在打开页面…" /></main>}><div key={passwordOpen ? "password" : page} className="tn-page-transition">{content}</div></Suspense></AppLayout>;
|
||||
}
|
||||
|
||||
function RouteErrorPage() {
|
||||
const error = useRouteError();
|
||||
useEffect(() => { document.title = "页面出现问题 | TallyNote"; }, []);
|
||||
const message = error instanceof Error && error.message.includes("网络")
|
||||
? "服务连接暂时不可用。"
|
||||
: "页面暂时无法打开,请重新加载后再试。";
|
||||
return <main className="tn-route-error" role="alert"><div className="tn-route-error-panel"><h1>页面出现问题</h1><p>{message}</p><Button theme="primary" onClick={() => window.location.reload()}>重新加载</Button></div></main>;
|
||||
}
|
||||
|
||||
const router = createBrowserRouter([{ path: "*", element: <Provider store={store}><UnsavedChangesProvider><App /></UnsavedChangesProvider></Provider>, errorElement: <RouteErrorPage /> }]);
|
||||
// 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(<RouterProvider router={router} />);
|
||||
Reference in New Issue
Block a user