Files
MailUI4Agents/client/electron/src/App.tsx

240 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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-javascriptnode_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 推到 anonymousneeds_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';