240 lines
7.8 KiB
TypeScript
240 lines
7.8 KiB
TypeScript
import { Suspense, lazy, useEffect } from 'react';
|
||
import { connectSSE } from './api/sse';
|
||
import { useAuthStore } from './stores/authStore';
|
||
import { useMailStore } from './stores/mailStore';
|
||
import { useSessionStore } from './stores/sessionStore';
|
||
import { useContactStore } from './stores/contactStore';
|
||
import { useUIStore } from './stores/uiStore';
|
||
import Sidebar from './components/Sidebar';
|
||
import MailList from './components/MailList';
|
||
import PermissionList from './components/PermissionList';
|
||
import ContactPanel from './components/ContactPanel';
|
||
import MailView from './components/MailView';
|
||
import ComposePage from './components/ComposePage';
|
||
import LoginPage from './components/LoginPage';
|
||
import SetupPage from './components/SetupPage';
|
||
import AccountPage from './components/AccountPage';
|
||
import AdminUsersPage from './components/AdminUsersPage';
|
||
// 日历懒加载。
|
||
//
|
||
// 它传递依赖 lunar-javascript(node_modules 里 588KB)—— 全量打进主 chunk
|
||
// 会让**登录页**也背上这份体积(实测主 bundle 447KB → 751KB,
|
||
// gzip 133 → 239KB)。农历表是一大张查找数据,压不动也没必要提前拉:
|
||
// 大多数会话根本不打开日历。
|
||
const CalendarView = lazy(() => import('./components/CalendarView'));
|
||
import NarrowNav from './components/NarrowNav';
|
||
import NarrowStack from './components/NarrowStack';
|
||
import { useIsNarrow } from './hooks/useIsNarrow';
|
||
|
||
export default function App() {
|
||
const phase = useAuthStore(s => s.phase);
|
||
const bootstrap = useAuthStore(s => s.bootstrap);
|
||
const user = useAuthStore(s => s.user);
|
||
|
||
const viewMode = useUIStore(s => s.viewMode);
|
||
const composing = useUIStore(s => s.composing);
|
||
const resetUI = useUIStore(s => s.reset);
|
||
const narrowPane = useUIStore(s => s.narrowPane);
|
||
const narrow = useIsNarrow();
|
||
|
||
const fetchInbox = useMailStore(s => s.fetchInbox);
|
||
const fetchSent = useMailStore(s => s.fetchSent);
|
||
const dropMailSession = useMailStore(s => s.dropSession);
|
||
|
||
const fetchSessions = useSessionStore(s => s.fetchSessions);
|
||
const dropSessionIfCurrent = useSessionStore(s => s.dropSessionIfCurrent);
|
||
const refreshRenameProposal = useSessionStore(s => s.refreshRenameProposal);
|
||
const refreshBudget = useSessionStore(s => s.refreshBudget);
|
||
|
||
const fetchContacts = useContactStore(s => s.fetchContacts);
|
||
const removeSessionLocally = useContactStore(s => s.removeSessionLocally);
|
||
|
||
// 启动时检测初始化状态 / 登录态
|
||
useEffect(() => {
|
||
bootstrap();
|
||
}, []);
|
||
|
||
// 登出后清理客户端状态,避免脏数据残留
|
||
useEffect(() => {
|
||
if (phase === 'anonymous') {
|
||
resetUI();
|
||
}
|
||
}, [phase]);
|
||
|
||
// 登录态就绪后拉取数据 + SSE
|
||
useEffect(() => {
|
||
if (phase !== 'authenticated') return;
|
||
|
||
fetchInbox('all');
|
||
fetchSent();
|
||
fetchSessions();
|
||
fetchContacts();
|
||
|
||
return connectSSE((type, data) => {
|
||
switch (type) {
|
||
case 'new_mail':
|
||
fetchInbox('all');
|
||
fetchSessions();
|
||
fetchContacts();
|
||
// 新来信可能带改名建议;Agent 发信也会消耗本任务的往返预算
|
||
refreshRenameProposal();
|
||
refreshBudget();
|
||
break;
|
||
case 'session_update':
|
||
// 别人(或另一个标签页)改了预算/别名
|
||
refreshBudget();
|
||
fetchInbox('all');
|
||
fetchSessions();
|
||
fetchContacts();
|
||
break;
|
||
case 'permission_decision':
|
||
fetchInbox('all');
|
||
fetchSessions();
|
||
fetchContacts();
|
||
break;
|
||
case 'session_archived': {
|
||
const id = typeof data.session_id === 'string' ? data.session_id : '';
|
||
if (!id) break;
|
||
removeSessionLocally(id);
|
||
dropMailSession(id);
|
||
dropSessionIfCurrent(id);
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
}, [phase]);
|
||
|
||
// loading 阶段
|
||
if (phase === 'checking') {
|
||
return (
|
||
<div className="h-full flex items-center justify-center bg-slate-100 text-gray-400">
|
||
<p className="text-sm">加载中</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 未登录
|
||
if (phase === 'anonymous') {
|
||
return <AnonymousRoute onBootDone={bootstrap} />;
|
||
}
|
||
|
||
// 主区域(右栏):写信 / 账号 / 管理 / 邮件详情
|
||
const main = composing ? (
|
||
<ComposePage />
|
||
) : viewMode === 'account' ? (
|
||
<AccountPage />
|
||
) : viewMode === 'calendar' ? (
|
||
// 日历自己在内部分两栏(网格 + 当日日程/编辑器),因此走 main 分支
|
||
// 而不是复用外层的 list/main —— 它的「列表」是网格,不是条目清单。
|
||
<Suspense fallback={<PaneLoading />}>
|
||
<CalendarView />
|
||
</Suspense>
|
||
) : viewMode === 'admin' && user?.role === 'admin' ? (
|
||
<AdminUsersPage />
|
||
) : (
|
||
<MailView />
|
||
);
|
||
|
||
// 列表(中栏):收发件箱、授权、联系人视图有
|
||
const list =
|
||
viewMode === 'contacts' ? (
|
||
<ContactPanel />
|
||
) : viewMode === 'permissions' ? (
|
||
<PermissionList />
|
||
) : viewMode === 'inbox' || viewMode === 'sent' ? (
|
||
<MailList />
|
||
) : null;
|
||
|
||
// 账号/管理页没有列表栏,窄屏下要直接显示主区域,
|
||
// 否则会出现一片空白(列表为 null 而 narrowPane 还停在 'list')
|
||
const hasList = list !== null;
|
||
|
||
// ---- 窄屏:详情页从右侧滑入盖住列表,不分栏 ----
|
||
if (narrow) {
|
||
// 没有列表栏的视图(账号/管理/写信)直接铺满,不需要覆盖层:
|
||
// 它们本来就是单页,套一层滑动只会让「进入账号页」也带动画,很怪
|
||
if (!hasList) {
|
||
return (
|
||
<NarrowShell>
|
||
<div className="flex-1 min-h-0 flex">{main}</div>
|
||
</NarrowShell>
|
||
);
|
||
}
|
||
return (
|
||
<NarrowShell>
|
||
<NarrowStack base={list} overlay={main} open={narrowPane === 'detail'} />
|
||
</NarrowShell>
|
||
);
|
||
}
|
||
|
||
// ---- 宽屏:三栏并排 ----
|
||
return (
|
||
<div className="h-full flex bg-gray-50">
|
||
<Sidebar />
|
||
{list}
|
||
{main}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 懒加载 chunk 到达前的占位。占满主区域,避免布局跳动。 */
|
||
function PaneLoading() {
|
||
return (
|
||
<div className="flex-1 min-w-0 flex items-center justify-center bg-gray-50 text-gray-400">
|
||
<p className="text-sm">加载中</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 窄屏外壳:内容区 + 底部导航。
|
||
*
|
||
* 没有抽屉式侧栏。它曾经存在,装的是六个与底部导航完全重复的入口,
|
||
* 唯一独有的是退出登录(已移到「我的」页)。为一个按钮维护一套
|
||
* fixed 层级 + 遮罩的代价是:抽屉 `z-50` 铺满视口高度,把底部导航
|
||
* 最左那一项盖住点不到(实测 elementFromPoint 命中抽屉里的 SVG)。
|
||
*/
|
||
function NarrowShell({ children }: { children: React.ReactNode }) {
|
||
return (
|
||
<div className="h-full flex flex-col bg-gray-50 overflow-hidden safe-frame">
|
||
{children}
|
||
<NarrowNav />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 未登录时:先查 needs_setup,决定展示初始化向导还是登录页 */
|
||
function AnonymousRoute({ onBootDone }: { onBootDone: () => void }) {
|
||
// bootstrap 已经在 App 里调过了,此处仅判断路由
|
||
// 若 bootstrap 已经把 phase 推到 anonymous,needs_setup 需独立查询
|
||
// 为简化,在 LoginPage 上方嵌套 SetupPage 的判断逻辑
|
||
return <LoginOrSetup onDone={onBootDone} />;
|
||
}
|
||
|
||
import { setupStatus } from './api/client';
|
||
function LoginOrSetup({ onDone }: { onDone: () => void }) {
|
||
const [needsSetup, setNeedsSetup] = useState<boolean | null>(null);
|
||
|
||
useEffect(() => {
|
||
setupStatus()
|
||
.then(r => setNeedsSetup(r.needs_setup))
|
||
.catch(() => setNeedsSetup(false));
|
||
}, []);
|
||
|
||
if (needsSetup === null) {
|
||
return (
|
||
<div className="h-full flex items-center justify-center bg-slate-100 text-gray-400">
|
||
<p className="text-sm">加载中</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (needsSetup) {
|
||
return <SetupPage onDone={onDone} />;
|
||
}
|
||
|
||
return <LoginPage />;
|
||
}
|
||
|
||
import { useState } from 'react';
|