132 lines
8.3 KiB
TypeScript
132 lines
8.3 KiB
TypeScript
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<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"><BeamLoading text="正在进入系统…" /></main>;
|
|
if (session.status === "error") return <main className="tn-auth-shell"><div className="tn-auth-panel"><h1 className="tn-page-title">无法连接服务</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 : "系统尚未初始化管理员账号,请联系系统管理员完成初始配置后登录。"}
|
|
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"><BeamLoading 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} />);
|