chore: directory migration - gateway→server, web→client/electron

This commit is contained in:
2026-09-08 19:16:35 +08:00
parent fd9f99a3f9
commit f9d757b5e5
243 changed files with 5095 additions and 228 deletions

239
client/electron/src/App.tsx Normal file
View File

@ -0,0 +1,239 @@
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';