Files
TallyNote/web-next/src/main.tsx
T
Qiufeng a070ad0434
TallyNote release / linux-x64 (push) Successful in 6m55s
fix: move notifications to bottom right
2026-09-05 17:06:23 +08:00

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} />);