chore: directory migration - gateway→server, web→client/electron
This commit is contained in:
2
client/electron/.gitignore
vendored
Normal file
2
client/electron/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
# Electron 构建产物
|
||||
/release/
|
||||
136
client/electron/electron/main.cjs
Normal file
136
client/electron/electron/main.cjs
Normal file
@ -0,0 +1,136 @@
|
||||
/**
|
||||
* AgentMail Electron 主进程。
|
||||
*
|
||||
* 职责:
|
||||
* - 创建 BrowserWindow 加载前端(dev=<ELECTRON_START_URL>,prod=../dist/index.html)
|
||||
* - 系统托盘(Phase 4 完整实现)
|
||||
* - 离线缓存(Phase 4)
|
||||
* - 把 Gateway 地址与用户密钥注入渲染进程(Phase 2:让 client/electron/src 以独立客户端身份连任意 Gateway)
|
||||
*
|
||||
* 设计约束:
|
||||
* - 渲染进程复用 client/electron/src 的 React 代码,零改动
|
||||
* - 只通过 contextBridge 暴露必要 API,不放开 nodeIntegration
|
||||
*/
|
||||
|
||||
const { app, BrowserWindow, Tray, Menu, nativeImage, ipcMain } = require('electron');
|
||||
const path = require('node:path');
|
||||
|
||||
// 开发模式:ELECTRON_START_URL 环境变量指向 vite dev server
|
||||
const DEV_URL = process.env.ELECTRON_START_URL || 'http://localhost:5173';
|
||||
const isDev = !!process.env.ELECTRON_START_URL || !app.isPackaged;
|
||||
|
||||
// Gateway 地址与用户密钥:环境变量注入(桌面客户端用 user_key 认证)
|
||||
const GATEWAY_URL = (process.env.AGENTMAIL_GATEWAY_URL || 'http://127.0.0.1:8180').replace(/\/+$/, '');
|
||||
const API_BASE = `${GATEWAY_URL}/api/v1`;
|
||||
const TOKEN = process.env.AGENTMAIL_TOKEN || process.env.AGENTMAIL_USER_KEY || '';
|
||||
|
||||
let mainWindow = null;
|
||||
let tray = null;
|
||||
|
||||
/** 创建主窗口 */
|
||||
function createMainWindow() {
|
||||
// 通过 additionalArguments 把 API 基地址与 token 传给 preload(preload 从 renderer 的 process.argv 末尾读)
|
||||
// 传入的是透传 arg,用户页面不能直接改,preload 能读到
|
||||
const injectArgs = [`--agentmail-api-base=${API_BASE}`];
|
||||
if (TOKEN) injectArgs.push(`--agentmail-token=${TOKEN}`);
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 800,
|
||||
minWidth: 375,
|
||||
minHeight: 640,
|
||||
title: 'AgentMail',
|
||||
icon: path.join(__dirname, '..', 'src', 'icons', 'tray-icon.png'),
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
additionalArguments: injectArgs,
|
||||
},
|
||||
});
|
||||
|
||||
if (isDev) {
|
||||
mainWindow.loadURL(DEV_URL);
|
||||
} else {
|
||||
mainWindow.loadFile(path.join(__dirname, '..', 'dist', 'index.html'));
|
||||
}
|
||||
|
||||
// 点关闭按钮时隐藏到托盘而不是退出(Phase 4 行为,但用户要求进托盘,直接做)
|
||||
mainWindow.on('close', (e) => {
|
||||
if (!app.isQuitting) {
|
||||
e.preventDefault();
|
||||
mainWindow.hide();
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
}
|
||||
|
||||
/** 系统托盘(Phase 4,这里是骨架:进托盘 + 重新显示) */
|
||||
function createTray() {
|
||||
// 用 16x16 像素的图标;缺文件时降级为 nativeImage.createEmpty()
|
||||
let iconPath = path.join(__dirname, '..', 'src', 'icons', 'tray-icon.png');
|
||||
let icon = nativeImage.createFromPath(iconPath);
|
||||
if (icon.isEmpty()) {
|
||||
icon = nativeImage.createEmpty();
|
||||
}
|
||||
|
||||
tray = new Tray(icon);
|
||||
tray.setToolTip('AgentMail');
|
||||
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: '打开 AgentMail',
|
||||
click: () => {
|
||||
if (!mainWindow) createMainWindow();
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: '退出',
|
||||
click: () => {
|
||||
app.isQuitting = true;
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
]);
|
||||
tray.setContextMenu(contextMenu);
|
||||
|
||||
// 单击托盘图标显示窗口(Windows 常用行为)
|
||||
tray.on('click', () => {
|
||||
if (!mainWindow) createMainWindow();
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
});
|
||||
}
|
||||
|
||||
// Electron 生命周期
|
||||
app.whenReady().then(() => {
|
||||
createMainWindow();
|
||||
createTray();
|
||||
|
||||
app.on('activate', () => {
|
||||
// macOS 点击 Dock 图标重新建窗口
|
||||
if (BrowserWindow.getAllWindows().length === 0) createMainWindow();
|
||||
else if (mainWindow) mainWindow.show();
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
// 托盘应用:关窗口不退出(macOS 惯例 + 用户要求的"进托盘"行为)
|
||||
// 除非显式退出
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
app.isQuitting = true;
|
||||
});
|
||||
|
||||
// IPC:渲染进程问主进程要 Gateway 地址(Phase 2 用)
|
||||
ipcMain.handle('get-gateway-url', () => {
|
||||
return process.env.AGENTMAIL_GATEWAY_URL || 'http://127.0.0.1:8180';
|
||||
});
|
||||
37
client/electron/electron/preload.cjs
Normal file
37
client/electron/electron/preload.cjs
Normal file
@ -0,0 +1,37 @@
|
||||
/**
|
||||
* AgentMail Electron preload —— 通过 contextBridge 暴露安全 API 给渲染进程。
|
||||
*
|
||||
* 渲染进程是 client/electron/src 的 React,跑在 sandbox: true 下(无 nodeIntegration)。
|
||||
* 需要桌面能力时(文件选择、通知、托盘角标、SSE 转发)走这里暴露的桥。
|
||||
*
|
||||
* Phase 1 只暴露最小集;后续 Phase 逐步加。
|
||||
*/
|
||||
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
// 注入 API 基地址与 token:渲染进程的 config.ts 在读 window.__AGENTMAIL_API_BASE__
|
||||
// 与 __AGENTMAIL_TOKEN__ 时拿到它们,于是现有 client.ts/sse.ts **零改动**复用。
|
||||
// 这两个值由主进程在创建窗口时通过附加参数传进来(见 main.cjs 的 additionalArguments)。
|
||||
function getInjected(name) {
|
||||
const prefix = `--agentmail-${name}=`;
|
||||
const arg = process.argv.find(a => a.startsWith(prefix));
|
||||
return arg ? arg.slice(prefix.length) : undefined;
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('__AGENTMAIL_API_BASE__', getInjected('api-base') || 'http://127.0.0.1:8180/api/v1');
|
||||
contextBridge.exposeInMainWorld('__AGENTMAIL_TOKEN__', getInjected('token') || undefined);
|
||||
|
||||
contextBridge.exposeInMainWorld('agentmail', {
|
||||
/** Gateway 基础地址(主进程 env 或默认 127.0.0.1:8180) */
|
||||
gatewayUrl: () => ipcRenderer.invoke('get-gateway-url'),
|
||||
|
||||
/** 版本信息(渲染进程可显示在 About 页) */
|
||||
versions: {
|
||||
electron: process.versions.electron,
|
||||
chrome: process.versions.chrome,
|
||||
node: process.versions.node,
|
||||
},
|
||||
|
||||
/** 平台标识:win32 / linux / darwin */
|
||||
platform: process.platform,
|
||||
});
|
||||
41
client/electron/index.html
Normal file
41
client/electron/index.html
Normal file
@ -0,0 +1,41 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||
<title>AgentMail</title>
|
||||
<script>
|
||||
/*
|
||||
* 首帧防闪屏。
|
||||
*
|
||||
* JS bundle 有几百 KB,从 HTML 解析完到 React 挂载之间有一段空窗 ——
|
||||
* 那段时间里页面是 body 的默认底色。深色偏好的用户会被闪一下白屏,
|
||||
* 而且是每次刷新都闪。
|
||||
*
|
||||
* 这段脚本必须**内联且同步**:外链或 defer 都会晚于首次绘制。
|
||||
* 逻辑与 themeStore 的 readStored/resolveTheme 一致,
|
||||
* 改那边的键名或取值时这里要同步。
|
||||
*/
|
||||
(function () {
|
||||
try {
|
||||
var p = localStorage.getItem('agentmail.theme') || 'system';
|
||||
var dark =
|
||||
p === 'dark' ||
|
||||
(p === 'system' &&
|
||||
window.matchMedia &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
if (dark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
document.documentElement.style.colorScheme = 'dark';
|
||||
}
|
||||
} catch (e) {
|
||||
/* localStorage 不可用(隐私模式)时退回浅色,不阻塞渲染 */
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-gray-50 text-gray-900 antialiased">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
5473
client/electron/package-lock.json
generated
Normal file
5473
client/electron/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
75
client/electron/package.json
Normal file
75
client/electron/package.json
Normal file
@ -0,0 +1,75 @@
|
||||
{
|
||||
"name": "agentmail-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "electron/main.cjs",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:electron": "ELECTRON_START_URL=http://localhost:5173 electron electron/main.cjs",
|
||||
"build": "vite build",
|
||||
"build:win": "vite build && electron-builder --win",
|
||||
"build:linux": "vite build && electron-builder --linux",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node test/markdown-xss.test.mjs && node test/narrow-layout.test.mjs && node test/theme.test.mjs && vitest run",
|
||||
"test:narrow": "node test/manual/narrow-verify.mjs",
|
||||
"test:wide": "node test/manual/wide-regression.mjs",
|
||||
"test:components": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"lunar-javascript": "1.7.7",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"zustand": "^4.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/dom": "10.4.1",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
"@testing-library/react": "16.3.0",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"electron": "^44.2.0",
|
||||
"electron-builder": "^26.15.3",
|
||||
"jsdom": "26.1.0",
|
||||
"postcss": "^8.4.39",
|
||||
"tailwindcss": "^3.4.6",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.3.4",
|
||||
"vitest": "3.2.4"
|
||||
},
|
||||
"build": {
|
||||
"appId": "xyz.jianfgit.agentmail",
|
||||
"productName": "AgentMail",
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"electron/**/*"
|
||||
],
|
||||
"directories": {
|
||||
"buildResources": "src/icons",
|
||||
"output": "release"
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
{ "target": "nsis", "arch": ["x64"] }
|
||||
]
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
{ "target": "AppImage", "arch": ["x64"] },
|
||||
{ "target": "deb", "arch": ["x64"] }
|
||||
],
|
||||
"category": "Network"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true
|
||||
}
|
||||
}
|
||||
}
|
||||
6
client/electron/postcss.config.js
Normal file
6
client/electron/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
239
client/electron/src/App.tsx
Normal file
239
client/electron/src/App.tsx
Normal 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-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';
|
||||
685
client/electron/src/api/client.ts
Normal file
685
client/electron/src/api/client.ts
Normal file
@ -0,0 +1,685 @@
|
||||
import type { User } from '../types';
|
||||
import type { Agent, Attachment, Contact, HumanSession, Mail, PermissionRequest, SuggestResult, SessionDetail, ThreadPage, RenameProposal, SessionBudget, CalendarEvent, CalendarEventInput, CalendarAttachment } from '../types';
|
||||
import { API_BASE, authHeaders, withToken } from './config';
|
||||
|
||||
export { API_BASE, setToken, getToken, authHeaders, withToken } from './config';
|
||||
|
||||
const BASE = API_BASE;
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
retryAfter?: number;
|
||||
constructor(status: number, message: string, retryAfter?: number) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.retryAfter = retryAfter;
|
||||
}
|
||||
}
|
||||
|
||||
let onUnauthorized: (() => void) | null = null;
|
||||
export function setUnauthorizedHandler(fn: () => void) {
|
||||
onUnauthorized = fn;
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
// Cookie 模式需要 include;带 Bearer 时多发一个 Cookie 也无害
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() }
|
||||
};
|
||||
if (body !== undefined) init.body = JSON.stringify(body);
|
||||
|
||||
const res = await fetch(`${BASE}${path}`, init);
|
||||
|
||||
if (!res.ok) {
|
||||
const payload = await res.json().catch(() => ({ error: res.statusText }));
|
||||
if (res.status === 401 && !path.startsWith('/auth/login') && !path.startsWith('/setup')) {
|
||||
onUnauthorized?.();
|
||||
}
|
||||
throw new ApiError(res.status, payload.error || `HTTP ${res.status}`, payload.retry_after);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ---------- 首次初始化 ----------
|
||||
|
||||
export async function setupStatus() {
|
||||
return request<{ needs_setup: boolean }>('GET', '/setup/status');
|
||||
}
|
||||
|
||||
export async function setupAdmin(payload: {
|
||||
username: string;
|
||||
password: string;
|
||||
display_name?: string;
|
||||
}) {
|
||||
return request<{ user: User }>('POST', '/setup/admin', payload);
|
||||
}
|
||||
|
||||
// ---------- 认证 ----------
|
||||
|
||||
export async function login(username: string, password: string) {
|
||||
return request<{ user: User }>('POST', '/auth/login', { username, password });
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
return request<{ status: string }>('POST', '/auth/logout');
|
||||
}
|
||||
|
||||
export async function me() {
|
||||
return request<{ user: User }>('GET', '/auth/me');
|
||||
}
|
||||
|
||||
export async function changePassword(oldPassword: string, newPassword: string) {
|
||||
return request<{ status: string }>('POST', '/auth/password', {
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 管理员:用户管理 ----------
|
||||
|
||||
export async function adminListUsers() {
|
||||
return request<{ users: User[] }>('GET', '/admin/users');
|
||||
}
|
||||
|
||||
export async function adminCreateUser(payload: {
|
||||
username: string;
|
||||
password: string;
|
||||
display_name?: string;
|
||||
role?: string;
|
||||
allowed_agents?: string[];
|
||||
allowed_paths?: string[];
|
||||
}) {
|
||||
return request<{ user: User }>('POST', '/admin/users', payload);
|
||||
}
|
||||
|
||||
export async function adminUpdateUser(
|
||||
id: string,
|
||||
payload: {
|
||||
display_name?: string;
|
||||
role?: string;
|
||||
status?: string;
|
||||
allowed_agents?: string[];
|
||||
allowed_paths?: string[];
|
||||
}
|
||||
) {
|
||||
return request<{ user: User }>('PUT', `/admin/users/${id}`, payload);
|
||||
}
|
||||
|
||||
export async function adminDisableUser(id: string) {
|
||||
return request<{ status: string }>('DELETE', `/admin/users/${id}`);
|
||||
}
|
||||
|
||||
export async function adminResetPassword(id: string, newPassword: string) {
|
||||
return request<{ status: string }>('POST', `/admin/users/${id}/reset`, {
|
||||
new_password: newPassword
|
||||
});
|
||||
}
|
||||
|
||||
export async function adminListScopes() {
|
||||
const r = await request<{ agents: string[]; paths: string[] }>('GET', '/admin/scopes');
|
||||
return r;
|
||||
}
|
||||
|
||||
// ---------- 密钥 ----------
|
||||
|
||||
export type KeyType = 'permanent' | 'one_time' | 'timed';
|
||||
|
||||
/** 密钥全文 key_token 仅在创建响应里出现一次,列表只给 token_hint。 */
|
||||
export interface AgentKey {
|
||||
key_id: string;
|
||||
key_token?: string;
|
||||
token_hint: string;
|
||||
agent_name: string | null;
|
||||
key_type: KeyType;
|
||||
label: string;
|
||||
expires_at: string | null;
|
||||
used_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface UserKey {
|
||||
key_id: string;
|
||||
key_token?: string;
|
||||
token_hint: string;
|
||||
label: string;
|
||||
key_type: KeyType;
|
||||
expires_at: string | null;
|
||||
used_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateKeyPayload {
|
||||
key_type: KeyType;
|
||||
label?: string;
|
||||
/** 仅 timed 需要 */
|
||||
expires_hours?: number;
|
||||
/** 仅 Agent 密钥:留空 = 待绑定,首次注册时落定 */
|
||||
agent_name?: string;
|
||||
/** 仅 Agent 密钥:登记客户端已在本地生成的密钥 */
|
||||
key_token?: string;
|
||||
}
|
||||
|
||||
export async function adminListAgentKeys(agentName?: string) {
|
||||
const q = agentName ? `?agent_name=${encodeURIComponent(agentName)}` : '';
|
||||
return request<{ keys: AgentKey[] }>('GET', `/admin/agent-keys${q}`);
|
||||
}
|
||||
|
||||
export async function adminCreateAgentKey(payload: CreateKeyPayload) {
|
||||
return request<{ key: AgentKey }>('POST', '/admin/agent-keys', payload);
|
||||
}
|
||||
|
||||
export async function adminDeleteAgentKey(id: string) {
|
||||
return request<{ status: string }>('DELETE', `/admin/agent-keys/${id}`);
|
||||
}
|
||||
|
||||
export async function adminBindAgentKey(id: string, agentName: string) {
|
||||
return request<{ status: string; agent_name: string }>(
|
||||
'POST',
|
||||
`/admin/agent-keys/${id}/bind`,
|
||||
{ agent_name: agentName }
|
||||
);
|
||||
}
|
||||
|
||||
export async function listMyKeys() {
|
||||
return request<{ keys: UserKey[] }>('GET', '/me/keys');
|
||||
}
|
||||
|
||||
export async function createMyKey(payload: CreateKeyPayload) {
|
||||
return request<{ key: UserKey }>('POST', '/me/keys', payload);
|
||||
}
|
||||
|
||||
export async function deleteMyKey(id: string) {
|
||||
return request<{ status: string }>('DELETE', `/me/keys/${id}`);
|
||||
}
|
||||
|
||||
// ---------- Agents ----------
|
||||
|
||||
export async function listAgents(status?: string) {
|
||||
const q = status ? `?status=${encodeURIComponent(status)}` : '';
|
||||
return request<{ agents: Agent[] }>('GET', `/agents${q}`);
|
||||
}
|
||||
|
||||
// ---------- 自己的邮箱 ----------
|
||||
|
||||
export interface SendMailOpts {
|
||||
cc?: string;
|
||||
reply_to?: string;
|
||||
session_alias?: string;
|
||||
attachment_ids?: string[];
|
||||
max_rounds?: number;
|
||||
/** 权限档位(仅新建会话时生效):plan / workspace / full */
|
||||
permission_mode?: string;
|
||||
}
|
||||
|
||||
export async function sendMail(
|
||||
to: string,
|
||||
subject: string,
|
||||
body: string,
|
||||
opts: SendMailOpts = {}
|
||||
) {
|
||||
return request<{
|
||||
mail_id: string;
|
||||
session_id: string;
|
||||
session_alias: string;
|
||||
budget_max?: number;
|
||||
budget_used?: number;
|
||||
budget_remaining?: number;
|
||||
}>('POST', '/me/mail/send', {
|
||||
to,
|
||||
subject,
|
||||
body,
|
||||
cc: opts.cc ?? '',
|
||||
reply_to: opts.reply_to ?? '',
|
||||
session_alias: opts.session_alias ?? '',
|
||||
attachment_ids: opts.attachment_ids ?? [],
|
||||
// null 而非 0:0 是「不限」的合法取值,省略才表示「不设置」
|
||||
max_rounds: opts.max_rounds ?? null,
|
||||
permission_mode: opts.permission_mode ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
export async function getInbox(status = 'all', limit = 50) {
|
||||
return request<{ mails: Mail[]; total: number }>(
|
||||
'GET',
|
||||
`/me/mail/inbox?status=${encodeURIComponent(status)}&limit=${limit}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function getSent() {
|
||||
return request<{ mails: Mail[] }>('GET', '/me/mail/sent');
|
||||
}
|
||||
|
||||
export async function getMail(id: string) {
|
||||
return request<Mail>('GET', `/mail/${id}`);
|
||||
}
|
||||
|
||||
export async function markMailRead(id: string) {
|
||||
return request<{ status: string }>('POST', `/mail/${id}/read`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取线索的一块。
|
||||
*
|
||||
* dir=around 首屏(锚点 + 部分祖先 + 部分子孙),up/down 配 offset 增量加载。
|
||||
* 树可跨会话,服务端按会话逐个鉴权,看不到的节点不返回并计入 hidden。
|
||||
*/
|
||||
export async function getMailThread(
|
||||
id: string,
|
||||
opts: { offset?: number; limit?: number } = {}
|
||||
) {
|
||||
const q = new URLSearchParams();
|
||||
if (opts.offset !== undefined) q.set('offset', String(opts.offset));
|
||||
if (opts.limit !== undefined) q.set('limit', String(opts.limit));
|
||||
const qs = q.toString();
|
||||
return request<ThreadPage>('GET', `/mail/${id}/thread${qs ? '?' + qs : ''}`);
|
||||
}
|
||||
|
||||
// ---------- 附件 ----------
|
||||
|
||||
/**
|
||||
* 上传附件,返回 attachment_id。
|
||||
*
|
||||
* 不能走 request():那里固定 Content-Type: application/json,
|
||||
* 而 multipart 必须让浏览器自己带 boundary。
|
||||
*/
|
||||
export async function uploadAttachment(file: File, onProgress?: (pct: number) => void) {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
|
||||
// 需要进度就用 XHR —— fetch 至今没有上传进度事件
|
||||
if (onProgress) {
|
||||
return new Promise<{ attachment: Attachment }>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', `${BASE}/me/attachments`);
|
||||
xhr.withCredentials = true;
|
||||
for (const [k, v] of Object.entries(authHeaders())) xhr.setRequestHeader(k, v);
|
||||
xhr.upload.onprogress = e => {
|
||||
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
|
||||
};
|
||||
xhr.onload = () => {
|
||||
let payload: { attachment?: Attachment; error?: string } = {};
|
||||
try {
|
||||
payload = JSON.parse(xhr.responseText);
|
||||
} catch {
|
||||
/* 非 JSON 响应按状态码处理 */
|
||||
}
|
||||
if (xhr.status >= 200 && xhr.status < 300 && payload.attachment) {
|
||||
resolve({ attachment: payload.attachment });
|
||||
} else {
|
||||
if (xhr.status === 401) onUnauthorized?.();
|
||||
reject(new ApiError(xhr.status, payload.error || `HTTP ${xhr.status}`));
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => reject(new ApiError(0, '网络错误'));
|
||||
xhr.send(form);
|
||||
});
|
||||
}
|
||||
|
||||
const res = await fetch(`${BASE}/me/attachments`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
// 不设 Content-Type:multipart 的 boundary 要交给浏览器生成
|
||||
headers: authHeaders(),
|
||||
body: form
|
||||
});
|
||||
if (!res.ok) {
|
||||
const payload = await res.json().catch(() => ({ error: res.statusText }));
|
||||
if (res.status === 401) onUnauthorized?.();
|
||||
throw new ApiError(res.status, payload.error || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<{ attachment: Attachment }>;
|
||||
}
|
||||
|
||||
export async function deleteAttachment(id: string) {
|
||||
return request<{ status: string }>('DELETE', `/me/attachments/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 附件下载链接。由浏览器直接发起(<a download>),因此无法带 Authorization 头:
|
||||
* Cookie 模式靠同源 Cookie,密钥模式回退到 ?access_token=。
|
||||
*/
|
||||
export function attachmentURL(id: string) {
|
||||
return withToken(`${BASE}/me/attachments/${id}`);
|
||||
}
|
||||
|
||||
/** 人类可读的字节数 */
|
||||
export function formatSize(n: number) {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export interface ForwardPayload {
|
||||
/** 新收件人的三维地址 */
|
||||
to: string;
|
||||
/** 转发说明,置于引用原文之前 */
|
||||
comment?: string;
|
||||
cc?: string;
|
||||
/** 留空则自动加 Fwd: 前缀 */
|
||||
subject?: string;
|
||||
/** 仅当 to 以 .new 结尾时生效 */
|
||||
session_alias?: string;
|
||||
}
|
||||
|
||||
export async function forwardMail(id: string, payload: ForwardPayload) {
|
||||
return request<{
|
||||
mail_id: string;
|
||||
session_id: string;
|
||||
session_alias: string;
|
||||
forwarded_from: string;
|
||||
}>('POST', `/me/mail/${id}/forward`, {
|
||||
to: payload.to,
|
||||
comment: payload.comment ?? '',
|
||||
cc: payload.cc ?? '',
|
||||
subject: payload.subject ?? '',
|
||||
session_alias: payload.session_alias ?? ''
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 配额 ----------
|
||||
|
||||
/**
|
||||
* Agent 的新任务默认预算与累计统计。
|
||||
*
|
||||
* 没有「剩余额度」字段 —— 额度属于具体任务(会话),见 SessionBudget。
|
||||
* 这里只有「派给它的新任务默认几个来回」与「一共发了多少信」。
|
||||
*/
|
||||
export interface AgentStats {
|
||||
agent_name: string;
|
||||
/** 派给该 Agent 的新任务默认多少个来回(0 = 不限) */
|
||||
default_rounds: number;
|
||||
/** 累计发信数,纯统计,不拦请求 */
|
||||
sent_total: number;
|
||||
/** 参与的未归档会话数,配合默认值判断设多少合适 */
|
||||
active_sessions: number;
|
||||
/** online / offline / disabled —— 管理页靠它区分状态并决定显示停用/恢复按钮 */
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export async function adminListAgentStats() {
|
||||
return request<{ quotas: AgentStats[] }>('GET', '/admin/quotas');
|
||||
}
|
||||
|
||||
/** 停用(true)或恢复(false)一个 Agent。返回撤销的密钥数等信息。 */
|
||||
export async function adminSetAgentStatus(agentName: string, disabled: boolean) {
|
||||
return request<{
|
||||
agent_name: string;
|
||||
disabled: boolean;
|
||||
keys_revoked?: number;
|
||||
/** 恢复路径为 true:停用时撤销的密钥不会自动回来,必须重新签发 */
|
||||
needs_new_key?: boolean;
|
||||
detail: string;
|
||||
}>(
|
||||
'PUT',
|
||||
`/admin/agents/${encodeURIComponent(agentName)}/status`,
|
||||
{ disabled }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 彻底删除一个 Agent。保留邮件历史与会话,清除密钥、镜像、日历。
|
||||
* 名字今后不可再注册(防止同名新注册冒用历史署名)。
|
||||
*/
|
||||
export async function adminDeleteAgent(agentName: string) {
|
||||
return request<{
|
||||
agent_name: string;
|
||||
keys_revoked: number;
|
||||
detail: string;
|
||||
}>(
|
||||
'DELETE',
|
||||
`/admin/agents/${encodeURIComponent(agentName)}`
|
||||
);
|
||||
}
|
||||
|
||||
/** 改该 Agent 的新任务默认预算(0 = 不限)。 */
|
||||
export async function adminSetDefaultRounds(agentName: string, defaultRounds: number) {
|
||||
return request<{ quota: AgentStats }>(
|
||||
'PUT',
|
||||
`/admin/quotas/${encodeURIComponent(agentName)}`,
|
||||
{ default_rounds: defaultRounds }
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 邮件场景下可用的模型范围 ----------
|
||||
|
||||
/** 平台上报的一个模型,带「是否已被选入」标记。 */
|
||||
export interface CatalogModel {
|
||||
provider: string;
|
||||
model: string;
|
||||
display_name?: string;
|
||||
allowed: boolean;
|
||||
/** 仅 allowed 为真时有意义,越小越先试 */
|
||||
rank?: number;
|
||||
}
|
||||
|
||||
export interface ModelRoute {
|
||||
provider: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读某 Agent 的模型目录。
|
||||
*
|
||||
* `stale` 是「已选但平台当前目录里没有」的那些 —— 平台可能临时下线了某个模型,
|
||||
* 而管理员的选择是持久的。界面上不显示会让人以为自己没选过它。
|
||||
*/
|
||||
export async function adminGetAgentModels(agentName: string) {
|
||||
return request<{ agent_name: string; catalog: CatalogModel[]; stale: ModelRoute[] }>(
|
||||
'GET',
|
||||
`/admin/agents/${encodeURIComponent(agentName)}/models`
|
||||
);
|
||||
}
|
||||
|
||||
/** 保存选择。数组顺序即优先级(插件按这个顺序降级尝试)。 */
|
||||
export async function adminSetAgentModels(agentName: string, models: ModelRoute[]) {
|
||||
return request<{ status: string; models: ModelRoute[] }>(
|
||||
'PUT',
|
||||
`/admin/agents/${encodeURIComponent(agentName)}/models`,
|
||||
{ models }
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Sessions ----------
|
||||
|
||||
export async function getHumanSessions() {
|
||||
return request<{ sessions: HumanSession[] }>('GET', '/me/sessions');
|
||||
}
|
||||
|
||||
export async function getSessionDetail(id: string) {
|
||||
return request<SessionDetail>('GET', `/sessions/${id}`);
|
||||
}
|
||||
|
||||
export async function updateSessionAlias(id: string, alias: string) {
|
||||
return request<{ status: string; alias: string }>('PUT', `/sessions/${id}/alias`, { alias });
|
||||
}
|
||||
|
||||
/**
|
||||
* 取该会话里最新一条尚未处理的改名提议(Agent 在邮件正文里提的)。
|
||||
* 已接受(提议就是当前别名)或已驳回的不再返回。
|
||||
*/
|
||||
export async function getRenameProposal(id: string) {
|
||||
return request<{ proposal: RenameProposal | null }>('GET', `/sessions/${id}/rename-proposal`);
|
||||
}
|
||||
|
||||
/** 本会话(= 本任务)的往返预算。 */
|
||||
export async function getSessionBudget(id: string) {
|
||||
return request<SessionBudget>('GET', `/sessions/${id}/budget`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 改本会话的往返预算。
|
||||
*
|
||||
* max_rounds = 0 表示不限;reset 把已用次数归零。两者可同时给
|
||||
* (「加到 20 并从头算」是一次很自然的操作,拆成两个请求只会多一次往返)。
|
||||
*/
|
||||
export async function updateSessionBudget(
|
||||
id: string,
|
||||
patch: { max_rounds?: number; reset?: boolean }
|
||||
) {
|
||||
return request<SessionBudget>('PUT', `/sessions/${id}/budget`, patch);
|
||||
}
|
||||
|
||||
export async function updateSessionPermission(
|
||||
id: string,
|
||||
permission_mode: string
|
||||
) {
|
||||
return request<{ permission_mode: string; permission_enforcement: string }>(
|
||||
'PUT', `/sessions/${id}/permission`, { permission_mode }
|
||||
);
|
||||
}
|
||||
|
||||
/** 驳回当前提议。记下来,提示条不再反复弹同一个建议。 */
|
||||
export async function dismissRenameProposal(id: string) {
|
||||
return request<{ status: string; dismissed?: string }>(
|
||||
'POST',
|
||||
`/sessions/${id}/rename-proposal/dismiss`
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Contacts ----------
|
||||
|
||||
export async function listContacts(archived = false) {
|
||||
return request<{ contacts: Contact[] }>('GET', `/contacts?archived=${archived}`);
|
||||
}
|
||||
|
||||
export async function suggestAddress(name?: string, path?: string) {
|
||||
const p = new URLSearchParams();
|
||||
if (name) p.set('name', name);
|
||||
if (path) p.set('path', path);
|
||||
return request<SuggestResult>('GET', `/contacts/suggest?${p.toString()}`);
|
||||
}
|
||||
|
||||
export async function archiveContact(payload: { address?: string; session_id?: string }) {
|
||||
return request<{ status: string; session_id: string; session_alias: string }>(
|
||||
'POST',
|
||||
'/contacts/archive',
|
||||
payload
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Permission ----------
|
||||
|
||||
export async function decidePermission(mailId: string, decision: string, note?: string) {
|
||||
return request<{ status: string; decision_mail_id: string }>('POST', '/permission/decide', {
|
||||
mail_id: mailId,
|
||||
decision,
|
||||
note: note ?? ''
|
||||
});
|
||||
}
|
||||
|
||||
export async function listPendingPermissions() {
|
||||
return request<{ requests: PermissionRequest[] }>('GET', '/permission/pending');
|
||||
}
|
||||
|
||||
// ---------- Calendar ----------
|
||||
|
||||
/**
|
||||
* 列事件。
|
||||
*
|
||||
* from/to 是 ISO 8601 区间;省略时后端给「前一个月到后一个月」。
|
||||
* 月视图要显示跨月的首尾几天,所以查询区间必须按**格子**算而不是按月首月末算。
|
||||
*/
|
||||
export async function listCalendarEvents(opts: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
status?: string;
|
||||
} = {}) {
|
||||
const p = new URLSearchParams();
|
||||
if (opts.from) p.set('from', opts.from);
|
||||
if (opts.to) p.set('to', opts.to);
|
||||
if (opts.status) p.set('status', opts.status);
|
||||
const qs = p.toString();
|
||||
return request<{ events: CalendarEvent[] }>('GET', `/calendar/events${qs ? '?' + qs : ''}`);
|
||||
}
|
||||
|
||||
export async function getCalendarEvent(id: string) {
|
||||
return request<CalendarEvent>('GET', `/calendar/events/${id}`);
|
||||
}
|
||||
|
||||
export async function createCalendarEvent(input: CalendarEventInput) {
|
||||
return request<CalendarEvent>('POST', '/calendar/events', input);
|
||||
}
|
||||
|
||||
export async function updateCalendarEvent(id: string, input: Partial<CalendarEventInput>) {
|
||||
return request<CalendarEvent>('PUT', `/calendar/events/${id}`, input);
|
||||
}
|
||||
|
||||
export async function deleteCalendarEvent(id: string) {
|
||||
return request<{ status: string }>('DELETE', `/calendar/events/${id}`);
|
||||
}
|
||||
|
||||
export async function listCalendarAttachments(id: string) {
|
||||
return request<{ attachments: CalendarAttachment[] }>(
|
||||
'GET',
|
||||
`/calendar/events/${id}/attachments`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出 .ics。
|
||||
*
|
||||
* 不走 request():那个函数假定响应是 JSON,而这里是 text/calendar。
|
||||
* 返回文本由调用方自己触发下载 —— 用 Blob 而不是让浏览器直接导航,
|
||||
* 因为导航会丢掉 cookie 之外的认证头(附件下载同理,见 withToken)。
|
||||
*/
|
||||
export async function exportCalendarICS(from?: string, to?: string): Promise<string> {
|
||||
const p = new URLSearchParams();
|
||||
if (from) p.set('from', from);
|
||||
if (to) p.set('to', to);
|
||||
const qs = p.toString();
|
||||
const res = await fetch(`${API_BASE}/calendar/export.ics${qs ? '?' + qs : ''}`, {
|
||||
headers: authHeaders(),
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new ApiError(res.status, `导出失败:HTTP ${res.status}`);
|
||||
}
|
||||
return res.text();
|
||||
}
|
||||
|
||||
/** 导入 .ics。整份文本原样 POST,后端解析 VEVENT。 */
|
||||
export async function importCalendarICS(ics: string) {
|
||||
const res = await fetch(`${API_BASE}/calendar/import.ics`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'text/calendar', ...authHeaders() },
|
||||
credentials: 'include',
|
||||
body: ics
|
||||
});
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new ApiError(res.status, payload.error || `导入失败:HTTP ${res.status}`);
|
||||
}
|
||||
return payload as { imported: number; skipped: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* 日历事件附件:上传。
|
||||
*
|
||||
* 与邮件附件走不同的存储关联(calendar_attachments 表),因此不能复用
|
||||
* uploadAttachment —— 那个挂到 mail_id 上,日历要挂到 event_id 上。
|
||||
* 事件必须先存在,所以新建流程是「先创建事件拿到 event_id,再传附件」。
|
||||
*/
|
||||
export async function uploadCalendarAttachment(eventId: string, file: File) {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const res = await fetch(`${API_BASE}/calendar/events/${eventId}/attachments`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
credentials: 'include',
|
||||
body: form
|
||||
});
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new ApiError(res.status, payload.error || `上传失败:HTTP ${res.status}`);
|
||||
}
|
||||
return payload as CalendarAttachment;
|
||||
}
|
||||
|
||||
export async function deleteCalendarAttachment(attachmentId: string) {
|
||||
return request<{ status: string; attachment_id: string }>(
|
||||
'DELETE',
|
||||
`/calendar/attachments/${attachmentId}`
|
||||
);
|
||||
}
|
||||
68
client/electron/src/api/config.ts
Normal file
68
client/electron/src/api/config.ts
Normal file
@ -0,0 +1,68 @@
|
||||
/**
|
||||
* API 接入配置。
|
||||
*
|
||||
* WebUI 与第三方客户端调用的是**同一套 WebAPI**,差别只在两点:
|
||||
* 1. 基地址:内嵌在 Gateway 里时是同源的 /api/v1;独立部署的客户端需要指向具体主机
|
||||
* 2. 凭证:浏览器用登录 Cookie;第三方客户端用用户密钥(Authorization: Bearer)
|
||||
*
|
||||
* 这两点都在此处集中配置,业务代码不感知差异 —— 这样把 src/api/ 整个抽成 SDK 时
|
||||
* 不需要改任何调用点。
|
||||
*/
|
||||
|
||||
/** 运行时注入点:宿主页面可在加载 bundle 前设置这两个全局量 */
|
||||
declare global {
|
||||
interface Window {
|
||||
__AGENTMAIL_API_BASE__?: string;
|
||||
__AGENTMAIL_TOKEN__?: string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 基地址优先级:运行时全局 > 构建期环境变量 > 同源默认值。
|
||||
*
|
||||
* 运行时优先是为了让同一份构建产物能部署到不同后端(容器镜像不必按环境重打)。
|
||||
*/
|
||||
function resolveBase(): string {
|
||||
const runtime = typeof window !== 'undefined' ? window.__AGENTMAIL_API_BASE__ : undefined;
|
||||
const build = import.meta.env?.VITE_API_BASE as string | undefined;
|
||||
const base = (runtime || build || '/api/v1').trim();
|
||||
// 统一去掉尾部斜杠,拼接时只在 path 侧带前导斜杠
|
||||
return base.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export const API_BASE = resolveBase();
|
||||
|
||||
/** 当前用于 Authorization 头的令牌;空表示走 Cookie。 */
|
||||
let bearerToken: string | null =
|
||||
(typeof window !== 'undefined' ? window.__AGENTMAIL_TOKEN__ : undefined) ?? null;
|
||||
|
||||
/**
|
||||
* 设置用户密钥。第三方客户端在启动时调用一次即可,
|
||||
* 之后所有请求(含 SSE 与附件下载)自动带上。
|
||||
*/
|
||||
export function setToken(token: string | null) {
|
||||
bearerToken = token && token.trim() !== '' ? token.trim() : null;
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
return bearerToken;
|
||||
}
|
||||
|
||||
/** 认证请求头。用 Cookie 时返回空对象。 */
|
||||
export function authHeaders(): Record<string, string> {
|
||||
return bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 给 URL 附加认证信息,供无法设置请求头的场景使用:
|
||||
* - EventSource(SSE)不支持自定义头
|
||||
* - <a download> / <img src> 由浏览器直接发起
|
||||
*
|
||||
* 服务端仅在 SSE 与附件下载这两处接受 ?access_token=,
|
||||
* 其余接口一律要求请求头 —— URL 里的令牌会进访问日志。
|
||||
*/
|
||||
export function withToken(url: string): string {
|
||||
if (!bearerToken) return url;
|
||||
const sep = url.includes('?') ? '&' : '?';
|
||||
return `${url}${sep}access_token=${encodeURIComponent(bearerToken)}`;
|
||||
}
|
||||
109
client/electron/src/api/sse.ts
Normal file
109
client/electron/src/api/sse.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import { API_BASE, withToken } from './config';
|
||||
|
||||
export type SSEEventHandler = (eventType: string, data: Record<string, unknown>) => void;
|
||||
export type SSEStatus = 'connecting' | 'connected' | 'disconnected' | 'reconnecting';
|
||||
|
||||
const EVENTS = [
|
||||
'new_mail',
|
||||
'permission_decision',
|
||||
'session_update',
|
||||
'session_archived',
|
||||
'agent_online'
|
||||
] as const;
|
||||
|
||||
let es: EventSource | null = null;
|
||||
let handlers: SSEEventHandler[] = [];
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let backoff = 1000;
|
||||
let _status: SSEStatus = 'disconnected';
|
||||
let statusHandlers: Array<(s: SSEStatus) => void> = [];
|
||||
|
||||
/** 监听 SSE 连接状态变化 */
|
||||
export function onSSEStatus(handler: (s: SSEStatus) => void): () => void {
|
||||
statusHandlers.push(handler);
|
||||
return () => {
|
||||
statusHandlers = statusHandlers.filter(h => h !== handler);
|
||||
};
|
||||
}
|
||||
|
||||
/** 当前 SSE 连接状态 */
|
||||
export function getSSEStatus(): SSEStatus {
|
||||
return _status;
|
||||
}
|
||||
|
||||
function setStatus(s: SSEStatus) {
|
||||
if (_status === s) return;
|
||||
_status = s;
|
||||
statusHandlers.forEach(h => h(s));
|
||||
}
|
||||
|
||||
export function connectSSE(onEvent: SSEEventHandler): () => void {
|
||||
handlers.push(onEvent);
|
||||
if (!es) open();
|
||||
|
||||
return () => {
|
||||
handlers = handlers.filter(h => h !== onEvent);
|
||||
if (handlers.length === 0) close();
|
||||
};
|
||||
}
|
||||
|
||||
function open() {
|
||||
close(false);
|
||||
setStatus('connecting');
|
||||
// EventSource 无法设置请求头:Cookie 模式靠同源 Cookie,
|
||||
// 密钥模式只能把令牌放进 query(服务端仅此端点与附件下载接受 ?access_token=)。
|
||||
es = new EventSource(withToken(`${API_BASE}/events/stream`), { withCredentials: true });
|
||||
|
||||
// EventSource 会自动重连,但它的 readyState 在网络断开时
|
||||
// 不一定及时反映状态。用 onopen 判断实际连上了。
|
||||
es.onopen = () => {
|
||||
backoff = 1000;
|
||||
setStatus('connected');
|
||||
};
|
||||
|
||||
es.addEventListener('connected', () => {
|
||||
backoff = 1000;
|
||||
setStatus('connected');
|
||||
});
|
||||
|
||||
for (const name of EVENTS) {
|
||||
es.addEventListener(name, (e: MessageEvent) => {
|
||||
let data: Record<string, unknown> = {};
|
||||
try {
|
||||
data = JSON.parse(e.data);
|
||||
} catch {
|
||||
/* 忽略非 JSON 负载 */
|
||||
}
|
||||
handlers.forEach(h => h(name, data));
|
||||
});
|
||||
}
|
||||
|
||||
es.onerror = () => {
|
||||
close(false);
|
||||
if (handlers.length === 0) return;
|
||||
if (retryTimer) return;
|
||||
setStatus('reconnecting');
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = null;
|
||||
backoff = Math.min(backoff * 2, 15000);
|
||||
open();
|
||||
}, backoff);
|
||||
};
|
||||
}
|
||||
|
||||
function close(clearHandlers = true) {
|
||||
if (retryTimer) {
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = null;
|
||||
}
|
||||
if (es) {
|
||||
es.close();
|
||||
es = null;
|
||||
}
|
||||
if (clearHandlers) handlers = [];
|
||||
setStatus('disconnected');
|
||||
}
|
||||
|
||||
export function disconnectSSE() {
|
||||
close();
|
||||
}
|
||||
248
client/electron/src/components/AccountPage.tsx
Normal file
248
client/electron/src/components/AccountPage.tsx
Normal file
@ -0,0 +1,248 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import * as api from '../api/client';
|
||||
import { LockIcon, LogoutIcon } from './icons';
|
||||
import KeyPanel from './KeyPanel';
|
||||
import ThemePicker from './ThemePicker';
|
||||
|
||||
/** 当前用户个人中心:查看资料、修改密码、管理客户端连接密钥 */
|
||||
export default function AccountPage() {
|
||||
const user = useAuthStore(s => s.user);
|
||||
const logout = useAuthStore(s => s.logout);
|
||||
const [oldPw, setOldPw] = useState('');
|
||||
const [newPw, setNewPw] = useState('');
|
||||
const [confirmPw, setConfirmPw] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 密钥面板状态
|
||||
const [keys, setKeys] = useState<api.UserKey[]>([]);
|
||||
const [keyBusy, setKeyBusy] = useState(false);
|
||||
const [keyError, setKeyError] = useState<string | null>(null);
|
||||
const [newToken, setNewToken] = useState<string | null>(null);
|
||||
|
||||
const loadKeys = useCallback(async () => {
|
||||
try {
|
||||
const r = await api.listMyKeys();
|
||||
setKeys(r.keys);
|
||||
setKeyError(null);
|
||||
} catch (err) {
|
||||
setKeyError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadKeys();
|
||||
}, [loadKeys]);
|
||||
|
||||
const createKey = async (payload: api.CreateKeyPayload) => {
|
||||
setKeyBusy(true);
|
||||
setKeyError(null);
|
||||
try {
|
||||
const r = await api.createMyKey(payload);
|
||||
// 全文只在创建响应里出现一次,必须当场展示
|
||||
setNewToken(r.key.key_token ?? null);
|
||||
await loadKeys();
|
||||
} catch (err) {
|
||||
setKeyError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setKeyBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteKey = async (id: string) => {
|
||||
setKeyError(null);
|
||||
try {
|
||||
await api.deleteMyKey(id);
|
||||
await loadKeys();
|
||||
} catch (err) {
|
||||
setKeyError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const mismatch = confirmPw !== '' && newPw !== confirmPw;
|
||||
const canSubmit = oldPw.length > 0 && newPw.length >= 8 && !mismatch && !busy;
|
||||
|
||||
const changePw = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setMsg(null);
|
||||
try {
|
||||
await api.changePassword(oldPw, newPw);
|
||||
setMsg('密码已修改,请重新登录');
|
||||
setOldPw('');
|
||||
setNewPw('');
|
||||
setConfirmPw('');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-w-0 flex flex-col bg-white">
|
||||
<div className="shrink-0 px-4 md:px-6 py-3 border-b border-gray-200 flex items-center gap-2">
|
||||
<h2 className="text-sm font-semibold text-gray-900">账号信息</h2>
|
||||
</div>
|
||||
|
||||
{/* 滚动容器。
|
||||
缺了它的后果:这个页的内容(资料 + 权限 + 改密码 + 密钥 + 退出)
|
||||
比视口高,而父级是 overflow-hidden 的 flex 列 —— 超出那段直接被裁掉,
|
||||
没有任何办法滚到。实测 390px 下内容需 860px、容器只有 795px;
|
||||
1280x800 的桌面上同样看不到最后的「退出登录」。 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-lg px-4 md:px-6 py-6 space-y-6">
|
||||
{/* 基本信息 */}
|
||||
<section>
|
||||
<h3 className="text-xs font-medium text-gray-500 mb-3">基本资料</h3>
|
||||
<dl className="text-sm space-y-2">
|
||||
<Row label="用户名" value={user.username} mono />
|
||||
<Row label="显示名" value={user.display_name} />
|
||||
<Row label="角色" value={user.role === 'admin' ? '管理员' : '普通用户'} />
|
||||
<Row label="状态" value={user.status === 'active' ? '启用' : '禁用'} />
|
||||
<Row label="创建时间" value={user.created_at || '-'} />
|
||||
<Row label="最后登录" value={user.last_login || '从未登录'} />
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{/* 权限边界 */}
|
||||
{user.role !== 'admin' && (
|
||||
<section>
|
||||
<h3 className="text-xs font-medium text-gray-500 mb-3">权限范围</h3>
|
||||
<dl className="text-sm space-y-2">
|
||||
<Row
|
||||
label="可调用 Agent"
|
||||
value={
|
||||
user.allowed_agents.length === 0
|
||||
? '不限(全部可用)'
|
||||
: user.allowed_agents.join(', ')
|
||||
}
|
||||
/>
|
||||
<Row
|
||||
label="可访问目录"
|
||||
value={
|
||||
user.allowed_paths.length === 0
|
||||
? '不限(全部可用)'
|
||||
: user.allowed_paths.join(', ')
|
||||
}
|
||||
/>
|
||||
</dl>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 修改密码 */}
|
||||
<section>
|
||||
<h3 className="text-xs font-medium text-gray-500 mb-3 inline-flex items-center gap-1">
|
||||
<LockIcon className="w-3.5 h-3.5" />
|
||||
修改密码
|
||||
</h3>
|
||||
<form onSubmit={changePw} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1">当前密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={oldPw}
|
||||
onChange={e => setOldPw(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
className="w-full text-sm border border-gray-300 rounded-md px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1">新密码(至少 8 位)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPw}
|
||||
onChange={e => setNewPw(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
className="w-full text-sm border border-gray-300 rounded-md px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1">确认新密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPw}
|
||||
onChange={e => setConfirmPw(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
className={`w-full text-sm border rounded-md px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 ${
|
||||
mismatch ? 'border-red-300' : 'border-gray-300 focus:border-blue-400'
|
||||
}`}
|
||||
/>
|
||||
{mismatch && <p className="mt-1 text-[10px] text-red-500">两次密码不一致</p>}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-600 bg-red-50 border border-red-100 rounded-md px-2.5 py-1.5">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{msg && (
|
||||
<p className="text-xs text-green-600 bg-green-50 border border-green-100 rounded-md px-2.5 py-1.5">
|
||||
{msg}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="px-4 py-1.5 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{busy ? '保存中' : '保存'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* 客户端连接密钥 */}
|
||||
<section className="border-t border-gray-200 pt-6">
|
||||
<KeyPanel
|
||||
variant="user"
|
||||
keys={keys}
|
||||
loading={keyBusy}
|
||||
error={keyError}
|
||||
newToken={newToken}
|
||||
onCreate={createKey}
|
||||
onDelete={deleteKey}
|
||||
onDismissToken={() => setNewToken(null)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* 外观。放在密钥之后、退出之前:它是一个高频且完全可逆的偏好,
|
||||
与「账号自身」的密码/密钥属于不同性质,但同样是「我的设置」。 */}
|
||||
<section className="border-t border-gray-200 pt-6">
|
||||
<ThemePicker />
|
||||
</section>
|
||||
|
||||
{/* 退出登录。
|
||||
放在这里而不是导航里:它是一个低频且不可逆的动作,
|
||||
与密码、密钥同属「账号自身」。窄屏下这也是唯一的退出口:
|
||||
抽屉式侧栏已删(它的其余入口与底部导航完全重复)。 */}
|
||||
<section className="border-t border-gray-200 pt-6">
|
||||
<h3 className="text-xs font-medium text-gray-500 mb-3">登录状态</h3>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="tap inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-md border border-gray-300 text-gray-700 hover:bg-gray-50 active:bg-gray-100 transition-colors"
|
||||
>
|
||||
<LogoutIcon className="w-4 h-4" />
|
||||
退出登录
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-baseline gap-3">
|
||||
<dt className="w-20 shrink-0 text-xs text-gray-400">{label}</dt>
|
||||
<dd className={`text-sm text-gray-900 break-all ${mono ? 'font-mono' : ''}`}>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
265
client/electron/src/components/AddressInput.tsx
Normal file
265
client/electron/src/components/AddressInput.tsx
Normal file
@ -0,0 +1,265 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import * as api from '../api/client';
|
||||
import type { SessionCandidate } from '../types';
|
||||
|
||||
/**
|
||||
* 三段式地址输入:name -> @path -> .session
|
||||
* 每段都向 /contacts/suggest 询问候选,未命中时也允许自由输入。
|
||||
* 值本身始终是完整字符串 name@path.session。
|
||||
*
|
||||
* session 段的候选带标题与来源标记:一个工作区下可能有十几条会话,
|
||||
* 光看 brisk-harbor / witty-planet 这类随机短名分不出哪条在谈什么。
|
||||
*/
|
||||
export default function AddressInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
allowMultiple = false,
|
||||
autoFocus = false
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
/** 抄送场景:允许逗号分隔多个地址,补全只作用于最后一段 */
|
||||
allowMultiple?: boolean;
|
||||
autoFocus?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [items, setItems] = useState<string[]>([]);
|
||||
// session 段的富候选,与 items 同序。其他段为空数组。
|
||||
const [meta, setMeta] = useState<SessionCandidate[]>([]);
|
||||
const [kind, setKind] = useState<'name' | 'path' | 'session'>('name');
|
||||
const [active, setActive] = useState(0);
|
||||
const [menuLayout, setMenuLayout] = useState({ flip: false, maxHeight: 288 });
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 当前正在编辑的那一段(多地址时取最后一段)
|
||||
const { head, editing } = useMemo(() => {
|
||||
if (!allowMultiple) return { head: '', editing: value };
|
||||
const idx = Math.max(value.lastIndexOf(','), value.lastIndexOf(';'));
|
||||
if (idx < 0) return { head: '', editing: value };
|
||||
return { head: value.slice(0, idx + 1), editing: value.slice(idx + 1).trimStart() };
|
||||
}, [value, allowMultiple]);
|
||||
|
||||
// 把编辑段拆成 name / path / session 三部分
|
||||
const parts = useMemo(() => parseParts(editing), [editing]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
try {
|
||||
// 决定问哪一层:还没写 @ -> 问 name;写了 @ 没写 . -> 问 path;写了 . -> 问 session
|
||||
const res = parts.hasDot
|
||||
? await api.suggestAddress(parts.name, parts.path)
|
||||
: parts.hasAt
|
||||
? await api.suggestAddress(parts.name)
|
||||
: await api.suggestAddress();
|
||||
if (cancelled) return;
|
||||
|
||||
const frag = parts.hasDot ? parts.session : parts.hasAt ? parts.path : parts.name;
|
||||
const lower = frag.toLowerCase();
|
||||
const all = res.suggestions || [];
|
||||
const cands = res.candidates || [];
|
||||
|
||||
// 过滤时保持 suggestions 与 candidates 同序:candidates 是按下标对应的,
|
||||
// 分别过滤两个数组会让标题错位到别的别名上。
|
||||
const keep: number[] = [];
|
||||
all.forEach((s, i) => {
|
||||
const c = cands[i];
|
||||
// 标题也参与匹配:想找「缓存选型」那条会话时,人记得的是标题而不是随机短名
|
||||
const hay = c?.title ? `${s} ${c.title}`.toLowerCase() : s.toLowerCase();
|
||||
if (hay.includes(lower)) keep.push(i);
|
||||
});
|
||||
|
||||
setKind(res.kind);
|
||||
setItems(keep.map(i => all[i]));
|
||||
setMeta(cands.length ? keep.map(i => cands[i]).filter(Boolean) : []);
|
||||
setActive(0);
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setItems([]);
|
||||
setMeta([]);
|
||||
}
|
||||
}
|
||||
};
|
||||
const t = setTimeout(run, 120);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(t);
|
||||
};
|
||||
}, [parts.name, parts.path, parts.session, parts.hasAt, parts.hasDot]);
|
||||
|
||||
useEffect(() => {
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onDocClick);
|
||||
return () => document.removeEventListener('mousedown', onDocClick);
|
||||
}, []);
|
||||
|
||||
// 补全菜单根据 Visual Viewport 剩余空间向上翻转;软键盘出现后也实时重算。
|
||||
useEffect(() => {
|
||||
if (!open || items.length === 0) return;
|
||||
const update = () => {
|
||||
const box = boxRef.current;
|
||||
if (!box) return;
|
||||
const rect = box.getBoundingClientRect();
|
||||
const viewport = window.visualViewport;
|
||||
const top = viewport?.offsetTop ?? 0;
|
||||
const bottom = top + (viewport?.height ?? window.innerHeight);
|
||||
const above = Math.max(0, rect.top - top - 8);
|
||||
const below = Math.max(0, bottom - rect.bottom - 8);
|
||||
const flip = below < Math.min(240, above) && above > below;
|
||||
setMenuLayout({ flip, maxHeight: Math.max(96, Math.min(288, flip ? above : below)) });
|
||||
};
|
||||
update();
|
||||
window.addEventListener('resize', update, { passive: true });
|
||||
window.addEventListener('scroll', update, { passive: true, capture: true });
|
||||
window.visualViewport?.addEventListener('resize', update, { passive: true });
|
||||
window.visualViewport?.addEventListener('scroll', update, { passive: true });
|
||||
return () => {
|
||||
window.removeEventListener('resize', update);
|
||||
window.removeEventListener('scroll', update, { capture: true });
|
||||
window.visualViewport?.removeEventListener('resize', update);
|
||||
window.visualViewport?.removeEventListener('scroll', update);
|
||||
};
|
||||
}, [open, items.length]);
|
||||
|
||||
/** 选中一个候选后拼回完整地址 */
|
||||
const apply = (choice: string) => {
|
||||
let next: string;
|
||||
if (kind === 'name') {
|
||||
next = `${choice}@`;
|
||||
} else if (kind === 'path') {
|
||||
next = `${parts.name}@${choice}.`;
|
||||
} else {
|
||||
next = `${parts.name}@${parts.path}.${choice}`;
|
||||
}
|
||||
onChange(allowMultiple ? `${head}${head ? ' ' : ''}${next}` : next);
|
||||
// name/path 选完仍停留在补全态,继续下一段
|
||||
setOpen(kind !== 'session');
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!open || items.length === 0) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActive(i => (i + 1) % items.length);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActive(i => (i - 1 + items.length) % items.length);
|
||||
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
apply(items[active]);
|
||||
} else if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hint =
|
||||
kind === 'name'
|
||||
? 'Agent 名'
|
||||
: kind === 'path'
|
||||
? '工作区路径'
|
||||
: '会话别名(new 为新建)';
|
||||
|
||||
return (
|
||||
<div ref={boxRef} className="relative">
|
||||
<input
|
||||
value={value}
|
||||
autoFocus={autoFocus}
|
||||
onChange={e => {
|
||||
onChange(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
className="w-full text-sm font-mono border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
/>
|
||||
|
||||
{open && items.length > 0 && (
|
||||
<div
|
||||
className={`absolute z-20 w-full overflow-y-auto bg-white border border-gray-200 rounded-md shadow-lg ${
|
||||
menuLayout.flip ? 'bottom-full mb-1' : 'top-full mt-1'
|
||||
}`}
|
||||
style={{ maxHeight: menuLayout.maxHeight }}
|
||||
>
|
||||
<div className="px-2.5 py-1 text-[10px] text-gray-400 border-b border-gray-100">
|
||||
{hint}
|
||||
</div>
|
||||
{items.map((s, i) => {
|
||||
const c = meta[i];
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
onMouseDown={e => {
|
||||
e.preventDefault();
|
||||
apply(s);
|
||||
}}
|
||||
onMouseEnter={() => setActive(i)}
|
||||
className={`w-full text-left px-2.5 py-1.5 ${
|
||||
i === active ? 'bg-blue-50' : 'hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`text-sm font-mono truncate ${
|
||||
i === active ? 'text-blue-700' : 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{s}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
{/* 平台侧会话本侧还没有邮件线索:标出来,让人知道这一封是「接入」
|
||||
一条已经在跑的会话,而不是继续一条已有的邮件往来 */}
|
||||
{c?.source === 'platform' && (
|
||||
<span
|
||||
className="shrink-0 px-1 py-0.5 rounded bg-blue-100 text-blue-700 text-[9px]"
|
||||
title="平台侧已有的会话,本站还没有对应的邮件往来"
|
||||
>
|
||||
平台
|
||||
</span>
|
||||
)}
|
||||
{c?.source === 'new' && (
|
||||
<span className="shrink-0 text-[10px] text-gray-400 font-sans">新建会话</span>
|
||||
)}
|
||||
{(c?.unread ?? 0) > 0 && (
|
||||
<span className="shrink-0 px-1 py-0.5 rounded bg-red-600 text-white text-[9px]">
|
||||
{c!.unread}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{c?.title && c.source !== 'new' && (
|
||||
<p className="text-[10px] text-gray-400 truncate mt-0.5">{c.title}</p>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 把 name@path.session 拆段;path 内允许 . 与 /,按最后一个 . 切 */
|
||||
function parseParts(s: string) {
|
||||
const at = s.indexOf('@');
|
||||
if (at < 0) {
|
||||
return { name: s, path: '', session: '', hasAt: false, hasDot: false };
|
||||
}
|
||||
const name = s.slice(0, at);
|
||||
const rest = s.slice(at + 1);
|
||||
const dot = rest.lastIndexOf('.');
|
||||
if (dot < 0) {
|
||||
return { name, path: rest, session: '', hasAt: true, hasDot: false };
|
||||
}
|
||||
return {
|
||||
name,
|
||||
path: rest.slice(0, dot),
|
||||
session: rest.slice(dot + 1),
|
||||
hasAt: true,
|
||||
hasDot: true
|
||||
};
|
||||
}
|
||||
451
client/electron/src/components/AdminUsersPage.tsx
Normal file
451
client/electron/src/components/AdminUsersPage.tsx
Normal file
@ -0,0 +1,451 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import * as api from '../api/client';
|
||||
import type { AdminScopes, User } from '../types';
|
||||
import { CheckIcon, LockIcon, UsersIcon, ChevronRightIcon, KeyIcon, BotIcon, CpuIcon } from './icons';
|
||||
import KeyPanel from './KeyPanel';
|
||||
import QuotaPanel from './QuotaPanel';
|
||||
import ModelScopePanel from './ModelScopePanel';
|
||||
|
||||
type Tab = 'users' | 'keys' | 'quotas' | 'models';
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const [tab, setTab] = useState<Tab>('users');
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [scopes, setScopes] = useState<AdminScopes>({ agents: [], paths: [] });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
|
||||
// Agent 密钥面板
|
||||
const [keys, setKeys] = useState<api.AgentKey[]>([]);
|
||||
const [keyBusy, setKeyBusy] = useState(false);
|
||||
const [keyError, setKeyError] = useState<string | null>(null);
|
||||
const [newToken, setNewToken] = useState<string | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [u, s] = await Promise.all([api.adminListUsers(), api.adminListScopes()]);
|
||||
setUsers(u.users || []);
|
||||
setScopes(s);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const loadKeys = useCallback(async () => {
|
||||
try {
|
||||
const r = await api.adminListAgentKeys();
|
||||
setKeys(r.keys);
|
||||
setKeyError(null);
|
||||
} catch (err) {
|
||||
setKeyError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab === 'keys') loadKeys();
|
||||
}, [tab, loadKeys]);
|
||||
|
||||
const createKey = async (payload: api.CreateKeyPayload) => {
|
||||
setKeyBusy(true);
|
||||
setKeyError(null);
|
||||
try {
|
||||
const r = await api.adminCreateAgentKey(payload);
|
||||
// 登记客户端已有密钥时对方已经持有全文,无需再弹一次
|
||||
setNewToken(payload.key_token ? null : r.key.key_token ?? null);
|
||||
await loadKeys();
|
||||
} catch (err) {
|
||||
setKeyError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setKeyBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteKey = async (id: string) => {
|
||||
setKeyError(null);
|
||||
try {
|
||||
await api.adminDeleteAgentKey(id);
|
||||
await loadKeys();
|
||||
} catch (err) {
|
||||
setKeyError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const bindKey = async (id: string, agentName: string) => {
|
||||
setKeyError(null);
|
||||
try {
|
||||
await api.adminBindAgentKey(id, agentName);
|
||||
await loadKeys();
|
||||
} catch (err) {
|
||||
setKeyError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const flash = (msg: string) => {
|
||||
setNotice(msg);
|
||||
setTimeout(() => setNotice(null), 2500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-w-0 flex flex-col bg-white">
|
||||
<div className="px-4 md:px-6 py-3 border-b border-gray-200 flex items-center gap-1 flex-wrap">
|
||||
<TabButton active={tab === 'users'} onClick={() => setTab('users')}>
|
||||
<UsersIcon className="w-4 h-4" />
|
||||
用户管理
|
||||
<span className="text-xs text-gray-400">{users.length}</span>
|
||||
</TabButton>
|
||||
<TabButton active={tab === 'keys'} onClick={() => setTab('keys')}>
|
||||
<KeyIcon className="w-4 h-4" />
|
||||
Agent 密钥
|
||||
</TabButton>
|
||||
<TabButton active={tab === 'quotas'} onClick={() => setTab('quotas')}>
|
||||
<BotIcon className="w-4 h-4" />
|
||||
Agent 管理
|
||||
</TabButton>
|
||||
<TabButton active={tab === 'models'} onClick={() => setTab('models')}>
|
||||
<CpuIcon className="w-4 h-4" />
|
||||
模型范围
|
||||
</TabButton>
|
||||
<div className="flex-1" />
|
||||
{notice && <span className="text-xs text-green-600">{notice}</span>}
|
||||
{tab === 'users' && (
|
||||
<button onClick={() => setCreating(v => !v)} className="tap px-3 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700">
|
||||
{creating ? '收起' : '新建用户'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="mx-6 mt-3 text-xs text-red-600 bg-red-50 border border-red-100 rounded-md px-2.5 py-1.5">{error}</p>}
|
||||
|
||||
{tab === 'models' ? (
|
||||
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-4">
|
||||
<ModelScopeTab />
|
||||
</div>
|
||||
) : tab === 'quotas' ? (
|
||||
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-4">
|
||||
<QuotaPanel />
|
||||
</div>
|
||||
) : tab === 'keys' ? (
|
||||
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-4">
|
||||
<KeyPanel
|
||||
variant="agent"
|
||||
keys={keys}
|
||||
loading={keyBusy}
|
||||
error={keyError}
|
||||
newToken={newToken}
|
||||
onCreate={createKey}
|
||||
onDelete={deleteKey}
|
||||
onBind={bindKey}
|
||||
onDismissToken={() => setNewToken(null)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{creating && <CreateUserForm scopes={scopes} onDone={() => { setCreating(false); flash('用户已创建'); load(); }} onError={setError} />}
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-4 space-y-2">
|
||||
{users.map(u => (
|
||||
<UserCard key={u.user_id} user={u} scopes={scopes}
|
||||
expanded={editing === u.user_id}
|
||||
onToggle={() => setEditing(editing === u.user_id ? null : u.user_id)}
|
||||
onSaved={flash} onReload={load} setError={setError} />
|
||||
))}
|
||||
{loading && users.length === 0 && <p className="text-xs text-gray-400 text-center py-6">加载中</p>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({ active, onClick, children }: {
|
||||
active: boolean; onClick: () => void; children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`tap flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md ${
|
||||
active ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── UserCard ── */
|
||||
|
||||
function UserCard({ user, scopes, expanded, onToggle, onSaved, onReload, setError }: {
|
||||
user: User; scopes: AdminScopes; expanded: boolean; onToggle: () => void;
|
||||
onSaved: (msg: string) => void; onReload: () => void; setError: (msg: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200">
|
||||
<div className="px-4 py-2.5 flex items-center gap-x-3 gap-y-1 flex-wrap">
|
||||
<button onClick={onToggle} className="tap flex items-center gap-1 text-xs text-gray-400 hover:text-gray-600">
|
||||
<ChevronRightIcon className={`w-3 h-3 transition-transform ${expanded ? 'rotate-90' : ''}`} />
|
||||
</button>
|
||||
<span className="font-mono text-sm text-gray-900 min-w-[100px]">{user.username}</span>
|
||||
<span className="tap text-[11px] text-gray-500">{user.display_name}</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${user.role === 'admin' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'}`}>
|
||||
{user.role === 'admin' ? '管理员' : '用户'}
|
||||
</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${user.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-gray-200 text-gray-500'}`}>
|
||||
{user.status === 'active' ? '启用' : '禁用'}
|
||||
</span>
|
||||
{user.role !== 'admin' && (user.allowed_agents.length > 0 || user.allowed_paths.length > 0) && (
|
||||
<span className="text-[10px] text-gray-400">受限</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<span className="text-[10px] text-gray-400">{user.last_login || '从未登录'}</span>
|
||||
</div>
|
||||
{expanded && <UserEditor user={user} scopes={scopes} onSaved={onSaved} onReload={onReload} setError={setError} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── UserEditor ── */
|
||||
|
||||
function UserEditor({ user, scopes, onSaved, onReload, setError }: {
|
||||
user: User; scopes: AdminScopes; onSaved: (msg: string) => void; onReload: () => void; setError: (msg: string) => void;
|
||||
}) {
|
||||
const [displayName, setDisplayName] = useState(user.display_name);
|
||||
const [role, setRole] = useState<'admin' | 'user'>(user.role as 'admin' | 'user');
|
||||
const [agents, setAgents] = useState<string[]>(user.allowed_agents);
|
||||
const [paths, setPaths] = useState<string[]>(user.allowed_paths);
|
||||
const [pw, setPw] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const toggleAgent = (a: string) => setAgents(p => p.includes(a) ? p.filter(x => x !== a) : [...p, a]);
|
||||
const togglePath = (p: string) => setPaths(prev => prev.includes(p) ? prev.filter(x => x !== p) : [...prev, p]);
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.adminUpdateUser(user.user_id, { display_name: displayName, role, allowed_agents: agents, allowed_paths: paths });
|
||||
onSaved('用户已更新'); await onReload();
|
||||
} catch (err) { setError(err instanceof Error ? err.message : String(err)); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const disableUser = async () => {
|
||||
try { await api.adminDisableUser(user.user_id); onSaved('用户已禁用'); await onReload(); }
|
||||
catch (err) { setError(err instanceof Error ? err.message : String(err)); }
|
||||
};
|
||||
|
||||
const enableUser = async () => {
|
||||
try { await api.adminUpdateUser(user.user_id, { status: 'active' }); onSaved('用户已启用'); await onReload(); }
|
||||
catch (err) { setError(err instanceof Error ? err.message : String(err)); }
|
||||
};
|
||||
|
||||
const resetPassword = async () => {
|
||||
if (pw.length < 8) return;
|
||||
setBusy(true);
|
||||
try { await api.adminResetPassword(user.user_id, pw); onSaved('密码已重置'); setPw(''); }
|
||||
catch (err) { setError(err instanceof Error ? err.message : String(err)); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t border-gray-100 bg-gray-50 px-4 py-3 space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1">显示名</label>
|
||||
<input value={displayName} onChange={e => setDisplayName(e.target.value)}
|
||||
className="w-full text-sm border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1">角色</label>
|
||||
<select value={role} onChange={e => setRole(e.target.value as 'admin' | 'user')}
|
||||
className="w-full text-sm border border-gray-300 rounded-md px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400">
|
||||
<option value="user">普通用户</option>
|
||||
<option value="admin">管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1">状态</label>
|
||||
{user.status === 'active' ? (
|
||||
<button onClick={disableUser} className="px-3 py-1.5 text-xs rounded-md border border-red-300 text-red-600 hover:bg-red-50 w-full">禁用</button>
|
||||
) : (
|
||||
<button onClick={enableUser} className="px-3 py-1.5 text-xs rounded-md border border-green-300 text-green-700 hover:bg-green-50 w-full">启用</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user.role !== 'admin' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1.5">
|
||||
可调用 Agent {agents.length > 0 && <span className="text-gray-400">({agents.length} 项)</span>}
|
||||
<span className="ml-2 font-normal text-gray-400">未勾选 = 不限</span>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{scopes.agents.map(a => (
|
||||
<button key={a} onClick={() => toggleAgent(a)}
|
||||
className={`px-2.5 py-1 text-xs font-mono rounded-md border transition-colors ${
|
||||
agents.includes(a) ? 'bg-blue-50 border-blue-300 text-blue-700' : 'border-gray-200 text-gray-500 hover:bg-gray-100'
|
||||
}`}>{a}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1.5">
|
||||
可访问目录 {paths.length > 0 && <span className="text-gray-400">({paths.length} 项)</span>}
|
||||
<span className="ml-2 font-normal text-gray-400">未勾选 = 不限;按目录前缀匹配</span>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{scopes.paths.map(p => (
|
||||
<button key={p} onClick={() => togglePath(p)}
|
||||
className={`px-2.5 py-1 text-xs font-mono rounded-md border transition-colors ${
|
||||
paths.includes(p) ? 'bg-green-50 border-green-300 text-green-700' : 'border-gray-200 text-gray-500 hover:bg-gray-100'
|
||||
}`}>{p}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-x-3 gap-y-1 flex-wrap">
|
||||
<button onClick={save} disabled={busy} className="tap px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 transition-colors">
|
||||
{busy ? '保存中' : '保存更改'}
|
||||
</button>
|
||||
<div className="flex items-center gap-1.5 ml-auto flex-wrap">
|
||||
<LockIcon className="w-3 h-3 text-gray-400" />
|
||||
<input type="password" value={pw} onChange={e => setPw(e.target.value)} placeholder="新密码(至少 8 位)"
|
||||
className="w-40 text-xs border border-gray-300 rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400" />
|
||||
<button onClick={resetPassword} disabled={pw.length < 8 || busy}
|
||||
className="tap inline-flex items-center gap-1 px-2 py-1 text-[11px] rounded bg-chrome-700 text-white hover:bg-chrome-800 disabled:opacity-40">
|
||||
<CheckIcon className="w-3 h-3" /> 重置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── CreateUserForm ── */
|
||||
|
||||
function CreateUserForm({ scopes, onDone, onError }: {
|
||||
scopes: AdminScopes; onDone: () => void; onError: (msg: string) => void;
|
||||
}) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [role, setRole] = useState<'admin' | 'user'>('user');
|
||||
const [agents, setAgents] = useState<string[]>([]);
|
||||
const [paths, setPaths] = useState<string[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const ok = username.trim().length >= 2 && password.length >= 8 && !busy;
|
||||
|
||||
const submit = async () => {
|
||||
if (!ok) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.adminCreateUser({
|
||||
username: username.trim().toLowerCase(), password, display_name: displayName.trim(), role: role,
|
||||
allowed_agents: role === 'admin' ? [] : agents, allowed_paths: role === 'admin' ? [] : paths,
|
||||
});
|
||||
setUsername(''); setDisplayName(''); setPassword(''); setRole('user'); setAgents([]); setPaths([]);
|
||||
onDone();
|
||||
} catch (err) { onError(err instanceof Error ? err.message : String(err)); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-6 mt-3 p-4 rounded-lg border border-gray-200 bg-gray-50 space-y-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<Field label="用户名" hint="小写字母数字 . _ -">
|
||||
<input value={username} onChange={e => setUsername(e.target.value)} placeholder="alice" spellCheck={false}
|
||||
className="w-full text-sm font-mono border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400" />
|
||||
</Field>
|
||||
<Field label="显示名"><input value={displayName} onChange={e => setDisplayName(e.target.value)} placeholder="Alice"
|
||||
className="w-full text-sm border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400" /></Field>
|
||||
<Field label="初始密码" hint="至少 8 位"><input type="password" value={password} onChange={e => setPassword(e.target.value)}
|
||||
className="w-full text-sm border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400" /></Field>
|
||||
<Field label="角色">
|
||||
<select value={role} onChange={e => setRole(e.target.value as 'admin' | 'user')}
|
||||
className="w-full text-sm border border-gray-300 rounded-md px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400">
|
||||
<option value="user">普通用户</option>
|
||||
<option value="admin">管理员</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{role !== 'admin' && (
|
||||
<>
|
||||
<ScopePick label="可调用 Agent" items={scopes.agents} selected={agents} onToggle={a => setAgents(p => p.includes(a) ? p.filter(x => x !== a) : [...p, a])} color="blue" />
|
||||
<ScopePick label="可访问目录" items={scopes.paths} selected={paths} onToggle={p => setPaths(prev => prev.includes(p) ? prev.filter(x => x !== p) : [...prev, p])} color="green" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button onClick={submit} disabled={!ok} className="tap px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40">
|
||||
{busy ? '创建中' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScopePick({ label, items, selected, onToggle, color }: {
|
||||
label: string; items: string[]; selected: string[]; onToggle: (item: string) => void; color: 'blue' | 'green';
|
||||
}) {
|
||||
const active = color === 'blue' ? 'bg-blue-50 border-blue-300 text-blue-700' : 'bg-green-50 border-green-300 text-green-700';
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1">
|
||||
{label} <span className="font-normal text-gray-400">未勾选 = 不限</span>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{items.map(i => (
|
||||
<button key={i} onClick={() => onToggle(i)}
|
||||
className={`px-2.5 py-1 text-xs font-mono rounded-md border transition-colors ${
|
||||
selected.includes(i) ? active : 'border-gray-200 text-gray-500 hover:bg-gray-100'
|
||||
}`}>{i}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-baseline gap-1.5 mb-1">
|
||||
<label className="text-[11px] font-medium text-gray-500">{label}</label>
|
||||
{hint && <span className="text-[10px] text-gray-400">{hint}</span>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 模型范围页。
|
||||
*
|
||||
* Agent 列表复用 `/admin/quotas` —— 它返回的就是全部已注册 Agent。
|
||||
* 另开一个「列出 Agent」接口只会多一条做同一件事的路径。
|
||||
*/
|
||||
function ModelScopeTab() {
|
||||
const [agents, setAgents] = useState<api.AgentStats[]>([]);
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.adminListAgentStats()
|
||||
.then(res => setAgents(res.quotas))
|
||||
.catch(e => setErr(e instanceof Error ? e.message : String(e)));
|
||||
}, []);
|
||||
|
||||
if (err) return <p className="text-xs text-red-600">{err}</p>;
|
||||
return <ModelScopePanel agents={agents} />;
|
||||
}
|
||||
171
client/electron/src/components/Attachments.tsx
Normal file
171
client/electron/src/components/Attachments.tsx
Normal file
@ -0,0 +1,171 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import * as api from '../api/client';
|
||||
import type { Attachment } from '../types';
|
||||
import { PaperclipIcon, DownloadIcon, FileIcon, CloseIcon, SpinnerIcon } from './icons';
|
||||
|
||||
/** 已发出邮件的附件清单(只读,点击下载)。 */
|
||||
export function AttachmentList({ items }: { items: Attachment[] }) {
|
||||
if (!items || items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-4 border-t border-gray-100 pt-3">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<PaperclipIcon className="w-3.5 h-3.5 text-gray-400" />
|
||||
<span className="text-[11px] font-medium text-gray-500">
|
||||
附件 {items.length}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{items.map(a => (
|
||||
<li key={a.attachment_id}>
|
||||
<a
|
||||
href={api.attachmentURL(a.attachment_id)}
|
||||
// download 让浏览器保存而非尝试渲染;服务端也已强制 octet-stream + attachment
|
||||
download={a.filename}
|
||||
className="group flex items-center gap-2 px-2 py-1.5 rounded border border-gray-200 hover:border-blue-300 hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
<FileIcon className="w-3.5 h-3.5 text-gray-400 shrink-0" />
|
||||
<span className="text-xs text-gray-800 truncate flex-1">{a.filename}</span>
|
||||
<span className="text-[10px] text-gray-400 shrink-0">
|
||||
{api.formatSize(a.size_bytes)}
|
||||
</span>
|
||||
<DownloadIcon className="w-3.5 h-3.5 text-gray-300 group-hover:text-blue-500 shrink-0" />
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 待发送的附件:已上传到服务器、等着随邮件发出。 */
|
||||
export interface PendingAttachment {
|
||||
id: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写信时的附件选择器。
|
||||
*
|
||||
* 上传是独立一步:选中即上传,拿到 attachment_id 后暂存,发信时一并提交。
|
||||
* 之所以不等到点「发送」再传:大文件上传要时间,让用户在写正文时就完成上传体验更好,
|
||||
* 而且上传失败能立刻反馈而不是卡在发送那一刻。
|
||||
*/
|
||||
export function AttachmentPicker({
|
||||
items,
|
||||
onChange,
|
||||
disabled
|
||||
}: {
|
||||
items: PendingAttachment[];
|
||||
onChange: (next: PendingAttachment[]) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState<{ name: string; pct: number } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const pick = () => inputRef.current?.click();
|
||||
|
||||
const handleFiles = async (files: FileList | null) => {
|
||||
if (!files || files.length === 0) return;
|
||||
setError(null);
|
||||
|
||||
// 逐个上传而非并发:并发时进度条只能显示其中一个,且大文件同时传更容易触发体积限制
|
||||
const added: PendingAttachment[] = [];
|
||||
for (const file of Array.from(files)) {
|
||||
setUploading({ name: file.name, pct: 0 });
|
||||
try {
|
||||
const r = await api.uploadAttachment(file, pct => setUploading({ name: file.name, pct }));
|
||||
added.push({
|
||||
id: r.attachment.attachment_id,
|
||||
filename: r.attachment.filename,
|
||||
size: r.attachment.size_bytes
|
||||
});
|
||||
} catch (err) {
|
||||
setError(`${file.name}:${err instanceof Error ? err.message : String(err)}`);
|
||||
break; // 一个失败就停下,避免连续弹同类错误
|
||||
}
|
||||
}
|
||||
setUploading(null);
|
||||
if (added.length > 0) onChange([...items, ...added]);
|
||||
|
||||
// 清空 input,否则重复选同一个文件不会触发 change
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
};
|
||||
|
||||
const remove = async (a: PendingAttachment) => {
|
||||
// 从服务器删掉未挂载的附件,不然它会占着磁盘等 24 小时 GC
|
||||
try {
|
||||
await api.deleteAttachment(a.id);
|
||||
} catch {
|
||||
/* 删不掉也只是留给 GC,不该阻塞用户移除操作 */
|
||||
}
|
||||
onChange(items.filter(x => x.id !== a.id));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={e => handleFiles(e.target.files)}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={pick}
|
||||
disabled={disabled || uploading !== null}
|
||||
className="tap shrink-0 inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-40"
|
||||
>
|
||||
<PaperclipIcon className="w-3.5 h-3.5" />
|
||||
添加附件
|
||||
</button>
|
||||
|
||||
{uploading && (
|
||||
<span className="min-w-0 flex-1 inline-flex items-center gap-1.5 text-[11px] text-gray-500">
|
||||
<SpinnerIcon className="w-3.5 h-3.5 animate-spin shrink-0" />
|
||||
<span className="truncate">{uploading.name}</span>
|
||||
<span className="shrink-0">{uploading.pct}%</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{items.length > 0 && !uploading && (
|
||||
<span className="text-[11px] text-gray-500 min-w-0 break-words">
|
||||
{items.length} 个附件 ·{' '}
|
||||
{api.formatSize(items.reduce((sum, a) => sum + a.size, 0))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="text-[11px] text-red-600 break-words">{error}</div>}
|
||||
|
||||
{items.length > 0 && (
|
||||
<ul className="space-y-1">
|
||||
{items.map(a => (
|
||||
<li
|
||||
key={a.id}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded border border-gray-200 bg-gray-50"
|
||||
>
|
||||
<FileIcon className="w-3.5 h-3.5 text-gray-400 shrink-0" />
|
||||
<span className="text-xs text-gray-800 truncate flex-1">{a.filename}</span>
|
||||
<span className="text-[10px] text-gray-400 shrink-0">{api.formatSize(a.size)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(a)}
|
||||
disabled={disabled}
|
||||
title="移除"
|
||||
className="tap shrink-0 inline-flex items-center justify-center text-gray-500 hover:text-red-600 disabled:opacity-40"
|
||||
>
|
||||
<CloseIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
client/electron/src/components/BackButton.tsx
Normal file
30
client/electron/src/components/BackButton.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { useIsNarrow } from '../hooks/useIsNarrow';
|
||||
import { useUIStore } from '../stores/uiStore';
|
||||
import { ChevronLeftIcon } from './icons';
|
||||
|
||||
/**
|
||||
* 窄屏返回按钮。
|
||||
*
|
||||
* 只在窄屏出现:宽屏是列表与详情并排,没有「返回」这个概念 ——
|
||||
* 放一个按钮在那里,点了什么也不会发生。
|
||||
*
|
||||
* 覆盖式布局下返回 = 让覆盖层滑出去(narrowPane 回到 list),
|
||||
* 而不是卸载详情组件:底层列表一直挂载着,滚动位置与选中态都还在。
|
||||
*/
|
||||
export default function BackButton({ label = '返回' }: { label?: string }) {
|
||||
const narrow = useIsNarrow();
|
||||
const showList = useUIStore(s => s.showList);
|
||||
|
||||
if (!narrow) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={showList}
|
||||
className="tap shrink-0 -ml-1 mr-1 inline-flex items-center gap-0.5 py-1 pr-1.5 pl-0.5 rounded text-gray-500 active:bg-gray-100"
|
||||
aria-label={label}
|
||||
>
|
||||
<ChevronLeftIcon className="w-4 h-4" />
|
||||
<span className="text-xs">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
694
client/electron/src/components/CalendarEventEditor.tsx
Normal file
694
client/electron/src/components/CalendarEventEditor.tsx
Normal file
@ -0,0 +1,694 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import * as api from '../api/client';
|
||||
import type {
|
||||
CalendarEvent,
|
||||
CalendarEventInput,
|
||||
CalendarAttachment,
|
||||
Recurrence,
|
||||
DeliveryMode
|
||||
} from '../types';
|
||||
import AddressInput from './AddressInput';
|
||||
import {
|
||||
toLocalInput,
|
||||
fromLocalInput,
|
||||
renderReminder,
|
||||
describeRemindBefore
|
||||
} from '../lib/calendar';
|
||||
import {
|
||||
upcomingOccurrences,
|
||||
formatSolarWithLunar,
|
||||
isLunarRecurrence,
|
||||
describeRecurrenceRule
|
||||
} from '../lib/lunar';
|
||||
import {
|
||||
CloseIcon,
|
||||
SpinnerIcon,
|
||||
TrashIcon,
|
||||
BellIcon,
|
||||
RepeatIcon,
|
||||
PaperclipIcon,
|
||||
FileIcon,
|
||||
PlusIcon,
|
||||
UsersIcon,
|
||||
BotIcon
|
||||
} from './icons';
|
||||
|
||||
/**
|
||||
* 事件编辑器(新建 / 编辑共用)。
|
||||
*
|
||||
* 参照 Outlook 的编辑面板:时间与重复在上、收件方居中、提醒正文在下。
|
||||
* 提醒正文可编辑且带 `{title}` `{time}` `{description}` 变量 —— 预览实时渲染,
|
||||
* 因为「模板里写了什么」和「Agent 收到什么」不是一个东西,
|
||||
* 不给预览的话人只能发一次试试看。
|
||||
*/
|
||||
|
||||
/** 提前提醒的预设档位。手打分钟数容易写出档位外的值,但也允许。 */
|
||||
const PRESETS = [0, 5, 15, 30, 60, 120, 1440];
|
||||
|
||||
const DEFAULT_TEMPLATE = '日程提醒:{title}\n时间:{time}\n{description}';
|
||||
|
||||
/** 重复规则选项。农历单独一组:它们的公历日期每次都在漂移。 */
|
||||
const RECURRENCE_GROUPS: { label: string; items: { value: Recurrence; label: string }[] }[] = [
|
||||
{
|
||||
label: '公历',
|
||||
items: [
|
||||
{ value: 'none', label: '不重复' },
|
||||
{ value: 'daily', label: '每天' },
|
||||
{ value: 'weekly', label: '每周' },
|
||||
{ value: 'monthly', label: '每月(同一日)' },
|
||||
{ value: 'yearly', label: '每年(同月日)' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '农历',
|
||||
items: [
|
||||
{ value: 'lunar_monthly', label: '每农历月(同一日,如每月十五)' },
|
||||
{ value: 'lunar_yearly', label: '每农历年(同月日,如农历生日)' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export default function CalendarEventEditor({
|
||||
event,
|
||||
initialTime,
|
||||
onClose,
|
||||
onSaved
|
||||
}: {
|
||||
/** 有值 = 编辑,无值 = 新建 */
|
||||
event?: CalendarEvent | null;
|
||||
/** 新建时的预填时间(点空白格子建事件) */
|
||||
initialTime?: Date;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const editing = !!event;
|
||||
|
||||
const [title, setTitle] = useState(event?.title ?? '');
|
||||
const [description, setDescription] = useState(event?.description ?? '');
|
||||
const [reminderText, setReminderText] = useState(event?.reminder_text ?? '');
|
||||
|
||||
/**
|
||||
* 收件人列表。
|
||||
*
|
||||
* 初值兼容旧数据:recipients 为空时退回 to_address / agent_name ——
|
||||
* 与后端 EffectiveRecipients() 同一条兜底链。不做这个归一化的话,
|
||||
* 编辑一条老事件再保存会把它的收件人清空(列表是空的,保存就覆盖了)。
|
||||
*/
|
||||
const [recipients, setRecipients] = useState<string[]>(() => {
|
||||
if (event?.recipients?.length) return event.recipients;
|
||||
const legacy = (event?.to_address || event?.agent_name || '').trim();
|
||||
return legacy ? [legacy] : [];
|
||||
});
|
||||
const [draftAddr, setDraftAddr] = useState('');
|
||||
const [deliveryMode, setDeliveryMode] = useState<DeliveryMode>(
|
||||
event?.delivery_mode === 'together' ? 'together' : 'separate'
|
||||
);
|
||||
|
||||
const [eventTime, setEventTime] = useState(() => {
|
||||
if (event?.event_time) return toLocalInput(new Date(event.event_time));
|
||||
if (initialTime) return toLocalInput(initialTime);
|
||||
// 默认下一个整点:现在这一刻当默认值几乎总要改
|
||||
const d = new Date();
|
||||
d.setHours(d.getHours() + 1, 0, 0, 0);
|
||||
return toLocalInput(d);
|
||||
});
|
||||
const [remindBefore, setRemindBefore] = useState(event?.remind_before ?? 0);
|
||||
const [recurrence, setRecurrence] = useState<Recurrence>(event?.recurrence ?? 'none');
|
||||
const [recurrenceEnd, setRecurrenceEnd] = useState(
|
||||
event?.recurrence_end ? toLocalInput(new Date(event.recurrence_end)) : ''
|
||||
);
|
||||
const [status, setStatus] = useState<CalendarEvent['status']>(event?.status ?? 'active');
|
||||
|
||||
const [agents, setAgents] = useState<string[]>([]);
|
||||
const [atts, setAtts] = useState<CalendarAttachment[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
// Agent 列表用于快捷添加。拉不到不算错 —— 地址仍可手输(三段式补全独立工作)。
|
||||
useEffect(() => {
|
||||
api
|
||||
.listAgents()
|
||||
.then(r => setAgents((r.agents ?? []).map(a => a.agent_name)))
|
||||
.catch(() => setAgents([]));
|
||||
}, []);
|
||||
|
||||
// 附件只在编辑既有事件时才有:新建时还没有 event_id 可挂
|
||||
useEffect(() => {
|
||||
if (!event?.event_id) return;
|
||||
api
|
||||
.listCalendarAttachments(event.event_id)
|
||||
.then(r => setAtts(r.attachments ?? []))
|
||||
.catch(() => setAtts([]));
|
||||
}, [event?.event_id]);
|
||||
|
||||
function addRecipient(addr: string) {
|
||||
const v = addr.trim();
|
||||
if (!v) return;
|
||||
// 去重:together 模式下同一个 Agent 既主收又抄送会收到两条 SSE,
|
||||
// 插件可能因此起两轮
|
||||
if (recipients.includes(v)) {
|
||||
setDraftAddr('');
|
||||
return;
|
||||
}
|
||||
setRecipients(prev => [...prev, v]);
|
||||
setDraftAddr('');
|
||||
}
|
||||
|
||||
function removeRecipient(addr: string) {
|
||||
setRecipients(prev => prev.filter(x => x !== addr));
|
||||
}
|
||||
|
||||
/** 上移一位。together 模式下第一个是主收件人,顺序有语义。 */
|
||||
function moveUp(i: number) {
|
||||
if (i <= 0) return;
|
||||
setRecipients(prev => {
|
||||
const next = [...prev];
|
||||
[next[i - 1], next[i]] = [next[i], next[i - 1]];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const preview = useMemo(() => {
|
||||
const tpl = reminderText.trim() || DEFAULT_TEMPLATE;
|
||||
return renderReminder(tpl, {
|
||||
title: title || '(未填标题)',
|
||||
description,
|
||||
event_time: fromLocalInput(eventTime) || new Date().toISOString()
|
||||
});
|
||||
}, [reminderText, title, description, eventTime]);
|
||||
|
||||
/**
|
||||
* 接下来三次触发。
|
||||
*
|
||||
* 农历规则必须给这个预览:规则名(「每农历月廿二」)看不出公历日子,
|
||||
* 而公历日子每次都在变 —— 不预览的话人要等一个月才知道理解对没对。
|
||||
*/
|
||||
const upcoming = useMemo(() => {
|
||||
if (recurrence === 'none') return [];
|
||||
const iso = fromLocalInput(eventTime);
|
||||
if (!iso) return [];
|
||||
return upcomingOccurrences(recurrence, new Date(iso), 3);
|
||||
}, [recurrence, eventTime]);
|
||||
|
||||
async function addFiles(files: FileList | null) {
|
||||
if (!files?.length || !event?.event_id) return;
|
||||
setUploading(true);
|
||||
setErr('');
|
||||
try {
|
||||
// 逐个传而非并发:并发失败时分不清是哪个文件的问题
|
||||
for (const f of Array.from(files)) {
|
||||
const a = await api.uploadCalendarAttachment(event.event_id, f);
|
||||
setAtts(prev => [...prev, a]);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || '上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function dropAttachment(id: string) {
|
||||
try {
|
||||
await api.deleteCalendarAttachment(id);
|
||||
setAtts(prev => prev.filter(a => a.attachment_id !== id));
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || '删除附件失败');
|
||||
}
|
||||
}
|
||||
|
||||
// 收件人为空时不能保存:事件永远发不出去,后端也会 400。
|
||||
// 在这里就禁用按钮比让人点了再看报错好。
|
||||
const canSave = title.trim() !== '' && eventTime !== '' && recipients.length > 0 && !saving;
|
||||
|
||||
async function save() {
|
||||
if (!canSave) return;
|
||||
setErr('');
|
||||
setSaving(true);
|
||||
const iso = fromLocalInput(eventTime);
|
||||
if (!iso) {
|
||||
setErr('事件时间无效');
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
// recurrence 为 none 时清掉终止时间:留着它只会让后续编辑困惑
|
||||
const endIso = recurrence === 'none' || !recurrenceEnd ? null : fromLocalInput(recurrenceEnd);
|
||||
const payload: CalendarEventInput = {
|
||||
title: title.trim(),
|
||||
description,
|
||||
reminder_text: reminderText.trim(),
|
||||
recipients,
|
||||
delivery_mode: deliveryMode,
|
||||
// 旧字段保持与列表首项一致:第三方客户端(与旧版前端)只读 to_address
|
||||
to_address: recipients[0] ?? '',
|
||||
event_time: iso,
|
||||
remind_before: remindBefore,
|
||||
recurrence,
|
||||
recurrence_end: endIso,
|
||||
status
|
||||
};
|
||||
try {
|
||||
if (editing && event) await api.updateCalendarEvent(event.event_id, payload);
|
||||
else await api.createCalendarEvent(payload);
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!event) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.deleteCalendarEvent(event.event_id);
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || '删除失败');
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const unusedAgents = agents.filter(a => !recipients.includes(a));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 bg-white">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 shrink-0">
|
||||
<h2 className="text-base font-medium text-gray-900">
|
||||
{editing ? '编辑日程' : '新建日程'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded hover:bg-gray-100 text-gray-500"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-4 space-y-5">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">标题</label>
|
||||
<input
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
autoFocus
|
||||
placeholder="例如:llmsproxy 发布评审"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">说明</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="可留空;会作为 {description} 变量填入提醒正文"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded text-sm resize-y focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">时间</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={eventTime}
|
||||
onChange={e => setEventTime(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
{fromLocalInput(eventTime) && (
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{formatSolarWithLunar(new Date(fromLocalInput(eventTime)))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="flex items-center gap-1 text-xs font-medium text-gray-600 mb-1">
|
||||
<BellIcon className="w-3.5 h-3.5" />
|
||||
提醒
|
||||
</label>
|
||||
<select
|
||||
value={PRESETS.includes(remindBefore) ? String(remindBefore) : 'custom'}
|
||||
onChange={e => {
|
||||
if (e.target.value !== 'custom') setRemindBefore(Number(e.target.value));
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded text-sm bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{PRESETS.map(m => (
|
||||
<option key={m} value={m}>
|
||||
{describeRemindBefore(m)}
|
||||
</option>
|
||||
))}
|
||||
{!PRESETS.includes(remindBefore) && (
|
||||
<option value="custom">{describeRemindBefore(remindBefore)}</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-1 text-xs font-medium text-gray-600 mb-1">
|
||||
<RepeatIcon className="w-3.5 h-3.5" />
|
||||
重复
|
||||
</label>
|
||||
<select
|
||||
value={recurrence}
|
||||
onChange={e => setRecurrence(e.target.value as Recurrence)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded text-sm bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{RECURRENCE_GROUPS.map(g => (
|
||||
<optgroup key={g.label} label={g.label}>
|
||||
{g.items.map(it => (
|
||||
<option key={it.value} value={it.value}>
|
||||
{it.label}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{recurrence !== 'none' && (
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="px-2.5 py-2 bg-gray-50 border border-gray-200 rounded">
|
||||
<div className="text-xs text-gray-600 mb-1">
|
||||
{describeRecurrenceRule(
|
||||
recurrence,
|
||||
fromLocalInput(eventTime) ? new Date(fromLocalInput(eventTime)) : undefined
|
||||
)}
|
||||
{isLunarRecurrence(recurrence) && (
|
||||
<span className="ml-1 text-amber-700">· 按农历推进</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 接下来三次必须显示:农历规则的公历日期每次都在变,
|
||||
光看规则名分辨不出对不对,而错了要等一个月才发现 */}
|
||||
{upcoming.length > 0 ? (
|
||||
<ul className="space-y-0.5">
|
||||
{upcoming.map((d, i) => (
|
||||
<li key={i} className="text-xs text-gray-700 tabular-nums">
|
||||
{formatSolarWithLunar(d)}{' '}
|
||||
<span className="text-gray-400">
|
||||
{String(d.getHours()).padStart(2, '0')}:
|
||||
{String(d.getMinutes()).padStart(2, '0')}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-xs text-amber-700">
|
||||
算不出下一次 —— 这条规则在后续年份可能不存在(例如闰月)。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">重复至</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={recurrenceEnd}
|
||||
onChange={e => setRecurrenceEnd(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">留空 = 一直重复</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 收件人 ── */}
|
||||
<div className="pt-1 border-t border-gray-100">
|
||||
<label className="flex items-center gap-1 text-xs font-medium text-gray-600 mb-1 mt-3">
|
||||
<UsersIcon className="w-3.5 h-3.5" />
|
||||
提醒发给谁({recipients.length})
|
||||
</label>
|
||||
|
||||
{recipients.length > 0 && (
|
||||
<ul className="mb-2 space-y-1">
|
||||
{recipients.map((addr, i) => (
|
||||
<li
|
||||
key={addr}
|
||||
className="flex items-center gap-2 px-2 py-1.5 bg-gray-50 border border-gray-200 rounded text-xs"
|
||||
>
|
||||
<BotIcon className="w-3.5 h-3.5 text-gray-400 shrink-0" />
|
||||
<span className="flex-1 min-w-0 truncate text-gray-800 font-mono">{addr}</span>
|
||||
{/* together 模式下首个是主收件人,顺序有语义,因此要能调 */}
|
||||
{deliveryMode === 'together' && i === 0 && (
|
||||
<span className="px-1.5 py-0.5 bg-blue-100 text-blue-700 rounded shrink-0">
|
||||
主收件人
|
||||
</span>
|
||||
)}
|
||||
{deliveryMode === 'together' && i > 0 && (
|
||||
<button
|
||||
onClick={() => moveUp(i)}
|
||||
title="设为主收件人方向移动"
|
||||
className="px-1 rounded hover:bg-gray-200 text-gray-500 shrink-0"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => removeRecipient(addr)}
|
||||
className="p-0.5 rounded hover:bg-gray-200 text-gray-500 shrink-0"
|
||||
aria-label={`移除 ${addr}`}
|
||||
>
|
||||
<CloseIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<AddressInput
|
||||
value={draftAddr}
|
||||
onChange={setDraftAddr}
|
||||
placeholder="name@path.session(省略 .session = 默认会话)"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => addRecipient(draftAddr)}
|
||||
disabled={!draftAddr.trim()}
|
||||
className="px-2.5 py-2 border border-gray-300 text-gray-700 text-xs rounded hover:bg-gray-50 disabled:opacity-40 shrink-0 flex items-center gap-1"
|
||||
>
|
||||
<PlusIcon className="w-3.5 h-3.5" />
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{unusedAgents.length > 0 && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{unusedAgents.map(a => (
|
||||
<button
|
||||
key={a}
|
||||
onClick={() => addRecipient(a)}
|
||||
className="px-2 py-0.5 text-xs bg-gray-100 hover:bg-gray-200 text-gray-700 rounded border border-gray-200"
|
||||
>
|
||||
+ {a}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recipients.length > 1 && (
|
||||
<div className="mt-3">
|
||||
<div className="text-xs font-medium text-gray-600 mb-1">怎么投递</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="flex items-start gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
checked={deliveryMode === 'separate'}
|
||||
onChange={() => setDeliveryMode('separate')}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="text-xs">
|
||||
<span className="text-gray-800">分别发送</span>
|
||||
<span className="block text-gray-500">
|
||||
每人一封、落各自的会话,互相看不到 —— 适合让几个 Agent
|
||||
各自独立判断。
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
checked={deliveryMode === 'together'}
|
||||
onChange={() => setDeliveryMode('together')}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="text-xs">
|
||||
<span className="text-gray-800">一起发送</span>
|
||||
<span className="block text-gray-500">
|
||||
首个是主收件人、其余抄送,共享同一条线索,能看到彼此的回复 ——
|
||||
适合有主次的协作。
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 提醒正文 ── */}
|
||||
<div className="pt-1 border-t border-gray-100">
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1 mt-3">提醒正文</label>
|
||||
<textarea
|
||||
value={reminderText}
|
||||
onChange={e => setReminderText(e.target.value)}
|
||||
rows={4}
|
||||
placeholder={DEFAULT_TEMPLATE}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded text-sm font-mono resize-y focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{['{title}', '{time}', '{description}'].map(v => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => setReminderText(t => t + v)}
|
||||
className="px-2 py-0.5 text-xs font-mono bg-gray-100 hover:bg-gray-200 text-gray-700 rounded border border-gray-200"
|
||||
>
|
||||
{v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-600 mb-1">Agent 会收到</div>
|
||||
<pre className="px-3 py-2 bg-gray-50 border border-gray-200 rounded text-xs text-gray-800 whitespace-pre-wrap break-words">
|
||||
{preview}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<div>
|
||||
<label className="flex items-center gap-1 text-xs font-medium text-gray-600 mb-1">
|
||||
<PaperclipIcon className="w-3.5 h-3.5" />
|
||||
附件(随提醒邮件一起发出)
|
||||
</label>
|
||||
{atts.length > 0 && (
|
||||
<ul className="mb-2 space-y-1">
|
||||
{atts.map(a => (
|
||||
<li
|
||||
key={a.attachment_id}
|
||||
className="flex items-center gap-2 px-2 py-1.5 bg-gray-50 border border-gray-200 rounded text-xs"
|
||||
>
|
||||
<FileIcon className="w-3.5 h-3.5 text-gray-400 shrink-0" />
|
||||
<span className="flex-1 min-w-0 truncate text-gray-800">{a.filename}</span>
|
||||
<span className="text-gray-400 tabular-nums shrink-0">
|
||||
{(a.size_bytes / 1024).toFixed(1)} KB
|
||||
</span>
|
||||
<button
|
||||
onClick={() => dropAttachment(a.attachment_id)}
|
||||
className="p-0.5 rounded hover:bg-gray-200 text-gray-500 shrink-0"
|
||||
aria-label={`移除 ${a.filename}`}
|
||||
>
|
||||
<CloseIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="px-2.5 py-1 border border-gray-300 text-gray-700 text-xs rounded hover:bg-gray-50 disabled:opacity-40 flex items-center gap-1.5"
|
||||
>
|
||||
{uploading ? (
|
||||
<SpinnerIcon className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<PaperclipIcon className="w-3.5 h-3.5" />
|
||||
)}
|
||||
添加附件
|
||||
</button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={e => addFiles(e.target.files)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">状态</label>
|
||||
<select
|
||||
value={status}
|
||||
onChange={e => setStatus(e.target.value as CalendarEvent['status'])}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded text-sm bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="active">启用</option>
|
||||
<option value="paused">暂停(不再触发提醒)</option>
|
||||
<option value="cancelled">已取消</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{err && (
|
||||
<div className="px-3 py-2 bg-red-50 border border-red-200 rounded text-sm text-red-700">
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-t border-gray-200 shrink-0 flex-wrap">
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={!canSave}
|
||||
title={recipients.length === 0 ? '至少要有一个收件人' : undefined}
|
||||
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{saving && <SpinnerIcon className="w-4 h-4 animate-spin" />}
|
||||
{editing ? '保存' : '创建'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 text-sm rounded hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
{editing && (
|
||||
<div className="w-full sm:w-auto sm:ml-auto">
|
||||
{confirmDelete ? (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-gray-600">确定删除?</span>
|
||||
<button
|
||||
onClick={remove}
|
||||
disabled={deleting}
|
||||
className="px-3 py-1.5 bg-red-600 text-white text-xs rounded hover:bg-red-700 disabled:opacity-40 flex items-center gap-1"
|
||||
>
|
||||
{deleting && <SpinnerIcon className="w-3 h-3 animate-spin" />}
|
||||
删除
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDelete(false)}
|
||||
className="px-3 py-1.5 border border-gray-300 text-gray-700 text-xs rounded hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
className="px-3 py-1.5 text-red-600 text-sm rounded hover:bg-red-50 flex items-center gap-1.5"
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
775
client/electron/src/components/CalendarView.tsx
Normal file
775
client/electron/src/components/CalendarView.tsx
Normal file
@ -0,0 +1,775 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import * as api from '../api/client';
|
||||
import type { CalendarEvent } from '../types';
|
||||
import CalendarEventEditor from './CalendarEventEditor';
|
||||
import NarrowStack from './NarrowStack';
|
||||
import {
|
||||
monthGrid,
|
||||
weekDays,
|
||||
bucketByDay,
|
||||
dayKey,
|
||||
isSameDay,
|
||||
addDays,
|
||||
addMonths,
|
||||
startOfMonth,
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
startOfDay,
|
||||
endOfDay,
|
||||
remindAt,
|
||||
describeRemindBefore
|
||||
} from '../lib/calendar';
|
||||
import {
|
||||
cellLunarLabel,
|
||||
formatSolarWithLunar,
|
||||
describeRecurrenceRule,
|
||||
isLunarRecurrence
|
||||
} from '../lib/lunar';
|
||||
import { useIsNarrow } from '../hooks/useIsNarrow';
|
||||
import {
|
||||
CalendarIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
PlusIcon,
|
||||
SpinnerIcon,
|
||||
DownloadIcon,
|
||||
UploadIcon,
|
||||
BellIcon,
|
||||
RepeatIcon,
|
||||
BotIcon,
|
||||
PauseIcon,
|
||||
UsersIcon,
|
||||
CloseIcon
|
||||
} from './icons';
|
||||
|
||||
type Scale = 'month' | 'week' | 'day';
|
||||
|
||||
const WEEKDAY_LABELS = ['一', '二', '三', '四', '五', '六', '日'];
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => i);
|
||||
|
||||
/**
|
||||
* 日历主视图。
|
||||
*
|
||||
* **布局与全站一致:内容区自己再分两栏。**
|
||||
*
|
||||
* 之前这里是单栏 —— 网格铺满整个主区域,宽屏下右边一大片空白无事可做,
|
||||
* 而点「新建」时编辑器**顶掉**整个日历,人失去了正在看的那个月的上下文。
|
||||
* 两个问题同源:日历没有「详情栏」这个位置。
|
||||
*
|
||||
* 现在左边是网格、右边是常驻面板:默认显示选中那天的日程(所以永远不空),
|
||||
* 新建/编辑时同一个位置变成编辑器 —— 与「新建邮件是右侧整页」同一套语言。
|
||||
*
|
||||
* 窄屏放不下两栏,退回覆盖式(NarrowStack),与收件箱的行为一致。
|
||||
*/
|
||||
export default function CalendarView() {
|
||||
const narrow = useIsNarrow();
|
||||
const [scale, setScale] = useState<Scale>('month');
|
||||
const [anchor, setAnchor] = useState(() => new Date());
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
// 右栏三态:编辑既有事件 / 新建(带预填时间)/ 看某天的日程
|
||||
const [editing, setEditing] = useState<CalendarEvent | null>(null);
|
||||
const [creating, setCreating] = useState<Date | null>(null);
|
||||
const [selectedDay, setSelectedDay] = useState<Date>(() => new Date());
|
||||
// 窄屏下右栏是否已滑入。宽屏恒为 false(两栏并排,不需要覆盖)
|
||||
const [paneOpen, setPaneOpen] = useState(false);
|
||||
|
||||
const [importing, setImporting] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
/**
|
||||
* 查询区间一律按月视图的 42 格算(含邻月首尾几天)。
|
||||
*
|
||||
* 三种粒度共用同一份数据:切 scale 不必重新请求,也不会出现
|
||||
* 「周视图跨月时后半周空白」。
|
||||
*/
|
||||
const range = useMemo(() => {
|
||||
const cells = monthGrid(anchor);
|
||||
return {
|
||||
from: startOfDay(cells[0]).toISOString(),
|
||||
to: endOfDay(cells[cells.length - 1]).toISOString()
|
||||
};
|
||||
}, [anchor]);
|
||||
|
||||
async function load() {
|
||||
setErr('');
|
||||
try {
|
||||
const r = await api.listCalendarEvents({ from: range.from, to: range.to });
|
||||
setEvents(r.events ?? []);
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
load();
|
||||
}, [range.from, range.to]);
|
||||
|
||||
const byDay = useMemo(() => bucketByDay(events), [events]);
|
||||
|
||||
function shift(dir: 1 | -1) {
|
||||
if (scale === 'month') setAnchor(a => addMonths(a, dir));
|
||||
else if (scale === 'week') setAnchor(a => addDays(a, dir * 7));
|
||||
else setAnchor(a => addDays(a, dir));
|
||||
}
|
||||
|
||||
function goToday() {
|
||||
const now = new Date();
|
||||
setAnchor(now);
|
||||
setSelectedDay(now);
|
||||
}
|
||||
|
||||
/** 点某天:宽屏只换右栏内容,窄屏滑入右栏。 */
|
||||
function pickDay(d: Date) {
|
||||
setSelectedDay(d);
|
||||
setEditing(null);
|
||||
setCreating(null);
|
||||
if (narrow) setPaneOpen(true);
|
||||
}
|
||||
|
||||
function pickEvent(e: CalendarEvent) {
|
||||
setEditing(e);
|
||||
setCreating(null);
|
||||
if (narrow) setPaneOpen(true);
|
||||
}
|
||||
|
||||
function startCreate(at: Date) {
|
||||
setCreating(at);
|
||||
setEditing(null);
|
||||
setSelectedDay(at);
|
||||
if (narrow) setPaneOpen(true);
|
||||
}
|
||||
|
||||
function closePane() {
|
||||
setEditing(null);
|
||||
setCreating(null);
|
||||
setPaneOpen(false);
|
||||
}
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (scale === 'month') return `${anchor.getFullYear()} 年 ${anchor.getMonth() + 1} 月`;
|
||||
if (scale === 'week') {
|
||||
const a = startOfWeek(anchor);
|
||||
const b = endOfWeek(anchor);
|
||||
// 跨月时两头都写月份,否则「9月28日 - 4日」看不出后者是十月
|
||||
if (a.getMonth() === b.getMonth()) {
|
||||
return `${a.getFullYear()} 年 ${a.getMonth() + 1} 月 ${a.getDate()}–${b.getDate()} 日`;
|
||||
}
|
||||
return `${a.getMonth() + 1}.${a.getDate()} – ${b.getMonth() + 1}.${b.getDate()}`;
|
||||
}
|
||||
return `${anchor.getFullYear()} 年 ${anchor.getMonth() + 1} 月 ${anchor.getDate()} 日 周${
|
||||
WEEKDAY_LABELS[(anchor.getDay() + 6) % 7]
|
||||
}`;
|
||||
}, [scale, anchor]);
|
||||
|
||||
async function doExport() {
|
||||
try {
|
||||
const ics = await api.exportCalendarICS(range.from, range.to);
|
||||
// Blob 下载而不是导航:导航会丢掉 cookie 之外的认证头
|
||||
const url = URL.createObjectURL(new Blob([ics], { type: 'text/calendar' }));
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `agentmail-${dayKey(anchor)}.ics`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || '导出失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function doImport(f: File) {
|
||||
setImporting(true);
|
||||
setErr('');
|
||||
try {
|
||||
const r = await api.importCalendarICS(await f.text());
|
||||
await load();
|
||||
setErr(`已导入 ${r.imported} 个事件${r.skipped ? `,跳过 ${r.skipped} 个` : ''}`);
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || '导入失败');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 左栏:工具条 + 网格 ───
|
||||
const gridPane = (
|
||||
<div className="flex-1 min-w-0 flex flex-col bg-white min-h-0">
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-gray-200 shrink-0 flex-wrap">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => shift(-1)}
|
||||
className="p-1.5 rounded hover:bg-gray-100 text-gray-600"
|
||||
aria-label="上一页"
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => shift(1)}
|
||||
className="p-1.5 rounded hover:bg-gray-100 text-gray-600"
|
||||
aria-label="下一页"
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={goToday}
|
||||
className="px-2.5 py-1 text-xs border border-gray-300 rounded hover:bg-gray-50 text-gray-700"
|
||||
>
|
||||
今天
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium text-gray-900 min-w-0">
|
||||
<CalendarIcon className="w-4 h-4 text-gray-400 shrink-0" />
|
||||
<span className="truncate">{title}</span>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<div className="flex rounded border border-gray-300 overflow-hidden">
|
||||
{(['month', 'week', 'day'] as Scale[]).map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setScale(s)}
|
||||
className={`px-2.5 py-1 text-xs ${
|
||||
scale === s ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{s === 'month' ? '月' : s === 'week' ? '周' : '日'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={doExport}
|
||||
title="导出 .ics"
|
||||
className="p-1.5 rounded hover:bg-gray-100 text-gray-600"
|
||||
aria-label="导出"
|
||||
>
|
||||
<DownloadIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
title="导入 .ics"
|
||||
disabled={importing}
|
||||
className="p-1.5 rounded hover:bg-gray-100 text-gray-600 disabled:opacity-40"
|
||||
aria-label="导入"
|
||||
>
|
||||
{importing ? <SpinnerIcon className="w-4 h-4 animate-spin" /> : <UploadIcon />}
|
||||
</button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".ics,text/calendar"
|
||||
className="hidden"
|
||||
onChange={e => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) doImport(f);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => startCreate(atNineOClock(selectedDay))}
|
||||
className="px-2.5 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 flex items-center gap-1"
|
||||
>
|
||||
<PlusIcon className="w-3.5 h-3.5" />
|
||||
新建
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{err && (
|
||||
<div className="px-3 py-2 bg-amber-50 border-b border-amber-200 text-xs text-amber-800 shrink-0">
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-400">
|
||||
<SpinnerIcon className="w-6 h-6 animate-spin" />
|
||||
</div>
|
||||
) : scale === 'month' ? (
|
||||
<MonthGrid
|
||||
anchor={anchor}
|
||||
selectedDay={selectedDay}
|
||||
byDay={byDay}
|
||||
onPickDay={pickDay}
|
||||
onPickEvent={pickEvent}
|
||||
onCreateAt={startCreate}
|
||||
/>
|
||||
) : scale === 'week' ? (
|
||||
<WeekGrid
|
||||
anchor={anchor}
|
||||
selectedDay={selectedDay}
|
||||
byDay={byDay}
|
||||
onPickDay={pickDay}
|
||||
onPickEvent={pickEvent}
|
||||
onCreateAt={startCreate}
|
||||
/>
|
||||
) : (
|
||||
<DayGrid
|
||||
anchor={anchor}
|
||||
events={byDay.get(dayKey(anchor)) ?? []}
|
||||
onPickEvent={pickEvent}
|
||||
onCreateAt={startCreate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ─── 右栏:编辑器 / 当日日程 ───
|
||||
const sidePane =
|
||||
editing || creating ? (
|
||||
<CalendarEventEditor
|
||||
event={editing}
|
||||
initialTime={creating ?? undefined}
|
||||
onClose={closePane}
|
||||
onSaved={load}
|
||||
/>
|
||||
) : (
|
||||
<DayAgendaPane
|
||||
day={selectedDay}
|
||||
events={byDay.get(dayKey(selectedDay)) ?? []}
|
||||
onPickEvent={pickEvent}
|
||||
onCreate={() => startCreate(atNineOClock(selectedDay))}
|
||||
onClose={narrow ? closePane : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
// 窄屏:右栏覆盖在网格上,滑入滑出(与收件箱详情同一套动画)
|
||||
if (narrow) {
|
||||
return <NarrowStack base={gridPane} overlay={sidePane} open={paneOpen} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-w-0 flex min-h-0">
|
||||
{gridPane}
|
||||
<div className="w-full lg:w-[400px] shrink-0 border-l border-gray-200 bg-white flex flex-col min-h-0">
|
||||
{sidePane}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 新建事件的默认时刻:那天上午 9 点,比「现在」有用得多。 */
|
||||
function atNineOClock(d: Date): Date {
|
||||
const x = new Date(d);
|
||||
x.setHours(9, 0, 0, 0);
|
||||
return x;
|
||||
}
|
||||
|
||||
function hhmm(iso: string): string {
|
||||
const t = new Date(iso);
|
||||
if (Number.isNaN(t.getTime())) return '';
|
||||
return `${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** 收件人数量标记。多收件人是常态,格子里得看得出来。 */
|
||||
function recipientCount(e: CalendarEvent): number {
|
||||
if (e.recipients?.length) return e.recipients.length;
|
||||
return e.to_address || e.agent_name ? 1 : 0;
|
||||
}
|
||||
|
||||
/** 事件在格子里的小色条。 */
|
||||
function EventChip({
|
||||
e,
|
||||
onClick,
|
||||
showTime = true
|
||||
}: {
|
||||
e: CalendarEvent;
|
||||
onClick: () => void;
|
||||
showTime?: boolean;
|
||||
}) {
|
||||
// 暂停/取消的事件不会触发提醒,视觉上必须与生效的区分开 ——
|
||||
// 否则人以为设好了,实际到点什么都不会发生
|
||||
const dead = e.status !== 'active';
|
||||
const n = recipientCount(e);
|
||||
return (
|
||||
<button
|
||||
onClick={ev => {
|
||||
ev.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
title={`${e.title}${n > 1 ? `(${n} 个收件人)` : ''}`}
|
||||
className={`w-full text-left px-1.5 py-0.5 rounded text-xs truncate flex items-center gap-1 ${
|
||||
dead
|
||||
? 'bg-gray-100 text-gray-400 line-through'
|
||||
: 'bg-blue-50 text-blue-800 hover:bg-blue-100'
|
||||
}`}
|
||||
>
|
||||
{dead && <PauseIcon className="w-3 h-3 shrink-0" />}
|
||||
{showTime && <span className="tabular-nums shrink-0 opacity-70">{hhmm(e.event_time)}</span>}
|
||||
<span className="truncate">{e.title}</span>
|
||||
{n > 1 && <span className="shrink-0 opacity-70 tabular-nums">·{n}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function MonthGrid({
|
||||
anchor,
|
||||
selectedDay,
|
||||
byDay,
|
||||
onPickDay,
|
||||
onPickEvent,
|
||||
onCreateAt
|
||||
}: {
|
||||
anchor: Date;
|
||||
selectedDay: Date;
|
||||
byDay: Map<string, CalendarEvent[]>;
|
||||
onPickDay: (d: Date) => void;
|
||||
onPickEvent: (e: CalendarEvent) => void;
|
||||
onCreateAt: (d: Date) => void;
|
||||
}) {
|
||||
const cells = useMemo(() => monthGrid(anchor), [anchor]);
|
||||
const now = new Date();
|
||||
const curMonth = startOfMonth(anchor).getMonth();
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-y-auto">
|
||||
<div className="grid grid-cols-7 border-b border-gray-200 shrink-0 sticky top-0 bg-white z-10">
|
||||
{WEEKDAY_LABELS.map(w => (
|
||||
<div key={w} className="px-2 py-1.5 text-xs font-medium text-gray-500 text-center">
|
||||
{w}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 grid-rows-6 flex-1 min-h-[30rem]">
|
||||
{cells.map((d, i) => {
|
||||
const list = byDay.get(dayKey(d)) ?? [];
|
||||
const outside = d.getMonth() !== curMonth;
|
||||
const isToday = isSameDay(d, now);
|
||||
const isPicked = isSameDay(d, selectedDay);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => onPickDay(d)}
|
||||
onDoubleClick={() => onCreateAt(atNineOClock(d))}
|
||||
className={`border-b border-r border-gray-100 p-1 flex flex-col gap-0.5 min-h-0 overflow-hidden cursor-pointer ${
|
||||
isPicked ? 'bg-blue-50/70 ring-1 ring-inset ring-blue-300' : outside ? 'bg-gray-50/60' : 'bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-baseline gap-1 shrink-0">
|
||||
<span
|
||||
className={`px-1.5 rounded text-xs tabular-nums ${
|
||||
isToday
|
||||
? 'bg-blue-600 text-white font-medium'
|
||||
: outside
|
||||
? 'text-gray-400'
|
||||
: 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{d.getDate()}
|
||||
</span>
|
||||
{/* 农历日必须显示:农历重复规则的公历日期每次都在变,
|
||||
不显示农历人无法确认「每月十五」到底落在哪一格 */}
|
||||
<span
|
||||
className={`text-[10px] leading-none truncate ${
|
||||
outside ? 'text-gray-300' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{cellLunarLabel(d)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 overflow-hidden">
|
||||
{list.slice(0, 3).map(e => (
|
||||
<EventChip key={e.event_id} e={e} onClick={() => onPickEvent(e)} />
|
||||
))}
|
||||
{list.length > 3 && (
|
||||
<span className="px-1.5 text-xs text-gray-500">还有 {list.length - 3} 项</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WeekGrid({
|
||||
anchor,
|
||||
selectedDay,
|
||||
byDay,
|
||||
onPickDay,
|
||||
onPickEvent,
|
||||
onCreateAt
|
||||
}: {
|
||||
anchor: Date;
|
||||
selectedDay: Date;
|
||||
byDay: Map<string, CalendarEvent[]>;
|
||||
onPickDay: (d: Date) => void;
|
||||
onPickEvent: (e: CalendarEvent) => void;
|
||||
onCreateAt: (d: Date) => void;
|
||||
}) {
|
||||
const days = useMemo(() => weekDays(anchor), [anchor]);
|
||||
const now = new Date();
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
<div className="grid grid-cols-7 min-w-[36rem] h-full">
|
||||
{days.map((d, i) => {
|
||||
const list = byDay.get(dayKey(d)) ?? [];
|
||||
const isToday = isSameDay(d, now);
|
||||
const isPicked = isSameDay(d, selectedDay);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => onPickDay(d)}
|
||||
onDoubleClick={() => onCreateAt(atNineOClock(d))}
|
||||
className={`border-r border-gray-100 flex flex-col min-h-[28rem] cursor-pointer ${
|
||||
isPicked ? 'bg-blue-50/50' : ''
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`px-2 py-1.5 border-b border-gray-200 sticky top-0 z-10 ${
|
||||
isToday ? 'bg-blue-50' : isPicked ? 'bg-blue-50/70' : 'bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs text-gray-500">{WEEKDAY_LABELS[i]}</div>
|
||||
<div
|
||||
className={`text-sm tabular-nums ${
|
||||
isToday ? 'text-blue-700 font-medium' : 'text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{d.getMonth() + 1}.{d.getDate()}
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-400 leading-none">{cellLunarLabel(d)}</div>
|
||||
</div>
|
||||
<div className="flex-1 p-1 flex flex-col gap-1">
|
||||
{list.length === 0 ? (
|
||||
<div className="text-xs text-gray-300 px-1 py-2">—</div>
|
||||
) : (
|
||||
list.map(e => <EventChip key={e.event_id} e={e} onClick={() => onPickEvent(e)} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 日视图:按小时排。空的小时折叠成细条,否则 24 行等高会把有内容的挤出屏幕。 */
|
||||
function DayGrid({
|
||||
anchor,
|
||||
events,
|
||||
onPickEvent,
|
||||
onCreateAt
|
||||
}: {
|
||||
anchor: Date;
|
||||
events: CalendarEvent[];
|
||||
onPickEvent: (e: CalendarEvent) => void;
|
||||
onCreateAt: (d: Date) => void;
|
||||
}) {
|
||||
const byHour = useMemo(() => {
|
||||
const m = new Map<number, CalendarEvent[]>();
|
||||
for (const e of events) {
|
||||
const t = new Date(e.event_time);
|
||||
if (Number.isNaN(t.getTime())) continue;
|
||||
const h = t.getHours();
|
||||
const b = m.get(h);
|
||||
if (b) b.push(e);
|
||||
else m.set(h, [e]);
|
||||
}
|
||||
return m;
|
||||
}, [events]);
|
||||
|
||||
const nowHour = isSameDay(anchor, new Date()) ? new Date().getHours() : -1;
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="px-3 py-2 text-xs text-gray-500 border-b border-gray-100">
|
||||
{formatSolarWithLunar(anchor)}
|
||||
</div>
|
||||
<div className="divide-y divide-gray-100">
|
||||
{HOURS.map(h => {
|
||||
const list = byHour.get(h) ?? [];
|
||||
const at = new Date(anchor);
|
||||
at.setHours(h, 0, 0, 0);
|
||||
return (
|
||||
<div
|
||||
key={h}
|
||||
onDoubleClick={() => onCreateAt(at)}
|
||||
className={`flex gap-3 px-3 ${list.length ? 'py-2' : 'py-1 hover:bg-gray-50'} ${
|
||||
h === nowHour ? 'bg-blue-50/40' : ''
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-10 shrink-0 text-xs tabular-nums ${
|
||||
list.length ? 'text-gray-500 pt-1' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{String(h).padStart(2, '0')}:00
|
||||
</span>
|
||||
{list.length === 0 ? (
|
||||
<span className="text-xs text-gray-200">—</span>
|
||||
) : (
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-2">
|
||||
{list.map(e => (
|
||||
<EventRow key={e.event_id} e={e} onClick={() => onPickEvent(e)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 右栏默认内容:选中那天的日程。
|
||||
*
|
||||
* 这一栏的存在本身就是为了「宽屏右边不空着」—— 所以它必须在**没有**
|
||||
* 任何事件时也有话说(提示怎么新建),而不是渲染一片空白。
|
||||
*/
|
||||
function DayAgendaPane({
|
||||
day,
|
||||
events,
|
||||
onPickEvent,
|
||||
onCreate,
|
||||
onClose
|
||||
}: {
|
||||
day: Date;
|
||||
events: CalendarEvent[];
|
||||
onPickEvent: (e: CalendarEvent) => void;
|
||||
onCreate: () => void;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const isToday = isSameDay(day, new Date());
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 bg-white">
|
||||
<div className="px-4 py-3 border-b border-gray-200 shrink-0 flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-base font-medium text-gray-900">
|
||||
{day.getMonth() + 1} 月 {day.getDate()} 日
|
||||
</h2>
|
||||
<span className="text-xs text-gray-500">
|
||||
周{WEEKDAY_LABELS[(day.getDay() + 6) % 7]}
|
||||
</span>
|
||||
{isToday && (
|
||||
<span className="px-1.5 py-0.5 text-xs bg-blue-600 text-white rounded">今天</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-gray-500">{formatSolarWithLunar(day)}</p>
|
||||
</div>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded hover:bg-gray-100 text-gray-500 shrink-0"
|
||||
aria-label="返回日历"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-3">
|
||||
{events.length === 0 ? (
|
||||
<div className="text-sm text-gray-500">
|
||||
<p>这一天没有日程。</p>
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
在网格上双击任意格子也能直接新建。
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{events.map(e => (
|
||||
<EventRow key={e.event_id} e={e} onClick={() => onPickEvent(e)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 border-t border-gray-200 shrink-0">
|
||||
<button
|
||||
onClick={onCreate}
|
||||
className="w-full px-3 py-2 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
在这一天新建日程
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 完整事件行:收件方 / 提醒时刻 / 重复规则都写出来。 */
|
||||
function EventRow({ e, onClick }: { e: CalendarEvent; onClick: () => void }) {
|
||||
const t = new Date(e.event_time);
|
||||
const rt = remindAt(e);
|
||||
const dead = e.status !== 'active';
|
||||
const recips = e.recipients?.length ? e.recipients : [e.to_address || e.agent_name].filter(Boolean);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full text-left px-3 py-2 rounded border ${
|
||||
dead
|
||||
? 'bg-gray-50 border-gray-200'
|
||||
: 'bg-white border-gray-200 hover:border-blue-300 hover:bg-blue-50/30'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs text-gray-500 tabular-nums shrink-0">{hhmm(e.event_time)}</span>
|
||||
<span
|
||||
className={`text-sm font-medium truncate ${
|
||||
dead ? 'text-gray-400 line-through' : 'text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{e.title}
|
||||
</span>
|
||||
{dead && (
|
||||
<span className="px-1.5 py-0.5 text-xs bg-gray-200 text-gray-600 rounded shrink-0">
|
||||
{e.status === 'paused' ? '已暂停' : '已取消'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{e.description && (
|
||||
<p className="text-xs text-gray-600 mb-1.5 line-clamp-2">{e.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-500">
|
||||
{recips.length > 0 && (
|
||||
<span className="flex items-center gap-1 min-w-0">
|
||||
{recips.length > 1 ? (
|
||||
<UsersIcon className="w-3 h-3 shrink-0" />
|
||||
) : (
|
||||
<BotIcon className="w-3 h-3 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">
|
||||
{recips.length > 1
|
||||
? `${recips.length} 人 · ${e.delivery_mode === 'together' ? '同一线索' : '各自独立'}`
|
||||
: recips[0]}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<BellIcon className="w-3 h-3" />
|
||||
{describeRemindBefore(e.remind_before)}
|
||||
{e.remind_before > 0 && (
|
||||
<span className="tabular-nums opacity-70">
|
||||
({String(rt.getHours()).padStart(2, '0')}:{String(rt.getMinutes()).padStart(2, '0')})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{e.recurrence !== 'none' && (
|
||||
<span
|
||||
className={`flex items-center gap-1 ${
|
||||
isLunarRecurrence(e.recurrence) ? 'text-amber-700' : ''
|
||||
}`}
|
||||
>
|
||||
<RepeatIcon className="w-3 h-3" />
|
||||
{describeRecurrenceRule(e.recurrence, Number.isNaN(t.getTime()) ? undefined : t)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
349
client/electron/src/components/ComposePage.tsx
Normal file
349
client/electron/src/components/ComposePage.tsx
Normal file
@ -0,0 +1,349 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import * as api from '../api/client';
|
||||
import { useUIStore } from '../stores/uiStore';
|
||||
import NarrowOnly from './NarrowOnly';
|
||||
import { useMailStore } from '../stores/mailStore';
|
||||
import { useSessionStore } from '../stores/sessionStore';
|
||||
import { useContactStore } from '../stores/contactStore';
|
||||
import { useIsNarrow } from '../hooks/useIsNarrow';
|
||||
import AddressInput from './AddressInput';
|
||||
import { AttachmentPicker, type PendingAttachment } from './Attachments';
|
||||
import { ComposeIcon, ChevronLeftIcon } from './icons';
|
||||
|
||||
/** 完整的写邮件页面,占据右侧整个区域 */
|
||||
export default function ComposePage() {
|
||||
const prefill = useUIStore(s => s.composePrefill);
|
||||
const cancelCompose = useUIStore(s => s.cancelCompose);
|
||||
const fetchInbox = useMailStore(s => s.fetchInbox);
|
||||
const fetchSent = useMailStore(s => s.fetchSent);
|
||||
const fetchSessions = useSessionStore(s => s.fetchSessions);
|
||||
const fetchContacts = useContactStore(s => s.fetchContacts);
|
||||
const isNarrow = useIsNarrow();
|
||||
|
||||
const [to, setTo] = useState(prefill?.to ?? '');
|
||||
const [cc, setCc] = useState(prefill?.cc ?? '');
|
||||
const [subject, setSubject] = useState('');
|
||||
const [body, setBody] = useState('');
|
||||
const [sessionAlias, setSessionAlias] = useState('');
|
||||
// 本任务的往返预算。空 = 不限。
|
||||
// 配额的语义是「这件事值得多少个来回」—— 那是任务的属性,所以在派活这一刻给,
|
||||
// 而不是事后到管理员页面去调某个 Agent 的全局配额。
|
||||
const [maxRounds, setMaxRounds] = useState('');
|
||||
// 权限档位(仅新建会话时生效):plan / workspace / full。
|
||||
const [permissionMode, setPermissionMode] = useState('workspace');
|
||||
// 收件 Agent 的默认预算;null = 还没查到(未注册的收件人也是 null)
|
||||
const [agentDefault, setAgentDefault] = useState<number | null>(null);
|
||||
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
||||
const [preview, setPreview] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [okMsg, setOkMsg] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setTo(prefill?.to ?? '');
|
||||
setCc(prefill?.cc ?? '');
|
||||
}, [prefill]);
|
||||
|
||||
// 三维地址的 name 位 = 收件 Agent 名
|
||||
const toName = to.trim().split('@')[0].trim();
|
||||
|
||||
// 收件人变了就重查该 Agent 的默认预算。
|
||||
// 只在新建会话时需要(续谈沿用会话已有预算),所以别的情况不打接口。
|
||||
const isNewTarget = /\.new\s*$/.test(to.trim());
|
||||
useEffect(() => {
|
||||
if (!isNewTarget || toName === '') {
|
||||
setAgentDefault(null);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
api
|
||||
.listAgents()
|
||||
.then(r => {
|
||||
if (!alive) return;
|
||||
const hit = r.agents?.find(a => a.agent_name === toName);
|
||||
setAgentDefault(hit?.default_rounds ?? null);
|
||||
})
|
||||
.catch(() => {
|
||||
// 查不到就不显示提示,不该因此打断写信
|
||||
if (alive) setAgentDefault(null);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [toName, isNewTarget]);
|
||||
|
||||
// 会话别名只在新建会话(地址以 .new 结尾)时有意义;
|
||||
// 命中已有会话或走默认会话时后端会忽略该字段。
|
||||
const isNewSession = /\.new\s*$/.test(to.trim());
|
||||
const aliasError =
|
||||
isNewSession && sessionAlias.trim() !== '' && /[.\s/@]/.test(sessionAlias.trim())
|
||||
? '别名不可含 . 空白 / 或 @'
|
||||
: isNewSession && sessionAlias.trim() === 'new'
|
||||
? '"new" 是寻址保留字'
|
||||
: null;
|
||||
|
||||
// 输入框的 placeholder:人在派活时该看得到「不填会是多少」
|
||||
const defaultRoundsHint =
|
||||
agentDefault === null ? '默认' : agentDefault === 0 ? '不限' : `默认 ${agentDefault}`;
|
||||
|
||||
const roundsError =
|
||||
maxRounds.trim() !== '' && !/^\d+$/.test(maxRounds.trim())
|
||||
? '预算必须是非负整数(0 = 不限)'
|
||||
: null;
|
||||
|
||||
const canSend =
|
||||
roundsError === null &&
|
||||
to.trim() !== '' &&
|
||||
subject.trim() !== '' &&
|
||||
body.trim() !== '' &&
|
||||
aliasError === null &&
|
||||
!sending;
|
||||
|
||||
const send = async () => {
|
||||
if (!canSend) return;
|
||||
setSending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.sendMail(to.trim(), subject.trim(), body, {
|
||||
cc: cc.trim(),
|
||||
session_alias: isNewSession ? sessionAlias.trim() : '',
|
||||
attachment_ids: attachments.map(a => a.id),
|
||||
...(isNewSession && maxRounds.trim() !== ''
|
||||
? { max_rounds: Number(maxRounds.trim()) }
|
||||
: {}),
|
||||
...(permissionMode ? { permission_mode: permissionMode } : {}),
|
||||
});
|
||||
const where = res.session_alias
|
||||
? `会话别名 ${res.session_alias}`
|
||||
: `会话 ${res.session_id.slice(0, 8)}`;
|
||||
setOkMsg(
|
||||
res.budget_max ? `已发送 · ${where} · 预算 ${res.budget_max} 个来回` : `已发送 · ${where}`
|
||||
);
|
||||
await Promise.all([fetchInbox('all'), fetchSent(), fetchSessions(), fetchContacts()]);
|
||||
setTimeout(() => {
|
||||
setOkMsg(null);
|
||||
cancelCompose();
|
||||
}, 900);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-w-0 min-h-0 flex flex-col overflow-y-auto lg:overflow-hidden overscroll-contain bg-white">
|
||||
<div className="shrink-0 sticky top-0 z-10 lg:static px-4 md:px-6 py-3 border-b border-gray-200 bg-white flex items-center gap-2">
|
||||
{/* 窄屏下写信是盖在列表上的覆盖层,得有个退出口。
|
||||
用 cancelCompose 而不是 showList:写信态本身要一起结束,
|
||||
只滑走覆盖层的话下次进列表又会弹回来 */}
|
||||
<NarrowOnly>
|
||||
<button
|
||||
onClick={cancelCompose}
|
||||
className="tap -ml-1 inline-flex items-center gap-0.5 py-1 pr-1 text-gray-500 active:bg-gray-100 rounded"
|
||||
aria-label="返回"
|
||||
>
|
||||
<ChevronLeftIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</NarrowOnly>
|
||||
<ComposeIcon className="w-4 h-4 text-blue-600 shrink-0" />
|
||||
<h2 className="text-sm font-semibold text-gray-900">新建邮件</h2>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={() => {
|
||||
setTo('');
|
||||
setCc('');
|
||||
setSubject('');
|
||||
setBody('');
|
||||
setSessionAlias('');
|
||||
setMaxRounds('');
|
||||
setPermissionMode('');
|
||||
// 已上传的附件要从服务端删掉,否则留到 GC 才回收
|
||||
attachments.forEach(a => void api.deleteAttachment(a.id).catch(() => {}));
|
||||
setAttachments([]);
|
||||
setError(null);
|
||||
}}
|
||||
className="tap text-xs text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 px-4 md:px-6 py-4 space-y-3 border-b border-gray-200">
|
||||
<Field label="收件人" hint="name@path.session:省略=默认会话,new=新建,别名=已有会话">
|
||||
<AddressInput
|
||||
value={to}
|
||||
onChange={setTo}
|
||||
autoFocus={!isNarrow}
|
||||
placeholder="deepseekharness@/program.upadtefeature"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{isNewSession && (
|
||||
<Field label="会话别名" hint="可选;命名后可用 name@path.别名 续谈,全局唯一">
|
||||
<input
|
||||
value={sessionAlias}
|
||||
onChange={e => setSessionAlias(e.target.value)}
|
||||
placeholder="refactor-auth"
|
||||
className="w-full text-sm border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
/>
|
||||
{aliasError && <span className="text-[10px] text-red-600">{aliasError}</span>}
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label="权限档位" hint={isNewSession ? "Agent 在这类任务里被允许动手的程度" : "改了即刻生效(该会话的档位会更新)"}>
|
||||
<div className="flex items-center gap-2">
|
||||
{[
|
||||
{ value: 'plan', label: '只读', desc: '不许写/改/执行' },
|
||||
{ value: 'workspace', label: '目录内', desc: '越界问人' },
|
||||
{ value: 'full', label: '全权', desc: '自动放行' },
|
||||
].map(o => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
onClick={() => setPermissionMode(o.value)}
|
||||
title={o.desc}
|
||||
className={`px-2.5 py-1.5 rounded-md border text-xs font-medium transition-colors ${
|
||||
permissionMode === o.value
|
||||
? 'border-blue-500 bg-blue-50 text-blue-700'
|
||||
: 'border-gray-300 bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{isNewSession && (
|
||||
<Field label="往返预算" hint="留空 = 用该 Agent 的默认值;之后可在对话页随时调整">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
value={maxRounds}
|
||||
onChange={e => setMaxRounds(e.target.value)}
|
||||
inputMode="numeric"
|
||||
placeholder={defaultRoundsHint}
|
||||
className="w-24 text-sm border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
/>
|
||||
<span className="text-[11px] text-gray-400">
|
||||
个来回后 Agent 停止主动发信(自动转发的总结与权限询问不占预算)
|
||||
</span>
|
||||
</div>
|
||||
{roundsError && <span className="text-[10px] text-red-600">{roundsError}</span>}
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label="抄送" hint="多个地址用逗号分隔">
|
||||
<AddressInput value={cc} onChange={setCc} allowMultiple placeholder="pi@root.new" />
|
||||
</Field>
|
||||
|
||||
<Field label="主题">
|
||||
<input
|
||||
value={subject}
|
||||
onChange={e => setSubject(e.target.value)}
|
||||
placeholder="更新特性分支"
|
||||
className="w-full text-sm border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 lg:flex-1 lg:min-h-0 px-4 md:px-6 py-3 flex flex-col">
|
||||
<div className="shrink-0 flex items-center gap-2 mb-1.5">
|
||||
<span className="text-[11px] font-medium text-gray-500">正文(Markdown)</span>
|
||||
<div className="flex-1" />
|
||||
<Toggle active={!preview} onClick={() => setPreview(false)}>
|
||||
编辑
|
||||
</Toggle>
|
||||
<Toggle active={preview} onClick={() => setPreview(true)}>
|
||||
预览
|
||||
</Toggle>
|
||||
</div>
|
||||
|
||||
{preview ? (
|
||||
<div className="markdown flex-1 min-h-[12rem] lg:min-h-0 overflow-y-auto border border-gray-200 rounded-md p-4">
|
||||
{body.trim() ? (
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{body}</Markdown>
|
||||
) : (
|
||||
<p className="text-gray-400 text-sm">暂无内容</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={e => setBody(e.target.value)}
|
||||
placeholder={'## 需求\n\n请在 /program 下推进 update feature…'}
|
||||
className="flex-1 min-h-[12rem] lg:min-h-0 w-full text-sm font-mono border border-gray-300 rounded-md p-4 resize-y lg:resize-none focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 窄屏底部操作栏是 sticky,会在矮视口里向上吸附;预留一栏高度,
|
||||
避免它覆盖附件按钮。桌面操作栏回到普通文档流,不需要这段缓冲。 */}
|
||||
<div className="shrink-0 px-4 md:px-6 pb-20 lg:pb-3">
|
||||
<AttachmentPicker items={attachments} onChange={setAttachments} disabled={sending} />
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 sticky bottom-0 z-10 lg:static px-4 md:px-6 py-3 border-t border-gray-200 bg-white flex items-center gap-3 flex-wrap">
|
||||
{error && <span className="min-w-0 text-xs text-red-600 break-words">{error}</span>}
|
||||
{okMsg && <span className="min-w-0 text-xs text-green-700 break-words">{okMsg}</span>}
|
||||
<div className="flex-1 min-w-2" />
|
||||
<button
|
||||
onClick={cancelCompose}
|
||||
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={send}
|
||||
disabled={!canSend}
|
||||
className="px-5 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{sending ? '发送中' : '发送'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
hint,
|
||||
children
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-baseline gap-2 mb-1">
|
||||
<label className="text-[11px] font-medium text-gray-500">{label}</label>
|
||||
{hint && <span className="text-[10px] text-gray-400">{hint}</span>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({
|
||||
active,
|
||||
onClick,
|
||||
children
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`tap text-[11px] px-2 py-0.5 rounded ${
|
||||
active ? 'bg-blue-600 text-white' : 'text-gray-500 hover:text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
37
client/electron/src/components/ConnectionIndicator.tsx
Normal file
37
client/electron/src/components/ConnectionIndicator.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { onSSEStatus, type SSEStatus } from '../api/sse';
|
||||
|
||||
/**
|
||||
* SSE 连接状态指示器。
|
||||
*
|
||||
* 实时性是 Agent 协作的核心体验:断线后用户以为系统正常,实际上通知已经停了。
|
||||
* 一个小小的绿/黄/红点就能避免「Agent 没在动」的误判。
|
||||
*
|
||||
* 不做成弹窗或横幅 —— 那会打断正在进行的对话。一个点足够了:
|
||||
* 会看它的人自然会看,不会看的人不需要被打扰。
|
||||
*/
|
||||
export function ConnectionIndicator() {
|
||||
const [status, setStatus] = useState<SSEStatus>('connecting');
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = onSSEStatus(setStatus);
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
const map: Record<SSEStatus, { color: string; title: string }> = {
|
||||
connecting: { color: 'bg-yellow-400', title: '正在连接…' },
|
||||
connected: { color: 'bg-green-500', title: '已连接' },
|
||||
reconnecting: { color: 'bg-orange-400', title: '重连中…' },
|
||||
disconnected: { color: 'bg-red-400', title: '已断开' },
|
||||
};
|
||||
|
||||
const { color, title } = map[status] || map.disconnected;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${color} shrink-0 transition-colors duration-300`}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
/>
|
||||
);
|
||||
}
|
||||
276
client/electron/src/components/ContactPanel.tsx
Normal file
276
client/electron/src/components/ContactPanel.tsx
Normal file
@ -0,0 +1,276 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useContactStore } from '../stores/contactStore';
|
||||
import { useSessionStore } from '../stores/sessionStore';
|
||||
import { useUIStore } from '../stores/uiStore';
|
||||
import { useMailStore } from '../stores/mailStore';
|
||||
import type { Contact } from '../types';
|
||||
import {
|
||||
ArchiveIcon,
|
||||
ComposeIcon,
|
||||
CheckIcon,
|
||||
CloseIcon,
|
||||
ChevronRightIcon,
|
||||
ListViewIcon,
|
||||
CardViewIcon
|
||||
} from './icons';
|
||||
import { WorkCard } from './WorkCard';
|
||||
|
||||
/**
|
||||
* 左侧联系人面板:列出所有 name@path.session,支持
|
||||
* - 点击进入该会话
|
||||
* - 写信(预填收件人为该三维地址)
|
||||
* - 归档(Agent 侧会话归档 + 邮箱界面移除)
|
||||
*/
|
||||
export default function ContactPanel() {
|
||||
const contacts = useContactStore(s => s.contacts);
|
||||
const archivedContacts = useContactStore(s => s.archivedContacts);
|
||||
const showArchived = useContactStore(s => s.showArchived);
|
||||
const loading = useContactStore(s => s.loading);
|
||||
const error = useContactStore(s => s.error);
|
||||
const pendingArchive = useContactStore(s => s.pendingArchive);
|
||||
const fetchContacts = useContactStore(s => s.fetchContacts);
|
||||
const toggleArchivedView = useContactStore(s => s.toggleArchivedView);
|
||||
const requestArchive = useContactStore(s => s.requestArchive);
|
||||
const cancelArchive = useContactStore(s => s.cancelArchive);
|
||||
const archive = useContactStore(s => s.archive);
|
||||
const view = useContactStore(s => s.view);
|
||||
const setView = useContactStore(s => s.setView);
|
||||
|
||||
const selectSession = useSessionStore(s => s.selectSession);
|
||||
const currentSession = useSessionStore(s => s.currentSession);
|
||||
const clearCurrentMail = useMailStore(s => s.clearCurrentMail);
|
||||
const startCompose = useUIStore(s => s.startCompose);
|
||||
const cancelCompose = useUIStore(s => s.cancelCompose);
|
||||
const showDetail = useUIStore(s => s.showDetail);
|
||||
|
||||
useEffect(() => {
|
||||
fetchContacts();
|
||||
}, []);
|
||||
|
||||
const open = (c: Contact) => {
|
||||
cancelCompose();
|
||||
clearCurrentMail();
|
||||
selectSession(c.session_id);
|
||||
showDetail(); // 窄屏下切到会话内容栏
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-full shrink-0 border-r border-gray-200 bg-white flex flex-col min-w-0 ${
|
||||
// 卡片要放两行摘要 + 预算条,320px 会挤;列表视图保持紧凑
|
||||
view === 'card' ? 'lg:w-[400px]' : 'lg:w-[320px]'
|
||||
}`}
|
||||
>
|
||||
<div className="px-4 py-3 border-b border-gray-200 flex items-center gap-1">
|
||||
<h2 className="text-sm font-semibold text-gray-800">
|
||||
{view === 'card' ? '工作列表' : '联系人'}
|
||||
</h2>
|
||||
<span className="ml-2 text-xs text-gray-400">{contacts.length}</span>
|
||||
<div className="flex-1" />
|
||||
{/* 视图切换:列表答「跟谁在聊」,卡片答「在聊什么、进展如何」 */}
|
||||
<button
|
||||
onClick={() => setView(view === 'list' ? 'card' : 'list')}
|
||||
title={view === 'list' ? '切换到卡片视图' : '切换到列表视图'}
|
||||
className="tap p-1 rounded text-gray-400 hover:text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
{view === 'list' ? (
|
||||
<CardViewIcon className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<ListViewIcon className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleArchivedView}
|
||||
className={`tap text-[11px] px-1.5 py-0.5 rounded ${
|
||||
showArchived ? 'bg-blue-600 text-white' : 'text-gray-500 hover:text-gray-800'
|
||||
}`}
|
||||
>
|
||||
归档
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="px-4 py-2 text-xs text-red-600">{error}</p>}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{loading && contacts.length === 0 && (
|
||||
<p className="text-xs text-gray-400 text-center py-6">加载中</p>
|
||||
)}
|
||||
|
||||
{contacts.map(c =>
|
||||
// 归档确认态两种视图共用同一个确认框:那是个破坏性操作,
|
||||
// 换个视图就换套确认 UI 只会让人对「点了什么」更没底
|
||||
pendingArchive === c.address ? (
|
||||
<ArchiveConfirm
|
||||
key={c.session_id}
|
||||
contact={c}
|
||||
onCancel={cancelArchive}
|
||||
onConfirm={() => archive(c)}
|
||||
/>
|
||||
) : view === 'card' ? (
|
||||
<WorkCard
|
||||
key={c.session_id}
|
||||
contact={c}
|
||||
active={currentSession?.session_id === c.session_id}
|
||||
onOpen={() => open(c)}
|
||||
onCompose={() => startCompose({ to: c.address })}
|
||||
onArchive={() => requestArchive(c.address)}
|
||||
/>
|
||||
) : (
|
||||
<ContactRow
|
||||
key={c.session_id}
|
||||
contact={c}
|
||||
active={currentSession?.session_id === c.session_id}
|
||||
onOpen={() => open(c)}
|
||||
onCompose={() => startCompose({ to: c.address })}
|
||||
onRequestArchive={() => requestArchive(c.address)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{!loading && contacts.length === 0 && (
|
||||
<p className="text-xs text-gray-400 text-center py-6">
|
||||
{view === 'card'
|
||||
? '暂无进行中的工作,发一封邮件即可开始'
|
||||
: '暂无联系人,发一封邮件即可建立'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showArchived && (
|
||||
<div className="pt-3 mt-2 border-t border-gray-200">
|
||||
<p className="px-2 pb-1 text-[11px] font-medium text-gray-400">
|
||||
已归档 {archivedContacts.length}
|
||||
</p>
|
||||
{archivedContacts.map(c => (
|
||||
<div
|
||||
key={c.session_id}
|
||||
className="px-3 py-2 rounded-lg opacity-60 hover:opacity-100 hover:bg-gray-50"
|
||||
>
|
||||
<p className="text-xs font-mono text-gray-500 truncate">{c.address}</p>
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">
|
||||
{c.mail_count} 封 · 已归档
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{archivedContacts.length === 0 && (
|
||||
<p className="text-xs text-gray-400 text-center py-3">无归档会话</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 归档确认框。
|
||||
*
|
||||
* 列表视图与卡片视图共用:归档是破坏性操作,换个视图就换套确认 UI
|
||||
* 只会让人对「自己点了什么」更没底。
|
||||
*/
|
||||
function ArchiveConfirm({
|
||||
contact,
|
||||
onCancel,
|
||||
onConfirm
|
||||
}: {
|
||||
contact: Contact;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-3 py-2.5 rounded-lg border border-red-200 bg-red-50">
|
||||
<p className="text-xs text-gray-800">
|
||||
归档 <span className="font-mono">{contact.address}</span>?
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-500 mt-0.5">
|
||||
对应 Agent 的 session 将被归档,此列表与邮箱界面同时移除
|
||||
</p>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="tap inline-flex items-center gap-1 px-2.5 py-1 rounded-md bg-red-600 text-white text-[11px] font-medium hover:bg-red-700"
|
||||
>
|
||||
<CheckIcon className="w-3 h-3" />
|
||||
确认归档
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="tap inline-flex items-center gap-1 px-2.5 py-1 rounded-md border border-gray-300 text-gray-600 text-[11px] hover:bg-white"
|
||||
>
|
||||
<CloseIcon className="w-3 h-3" />
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContactRow({
|
||||
contact,
|
||||
active,
|
||||
onOpen,
|
||||
onCompose,
|
||||
onRequestArchive
|
||||
}: {
|
||||
contact: Contact;
|
||||
active: boolean;
|
||||
onOpen: () => void;
|
||||
onCompose: () => void;
|
||||
onRequestArchive: () => void;
|
||||
}) {
|
||||
const time = new Date(contact.last_activity).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group px-3 py-2.5 rounded-lg border transition-colors ${
|
||||
active ? 'bg-blue-50 border-blue-200' : 'border-transparent hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<button onClick={onOpen} className="w-full text-left">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-semibold text-gray-900 truncate">
|
||||
{contact.agent_name}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400 font-mono truncate">{contact.path}</span>
|
||||
{contact.unread_count > 0 && (
|
||||
<span className="ml-auto shrink-0 min-w-[16px] h-4 px-1 rounded-full bg-blue-600 text-white text-[9px] font-bold flex items-center justify-center">
|
||||
{contact.unread_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<ChevronRightIcon className="w-3 h-3 text-blue-400 shrink-0" />
|
||||
<span className="text-[11px] text-blue-600 font-mono truncate">
|
||||
{contact.session_alias || '(未命名会话)'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">
|
||||
{contact.mail_count} 封 · {time}
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<div className="reveal flex gap-1 mt-1.5">
|
||||
<button
|
||||
onClick={onCompose}
|
||||
title="写信给该地址"
|
||||
className="tap inline-flex items-center gap-1 px-2 py-0.5 rounded border border-gray-300 text-[10px] text-gray-600 hover:bg-white"
|
||||
>
|
||||
<ComposeIcon className="w-3 h-3" />
|
||||
写信
|
||||
</button>
|
||||
<button
|
||||
onClick={onRequestArchive}
|
||||
title="归档该 name@path.session"
|
||||
className="tap inline-flex items-center gap-1 px-2 py-0.5 rounded border border-gray-300 text-[10px] text-gray-600 hover:bg-white hover:text-red-600 hover:border-red-300"
|
||||
>
|
||||
<ArchiveIcon className="w-3 h-3" />
|
||||
归档
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
311
client/electron/src/components/KeyPanel.tsx
Normal file
311
client/electron/src/components/KeyPanel.tsx
Normal file
@ -0,0 +1,311 @@
|
||||
import { useState } from 'react';
|
||||
import * as api from '../api/client';
|
||||
import { KeyIcon, CopyIcon, TrashIcon, PlusIcon, CheckIcon } from './icons';
|
||||
|
||||
/** 密钥类型的中文说明,创建表单与列表共用一份文案 */
|
||||
export const KEY_TYPE_LABEL: Record<api.KeyType, string> = {
|
||||
permanent: '长期',
|
||||
one_time: '一次性',
|
||||
timed: '限时'
|
||||
};
|
||||
|
||||
const KEY_TYPE_HINT: Record<api.KeyType, string> = {
|
||||
permanent: '永不过期,可重复使用',
|
||||
one_time: '首次使用后立即失效',
|
||||
timed: '指定小时数后过期'
|
||||
};
|
||||
|
||||
/** 一条密钥在列表里的状态:过期/已用完/可用 */
|
||||
function keyState(k: { key_type: api.KeyType; expires_at: string | null; used_at: string | null }) {
|
||||
if (k.key_type === 'one_time' && k.used_at) return { text: '已使用', cls: 'text-gray-400' };
|
||||
if (k.key_type === 'timed' && k.expires_at && new Date(k.expires_at) < new Date())
|
||||
return { text: '已过期', cls: 'text-red-500' };
|
||||
return { text: '可用', cls: 'text-green-600' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 新签发密钥的一次性展示条。
|
||||
*
|
||||
* 密钥全文只在创建响应里出现一次,服务端之后只返回前 8 位,
|
||||
* 所以这里必须明确提示「关掉就再也看不到」,而不是让用户以为随时能回来复制。
|
||||
*/
|
||||
function NewKeyBanner({ token, onDismiss }: { token: string; onDismiss: () => void }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(token);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
/* 无剪贴板权限时用户可手动选中 */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border border-amber-300 bg-amber-50 rounded-md p-3 space-y-2">
|
||||
<div className="text-xs font-medium text-amber-900">
|
||||
密钥已创建。全文仅显示这一次,关闭后无法再次查看。
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 text-[11px] font-mono bg-white border border-amber-200 rounded px-2 py-1.5 break-all">
|
||||
{token}
|
||||
</code>
|
||||
<button
|
||||
onClick={copy}
|
||||
className="tap shrink-0 flex items-center gap-1 text-xs px-2 py-1.5 border border-amber-300 rounded hover:bg-amber-100"
|
||||
>
|
||||
{copied ? <CheckIcon className="w-3.5 h-3.5" /> : <CopyIcon className="w-3.5 h-3.5" />}
|
||||
{copied ? '已复制' : '复制'}
|
||||
</button>
|
||||
<button onClick={onDismiss} className="tap shrink-0 text-xs text-amber-800 hover:underline">
|
||||
我已保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CreateFormProps {
|
||||
/** Agent 密钥面板会多出「绑定 Agent」与「登记已有密钥」两项 */
|
||||
variant: 'agent' | 'user';
|
||||
busy: boolean;
|
||||
onSubmit: (payload: api.CreateKeyPayload) => void;
|
||||
}
|
||||
|
||||
function CreateForm({ variant, busy, onSubmit }: CreateFormProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [keyType, setKeyType] = useState<api.KeyType>('permanent');
|
||||
const [label, setLabel] = useState('');
|
||||
const [hours, setHours] = useState(24);
|
||||
const [agentName, setAgentName] = useState('');
|
||||
const [keyToken, setKeyToken] = useState('');
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex items-center gap-1.5 text-xs px-3 py-1.5 border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
<PlusIcon className="w-3.5 h-3.5" />
|
||||
新建密钥
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
const payload: api.CreateKeyPayload = { key_type: keyType, label: label.trim() };
|
||||
if (keyType === 'timed') payload.expires_hours = hours;
|
||||
if (variant === 'agent') {
|
||||
if (agentName.trim()) payload.agent_name = agentName.trim();
|
||||
if (keyToken.trim()) payload.key_token = keyToken.trim();
|
||||
}
|
||||
onSubmit(payload);
|
||||
setOpen(false);
|
||||
setLabel('');
|
||||
setAgentName('');
|
||||
setKeyToken('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-md p-3 space-y-2.5 bg-gray-50">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
{(Object.keys(KEY_TYPE_LABEL) as api.KeyType[]).map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setKeyType(t)}
|
||||
className={`text-left px-2.5 py-2 rounded border text-xs ${
|
||||
keyType === t
|
||||
? 'border-blue-400 bg-white ring-2 ring-blue-100'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-gray-900">{KEY_TYPE_LABEL[t]}</div>
|
||||
<div className="text-[10px] text-gray-500 mt-0.5">{KEY_TYPE_HINT[t]}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={label}
|
||||
onChange={e => setLabel(e.target.value)}
|
||||
placeholder="备注(如 我的笔记本 / CI 机器)"
|
||||
className="flex-1 text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
/>
|
||||
{keyType === 'timed' && (
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={hours}
|
||||
onChange={e => setHours(Math.max(1, Number(e.target.value) || 1))}
|
||||
className="w-20 text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
/>
|
||||
<span className="text-[11px] text-gray-500">小时后过期</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{variant === 'agent' && (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
value={agentName}
|
||||
onChange={e => setAgentName(e.target.value)}
|
||||
placeholder="绑定到 Agent(留空 = 首次注册时自动落定)"
|
||||
className="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
/>
|
||||
<input
|
||||
value={keyToken}
|
||||
onChange={e => setKeyToken(e.target.value)}
|
||||
placeholder="登记插件本地生成的密钥(留空 = 由服务器生成)"
|
||||
className="w-full text-xs font-mono border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1" />
|
||||
<button onClick={() => setOpen(false)} className="text-xs text-gray-600 hover:text-gray-900">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={busy}
|
||||
className="tap text-xs px-3 py-1.5 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40"
|
||||
>
|
||||
创建
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 密钥面板。Agent 密钥(管理员)与用户连接密钥共用同一套渲染,
|
||||
* 差异用 variant 表达:只有 Agent 密钥能绑定 Agent 名、能登记客户端已生成的密钥。
|
||||
*/
|
||||
export default function KeyPanel({
|
||||
variant,
|
||||
keys,
|
||||
loading,
|
||||
error,
|
||||
newToken,
|
||||
onCreate,
|
||||
onDelete,
|
||||
onBind,
|
||||
onDismissToken
|
||||
}: {
|
||||
variant: 'agent' | 'user';
|
||||
keys: (api.AgentKey | api.UserKey)[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
newToken: string | null;
|
||||
onCreate: (payload: api.CreateKeyPayload) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onBind?: (id: string, agentName: string) => void;
|
||||
onDismissToken: () => void;
|
||||
}) {
|
||||
const [bindingID, setBindingID] = useState<string | null>(null);
|
||||
const [bindName, setBindName] = useState('');
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyIcon className="w-4 h-4 text-gray-500" />
|
||||
<h3 className="text-sm font-semibold text-gray-900">
|
||||
{variant === 'agent' ? 'Agent 接入密钥' : '客户端连接密钥'}
|
||||
</h3>
|
||||
<div className="flex-1" />
|
||||
<CreateForm variant={variant} busy={loading} onSubmit={onCreate} />
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-gray-500">
|
||||
{variant === 'agent'
|
||||
? 'Agent 用该密钥注册、收发邮件与订阅通知。插件首次安装会在本地生成一把密钥并打印出来,把它填到「登记」框即可。'
|
||||
: '第三方客户端用该密钥访问自己的邮箱(Authorization: Bearer)。它不能用于注册 Agent。'}
|
||||
</p>
|
||||
|
||||
{error && <div className="text-xs text-red-600">{error}</div>}
|
||||
{newToken && <NewKeyBanner token={newToken} onDismiss={onDismissToken} />}
|
||||
|
||||
{keys.length === 0 ? (
|
||||
<div className="text-xs text-gray-400 py-3">暂无密钥</div>
|
||||
) : (
|
||||
<div className="border border-gray-200 rounded-md divide-y divide-gray-100">
|
||||
{keys.map(k => {
|
||||
const st = keyState(k);
|
||||
const agentKey = variant === 'agent' ? (k as api.AgentKey) : null;
|
||||
return (
|
||||
<div key={k.key_id} className="px-3 py-2.5 flex items-center gap-x-3 gap-y-1 flex-wrap">
|
||||
<code className="text-[11px] font-mono text-gray-700 w-24 shrink-0">
|
||||
{k.token_hint}
|
||||
</code>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs text-gray-900 truncate">
|
||||
{k.label || <span className="text-gray-400">(无备注)</span>}
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-500 mt-0.5">
|
||||
{KEY_TYPE_LABEL[k.key_type]}
|
||||
{k.expires_at && ` · ${new Date(k.expires_at).toLocaleString()} 过期`}
|
||||
{agentKey &&
|
||||
(agentKey.agent_name ? ` · ${agentKey.agent_name}` : ' · 待绑定')}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`text-[10px] shrink-0 ${st.cls}`}>{st.text}</span>
|
||||
|
||||
{agentKey && onBind && bindingID === k.key_id ? (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<input
|
||||
value={bindName}
|
||||
onChange={e => setBindName(e.target.value)}
|
||||
placeholder="Agent 名"
|
||||
className="w-28 text-[11px] border border-gray-300 rounded px-1.5 py-1"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (bindName.trim()) onBind(k.key_id, bindName.trim());
|
||||
setBindingID(null);
|
||||
setBindName('');
|
||||
}}
|
||||
className="text-[11px] text-blue-600 hover:underline"
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBindingID(null)}
|
||||
className="text-[11px] text-gray-500 hover:underline"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
agentKey &&
|
||||
onBind && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setBindingID(k.key_id);
|
||||
setBindName(agentKey.agent_name ?? '');
|
||||
}}
|
||||
className="text-[11px] text-gray-500 hover:text-gray-900 shrink-0"
|
||||
>
|
||||
绑定
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => onDelete(k.key_id)}
|
||||
title="吊销"
|
||||
className="shrink-0 text-gray-400 hover:text-red-600"
|
||||
>
|
||||
<TrashIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
client/electron/src/components/LoginPage.tsx
Normal file
114
client/electron/src/components/LoginPage.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { MailboxIcon, SpinnerIcon } from './icons';
|
||||
|
||||
export default function LoginPage() {
|
||||
const login = useAuthStore(s => s.login);
|
||||
const error = useAuthStore(s => s.error);
|
||||
const retryAfter = useAuthStore(s => s.retryAfter);
|
||||
const submitting = useAuthStore(s => s.submitting);
|
||||
const clearError = useAuthStore(s => s.clearError);
|
||||
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const userRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
userRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// 被限速时倒计时
|
||||
useEffect(() => {
|
||||
if (!retryAfter) return;
|
||||
setCountdown(retryAfter);
|
||||
const t = setInterval(() => {
|
||||
setCountdown(c => {
|
||||
if (c <= 1) {
|
||||
clearInterval(t);
|
||||
clearError();
|
||||
return 0;
|
||||
}
|
||||
return c - 1;
|
||||
});
|
||||
}, 1000);
|
||||
return () => clearInterval(t);
|
||||
}, [retryAfter]);
|
||||
|
||||
const locked = countdown > 0;
|
||||
const canSubmit = username.trim() !== '' && password !== '' && !submitting && !locked;
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
const ok = await login(username, password);
|
||||
if (!ok) setPassword('');
|
||||
};
|
||||
|
||||
// 卡片高约 371px,比横屏手机(或软键盘弹出后)的可视高度还高。
|
||||
//
|
||||
// 居中用卡片自己的 `my-auto` 而**不是**容器的 `items-center`:后者在内容超高时
|
||||
// 会让卡片上下同时溢出,而溢出到顶部那段滚不到(scrollTop 最小是 0)——
|
||||
// 实测 568x280 下「登录」按钮完全在视口外,光加 overflow-y-auto 也够不着。
|
||||
// auto margin 在空间不足时自动退化为 0,于是矮屏变成正常的顶对齐可滚布局。
|
||||
return (
|
||||
<div className="h-full overflow-y-auto flex justify-center bg-slate-100">
|
||||
<div className="w-[380px] max-w-[92vw] shrink-0 my-auto bg-white rounded-xl shadow-sm border border-gray-200 p-6 sm:p-8">
|
||||
<div className="flex flex-col items-center mb-6">
|
||||
<div className="w-12 h-12 rounded-xl bg-blue-50 text-blue-600 flex items-center justify-center">
|
||||
<MailboxIcon className="w-6 h-6" />
|
||||
</div>
|
||||
<h1 className="mt-3 text-base font-semibold text-gray-900">AgentMail</h1>
|
||||
<p className="mt-1 text-xs text-gray-500">邮件驱动的多智能体协作平台</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1">用户名</label>
|
||||
<input
|
||||
ref={userRef}
|
||||
value={username}
|
||||
onChange={e => {
|
||||
setUsername(e.target.value);
|
||||
if (error) clearError();
|
||||
}}
|
||||
autoComplete="username"
|
||||
spellCheck={false}
|
||||
className="w-full text-sm border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1">密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => {
|
||||
setPassword(e.target.value);
|
||||
if (error) clearError();
|
||||
}}
|
||||
autoComplete="current-password"
|
||||
className="w-full text-sm border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-600 bg-red-50 border border-red-100 rounded-md px-2.5 py-1.5">
|
||||
{error}
|
||||
{locked && `(${countdown} 秒后可重试)`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="w-full inline-flex items-center justify-center gap-2 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{submitting && <SpinnerIcon className="w-3.5 h-3.5" />}
|
||||
{submitting ? '登录中' : locked ? `已锁定 ${countdown}s` : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
327
client/electron/src/components/MailList.tsx
Normal file
327
client/electron/src/components/MailList.tsx
Normal file
@ -0,0 +1,327 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMailStore } from '../stores/mailStore';
|
||||
import { useSessionStore } from '../stores/sessionStore';
|
||||
import { useUIStore } from '../stores/uiStore';
|
||||
import type { Mail } from '../types';
|
||||
import { groupMailsBySession, isFlatGroup, splitByPermission, type MailGroup } from '../lib/mailGroups';
|
||||
import { ShieldIcon, PaperclipIcon, ChevronRightIcon } from './icons';
|
||||
import { participantAddress } from '../lib/replyTarget';
|
||||
|
||||
export default function MailList() {
|
||||
const viewMode = useUIStore(s => s.viewMode);
|
||||
const cancelCompose = useUIStore(s => s.cancelCompose);
|
||||
const showDetail = useUIStore(s => s.showDetail);
|
||||
|
||||
const inbox = useMailStore(s => s.inbox);
|
||||
const sent = useMailStore(s => s.sent);
|
||||
const currentMail = useMailStore(s => s.currentMail);
|
||||
const selectMail = useMailStore(s => s.selectMail);
|
||||
const fetchInbox = useMailStore(s => s.fetchInbox);
|
||||
const fetchSent = useMailStore(s => s.fetchSent);
|
||||
const clearSession = useSessionStore(s => s.clearSession);
|
||||
|
||||
// 哪些会话组被展开。默认全部折叠 —— 收件箱的问题正是「一次任务的几十封信
|
||||
// 淹掉其他任务」,默认展开等于没分组
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (viewMode === 'sent') fetchSent();
|
||||
else if (viewMode === 'inbox') fetchInbox('all');
|
||||
}, [viewMode]);
|
||||
|
||||
// 切换收件箱/发件箱时收起所有组:两个箱子的会话集合不同,
|
||||
// 留着上一个箱子的展开状态会让人以为某个组「自己展开了」
|
||||
useEffect(() => {
|
||||
setExpanded(new Set());
|
||||
}, [viewMode]);
|
||||
|
||||
const isSent = viewMode === 'sent';
|
||||
// 权限请求已经有自己的导航项(授权),收件箱只放要读的内容。
|
||||
// 不过滤的后果实测过:一个会话的 17 封权限邮件把另外两个会话的信挤出视野。
|
||||
// 发件箱不筛:人发不出权限请求(那是 Agent 发的),筛也筛不掉什么。
|
||||
const source = isSent ? sent : splitByPermission(inbox).normal;
|
||||
const list = source;
|
||||
const groups = groupMailsBySession(list);
|
||||
|
||||
const toggle = (sessionId: string) => {
|
||||
setExpanded(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(sessionId)) next.delete(sessionId);
|
||||
else next.add(sessionId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const pick = (m: Mail) => {
|
||||
clearSession();
|
||||
cancelCompose();
|
||||
selectMail(m);
|
||||
// 窄屏下列表与详情共用一栏,选中后要切过去;
|
||||
// 宽屏下这个状态不影响渲染(两栏并排),但仍然维护 ——
|
||||
// 否则从窄屏拖宽再拖回来,用户会发现自己回到了列表,刚打开的邮件不见了
|
||||
showDetail();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full lg:w-[320px] shrink-0 border-r border-gray-200 bg-white flex flex-col min-w-0">
|
||||
<div className="px-4 py-3 border-b border-gray-200 flex items-center gap-1">
|
||||
<h2 className="text-sm font-semibold text-gray-800">{isSent ? '发件箱' : '收件箱'}</h2>
|
||||
{/* 显示「会话数 · 邮件数」而不是只显示邮件数:分组之后前者才是
|
||||
「有几件事」,后者只是流量 */}
|
||||
<span className="ml-2 text-xs text-gray-500">
|
||||
{groups.length > 0 && groups.length !== list.length
|
||||
? `${groups.length} 组 · ${list.length} 封`
|
||||
: list.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-0.5">
|
||||
{groups.map(g =>
|
||||
isFlatGroup(g) ? (
|
||||
<MailItem
|
||||
key={g.latest.mail_id}
|
||||
mail={g.latest}
|
||||
active={currentMail?.mail_id === g.latest.mail_id}
|
||||
showTo={isSent}
|
||||
onClick={() => pick(g.latest)}
|
||||
/>
|
||||
) : (
|
||||
<SessionGroup
|
||||
key={g.sessionId}
|
||||
group={g}
|
||||
showTo={isSent}
|
||||
open={expanded.has(g.sessionId)}
|
||||
onToggle={() => toggle(g.sessionId)}
|
||||
currentMailID={currentMail?.mail_id}
|
||||
onPick={pick}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{list.length === 0 && (
|
||||
<p className="text-xs text-gray-500 text-center py-6">暂无邮件</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个会话折叠成的一组。
|
||||
*
|
||||
* 组头答的是「哪件事、进行到哪」;展开后才是逐封邮件。
|
||||
* 权限请求不在这里 —— 它们在单独的「授权」导航项里。
|
||||
*/
|
||||
function SessionGroup({
|
||||
group,
|
||||
showTo,
|
||||
open,
|
||||
onToggle,
|
||||
currentMailID,
|
||||
onPick
|
||||
}: {
|
||||
group: MailGroup;
|
||||
showTo: boolean;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
currentMailID?: string;
|
||||
onPick: (m: Mail) => void;
|
||||
}) {
|
||||
const g = group;
|
||||
const time = new Date(g.latest.created_at).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
// 列表行只显示「跟谁在通信」,**不带会话位**:分组头下面已经单独显示了
|
||||
// 会话别名,再拼一遍会让长别名(实测 92 字节)把这一行挤没。
|
||||
//
|
||||
// 人还是 Agent 走显式布尔;workspace 从会话取(from_workspace 对 Agent
|
||||
// 存的是 Agent 名而非路径)。
|
||||
const isPeerHuman = showTo ? g.latest.to_human : g.latest.from_human;
|
||||
const peer = participantAddress(
|
||||
showTo ? g.latest.to_name : g.latest.from_name,
|
||||
isPeerHuman,
|
||||
isPeerHuman ? '' : g.latest.session_workspace || ''
|
||||
);
|
||||
|
||||
// 组内含选中邮件时给个边框,否则展开一个组再滚下去会找不到自己在看哪封
|
||||
const hasActive = currentMailID ? g.mails.some(m => m.mail_id === currentMailID) : false;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border ${
|
||||
hasActive ? 'border-blue-200 bg-blue-50/40' : 'border-transparent'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full text-left px-3 py-2.5 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ChevronRightIcon
|
||||
className={`w-3 h-3 text-gray-400 shrink-0 transition-transform ${
|
||||
open ? 'rotate-90' : ''
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-xs truncate flex-1 font-mono ${
|
||||
g.unreadCount > 0 ? 'font-semibold text-gray-900' : 'text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{showTo ? '→ ' : ''}
|
||||
{peer}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400 shrink-0">{time}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 mt-0.5 pl-5">
|
||||
{g.unreadCount > 0 && (
|
||||
<span className="shrink-0 min-w-[16px] h-4 px-1 rounded-full bg-blue-600 text-white text-[9px] font-bold flex items-center justify-center">
|
||||
{g.unreadCount}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`text-xs truncate ${
|
||||
g.unreadCount > 0 ? 'font-medium text-gray-900' : 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{g.subject}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-0.5 pl-5">
|
||||
<span className="text-[10px] text-blue-500 font-mono truncate">
|
||||
{g.alias ? `.${g.alias}` : '(未命名会话)'}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400 shrink-0">{g.mails.length} 封</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="pl-5 pr-1 pb-1.5 space-y-0.5">
|
||||
{g.mails.map(m => (
|
||||
<MailItem
|
||||
key={m.mail_id}
|
||||
mail={m}
|
||||
active={currentMailID === m.mail_id}
|
||||
showTo={showTo}
|
||||
onClick={() => onPick(m)}
|
||||
compact
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MailItem({
|
||||
mail,
|
||||
active,
|
||||
showTo,
|
||||
onClick,
|
||||
compact = false
|
||||
}: {
|
||||
mail: Mail;
|
||||
active: boolean;
|
||||
showTo: boolean;
|
||||
onClick: () => void;
|
||||
/** 组内条目:对端信息已在组头显示,这里省掉以免每行都重复同一个地址 */
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const isPermission = mail.mail_type === 'permission_request';
|
||||
const isUnread = mail.status === 'unread';
|
||||
const ccCount = mail.cc_list?.length ?? 0;
|
||||
const attachCount = mail.attachments?.length ?? 0;
|
||||
const time = new Date(mail.created_at).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
const isRowHuman = showTo ? mail.to_human : mail.from_human;
|
||||
const peer = participantAddress(
|
||||
showTo ? mail.to_name : mail.from_name,
|
||||
isRowHuman,
|
||||
isRowHuman ? '' : mail.session_workspace || ''
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full text-left px-3 py-2.5 rounded-lg border transition-colors ${
|
||||
active ? 'bg-blue-50 border-blue-200' : 'border-transparent hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`text-xs truncate flex-1 ${
|
||||
compact ? '' : 'font-mono'
|
||||
} ${isUnread ? 'font-semibold text-gray-900' : 'text-gray-600'}`}
|
||||
>
|
||||
{compact ? (
|
||||
<>
|
||||
{isUnread && (
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-500 mr-1.5 align-middle" />
|
||||
)}
|
||||
{mail.subject}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{showTo ? '→ ' : ''}
|
||||
{peer}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400 shrink-0">{time}</span>
|
||||
</div>
|
||||
|
||||
{!compact && (
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
{isUnread && <span className="w-1.5 h-1.5 rounded-full bg-blue-500 shrink-0" />}
|
||||
{isPermission && (
|
||||
<span className="shrink-0 inline-flex items-center gap-0.5 px-1 py-0.5 rounded bg-orange-100 text-orange-700 text-[9px] font-medium">
|
||||
<ShieldIcon className="w-2.5 h-2.5" />
|
||||
权限
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`text-xs truncate ${
|
||||
isUnread ? 'font-medium text-gray-900' : 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{mail.subject}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{/* 组内条目不重复显示别名(组头已有)。
|
||||
发件箱里仍可能出现权限邮件(理论上人发不出,但不假设数据一定干净) */}
|
||||
{compact && isPermission && (
|
||||
<span
|
||||
className={`inline-flex items-center gap-0.5 text-[10px] ${
|
||||
mail.permission_result ? 'text-gray-400' : 'text-orange-600 font-medium'
|
||||
}`}
|
||||
>
|
||||
<ShieldIcon className="w-2.5 h-2.5" />
|
||||
{mail.permission_result || '待决策'}
|
||||
</span>
|
||||
)}
|
||||
{!compact && mail.session_alias && (
|
||||
<span className="min-w-0 flex-1 truncate text-[10px] text-blue-600 font-mono">.{mail.session_alias}</span>
|
||||
)}
|
||||
{ccCount > 0 && <span className="text-[10px] text-gray-400">抄送 {ccCount}</span>}
|
||||
{attachCount > 0 && (
|
||||
<span className="inline-flex items-center gap-0.5 text-[10px] text-gray-400">
|
||||
<PaperclipIcon className="w-2.5 h-2.5" />
|
||||
{attachCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
864
client/electron/src/components/MailView.tsx
Normal file
864
client/electron/src/components/MailView.tsx
Normal file
@ -0,0 +1,864 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { useMailStore } from '../stores/mailStore';
|
||||
import { useSessionStore } from '../stores/sessionStore';
|
||||
import { useContactStore } from '../stores/contactStore';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import {
|
||||
sessionReplyTarget,
|
||||
mailReplyTarget,
|
||||
mailCounterpart,
|
||||
replyAllCC,
|
||||
participantAddress
|
||||
} from '../lib/replyTarget';
|
||||
import * as api from '../api/client';
|
||||
import type { Mail } from '../types';
|
||||
import { MailIcon, ShieldIcon, PersonIcon, BotIcon, CheckIcon, CloseIcon, ForwardIcon, TreeIcon, TagIcon, GaugeIcon } from './icons';
|
||||
import AddressInput from './AddressInput';
|
||||
import { AttachmentList, AttachmentPicker, type PendingAttachment } from './Attachments';
|
||||
import ThreadView from './ThreadView';
|
||||
import PermissionChip, { permissionModeHint } from './PermissionChip';
|
||||
import BackButton from './BackButton';
|
||||
|
||||
export default function MailView() {
|
||||
const currentMail = useMailStore(s => s.currentMail);
|
||||
const markRead = useMailStore(s => s.markRead);
|
||||
const currentSession = useSessionStore(s => s.currentSession);
|
||||
const currentSessionMails = useSessionStore(s => s.currentSessionMails);
|
||||
// 当前登录用户名:判定「哪封是我发的」的唯一基准。
|
||||
// 曾经写死成 'human'(单用户时代的遗留),多用户下登录名可能是 jianf,
|
||||
// 判据恒为假 —— 于是回复自己发的信时对端取成了自己。
|
||||
const me = useAuthStore(s => s.user?.username || '');
|
||||
// 转发面板作用于哪封邮件;null = 未打开
|
||||
const [forwarding, setForwarding] = useState<Mail | null>(null);
|
||||
// 正在看哪封邮件的对话树;null = 看正常的邮件视图
|
||||
const [threadOf, setThreadOf] = useState<string | null>(null);
|
||||
|
||||
// 切换邮件时关掉树视图:树是针对某封邮件的,留着会显示上一封的线索
|
||||
const currentMailID = currentMail?.mail_id;
|
||||
useEffect(() => {
|
||||
setThreadOf(null);
|
||||
}, [currentMailID]);
|
||||
|
||||
if (currentSession && currentSessionMails.length > 0) {
|
||||
const last = currentSessionMails[currentSessionMails.length - 1];
|
||||
// 会话视图的对端是**会话的属性**,不能由「最后一封是谁发的」决定:
|
||||
// 人在这里打字就是「给这次任务的对方追加一句」,而最后一封很可能是自己刚发的,
|
||||
// 那时取对端会取成自己 —— 信就发给了自己(生产已发生)。
|
||||
const sessionTarget = sessionReplyTarget(currentSessionMails, currentSession, me);
|
||||
return (
|
||||
<div className="flex-1 min-w-0 flex flex-col bg-gray-50">
|
||||
<div className="px-4 md:px-6 py-3 border-b border-gray-200 bg-white">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<BackButton label="会话" />
|
||||
<span className="text-sm font-semibold text-gray-900 font-mono">
|
||||
{currentSession.session_alias
|
||||
? `.${currentSession.session_alias}`
|
||||
: '(未命名会话)'}
|
||||
</span>
|
||||
<StatusBadge status={currentSession.status} />
|
||||
<span className="text-xs text-gray-400">
|
||||
{currentSessionMails.length} 封
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<PermissionEditor />
|
||||
<BudgetEditor />
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{currentSession.subject}</p>
|
||||
</div>
|
||||
<RenameProposalBar />
|
||||
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-4 space-y-3">
|
||||
{currentSessionMails.map(m => (
|
||||
<ThreadCard key={m.mail_id} mail={m} onForward={() => setForwarding(m)} />
|
||||
))}
|
||||
</div>
|
||||
{/* 会话视图原先只有回复,转发入口只存在于单封邮件视图 ——
|
||||
而人多数时间待在会话视图里,等于转发功能在 UI 上找不到 */}
|
||||
{forwarding ? (
|
||||
<ForwardBar mail={forwarding} onClose={() => setForwarding(null)} />
|
||||
) : (
|
||||
<ReplyBar replyTo={last} overrideTarget={sessionTarget} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!currentMail) {
|
||||
return (
|
||||
<div className="flex-1 min-w-0 flex items-center justify-center bg-gray-50 text-gray-400">
|
||||
<div className="text-center">
|
||||
<MailIcon className="w-10 h-10 mx-auto text-gray-300" />
|
||||
<p className="text-sm mt-3 text-gray-500">选择一封邮件查看,或点击左侧「新建」写邮件</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (threadOf) {
|
||||
return <ThreadView mailID={threadOf} onClose={() => setThreadOf(null)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-w-0 flex flex-col bg-white">
|
||||
<Header
|
||||
mail={currentMail}
|
||||
onRead={() => markRead(currentMail.mail_id)}
|
||||
onForward={() => setForwarding(currentMail)}
|
||||
onThread={() => setThreadOf(currentMail.mail_id)}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-4">
|
||||
<div className="markdown text-sm">
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{currentMail.body}</Markdown>
|
||||
</div>
|
||||
<AttachmentList items={currentMail.attachments ?? []} />
|
||||
{currentMail.mail_type === 'permission_request' && (
|
||||
<PermissionPanel mail={currentMail} />
|
||||
)}
|
||||
</div>
|
||||
{forwarding ? (
|
||||
<ForwardBar mail={forwarding} onClose={() => setForwarding(null)} />
|
||||
) : (
|
||||
<ReplyBar replyTo={currentMail} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限档位编辑器(会话头部)。
|
||||
*
|
||||
* 人在派活时声明「这件事允许 Agent 动手到什么程度」。
|
||||
* 只有新会话时在 ComposePage 里设;对话页里随时可改(规划档位)。
|
||||
* 三档:plan(只读)/ workspace(目录内,越界问人)/ full(全权)。
|
||||
*/
|
||||
function PermissionEditor() {
|
||||
const session = useSessionStore(s => s.currentSession);
|
||||
const setPermissionMode = useSessionStore(s => s.setPermissionMode);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
if (!session) return null;
|
||||
|
||||
const mode = session.permission_mode || 'workspace';
|
||||
const enforcement = session.permission_enforcement || 'advisory';
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
title={permissionModeHint(mode, enforcement)}
|
||||
className="inline-flex items-center"
|
||||
>
|
||||
<PermissionChip mode={mode} enforcement={enforcement} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const modes = [
|
||||
{ value: 'plan', label: '只读', desc: '不许写/改/执行' },
|
||||
{ value: 'workspace', label: '目录内', desc: '越界问人' },
|
||||
{ value: 'full', label: '全权', desc: '自动放行' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{modes.map(o => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
await setPermissionMode(o.value);
|
||||
setBusy(false);
|
||||
setEditing(false);
|
||||
}}
|
||||
title={o.desc}
|
||||
className={`px-1.5 py-0.5 rounded text-[10px] font-medium border transition-colors disabled:opacity-40 ${
|
||||
mode === o.value
|
||||
? 'border-blue-500 bg-blue-50 text-blue-700'
|
||||
: 'border-gray-200 text-gray-500 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
<button onClick={() => setEditing(false)} className="text-[10px] text-gray-400 hover:text-gray-700 ml-0.5">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 本任务的往返预算编辑器(会话头部)。
|
||||
*
|
||||
* 配额最该被编辑的地方就是这里:人看着往来内容才知道这件事还值不值得再来几个回合。
|
||||
* 放在管理员页面调某个 Agent 的全局配额是另一回事 —— 那管的是「这个 Agent 总共能发多少」,
|
||||
* 而不是「这件事值得多少个来回」。
|
||||
*/
|
||||
function BudgetEditor() {
|
||||
const budget = useSessionStore(s => s.budget);
|
||||
const setBudget = useSessionStore(s => s.setBudget);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
if (!budget) return null;
|
||||
|
||||
const exhausted = !budget.unlimited && budget.remaining === 0;
|
||||
|
||||
const open = () => {
|
||||
setDraft(budget.unlimited ? '' : String(budget.max_rounds));
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const commit = async (patch: { max_rounds?: number; reset?: boolean }) => {
|
||||
setBusy(true);
|
||||
await setBudget(patch);
|
||||
setBusy(false);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<button
|
||||
onClick={open}
|
||||
title="本任务的往返预算:Agent 主动发信的次数上限(自动转发的总结与权限询问不占用)"
|
||||
className={`inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded border transition-colors ${
|
||||
exhausted
|
||||
? 'border-red-200 bg-red-50 text-red-700'
|
||||
: 'border-gray-200 text-gray-500 hover:border-blue-300 hover:text-blue-600'
|
||||
}`}
|
||||
>
|
||||
<GaugeIcon className="w-3 h-3" />
|
||||
{budget.unlimited
|
||||
? '预算不限'
|
||||
: `${budget.used_rounds}/${budget.max_rounds} 来回${exhausted ? ' · 已用尽' : ''}`}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const invalid = draft.trim() !== '' && !/^\d+$/.test(draft.trim());
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] text-gray-500">往返预算</span>
|
||||
<input
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
inputMode="numeric"
|
||||
placeholder="不限"
|
||||
autoFocus
|
||||
className={`w-16 text-xs border rounded px-1.5 py-1 focus:outline-none focus:ring-2 focus:ring-blue-100 ${
|
||||
invalid ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
disabled={busy || invalid}
|
||||
onClick={() => commit({ max_rounds: draft.trim() === '' ? 0 : Number(draft.trim()) })}
|
||||
className="text-[11px] px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
disabled={busy || budget.used_rounds === 0}
|
||||
onClick={() => commit({ reset: true })}
|
||||
title="已用次数归零,上限不变"
|
||||
className="text-[11px] text-gray-500 hover:text-gray-900 disabled:opacity-30"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditing(false)}
|
||||
className="text-[11px] text-gray-400 hover:text-gray-700"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent 提议改会话别名的提示条。
|
||||
*
|
||||
* 为什么要人点头而不是让 Agent 直接改:别名是**人**的寻址入口(name@path.别名)。
|
||||
* Agent 干到一半自己改掉,人上一秒记住的地址下一秒就失效。
|
||||
* 提议 + 人确认,既让 Agent 表达意图,又保证寻址稳定性由人掌握。
|
||||
*/
|
||||
function RenameProposalBar() {
|
||||
const proposal = useSessionStore(s => s.renameProposal);
|
||||
const current = useSessionStore(s => s.currentSession);
|
||||
const accept = useSessionStore(s => s.acceptRename);
|
||||
const dismiss = useSessionStore(s => s.dismissRename);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
if (!proposal) return null;
|
||||
|
||||
const from = current?.session_alias ? `.${current.session_alias}` : '(未命名)';
|
||||
|
||||
return (
|
||||
<div className="px-4 md:px-6 py-2.5 bg-blue-50 border-b border-blue-100">
|
||||
<div className="flex items-start gap-2">
|
||||
<TagIcon className="w-3.5 h-3.5 text-blue-500 mt-0.5 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-blue-900">
|
||||
Agent 建议把会话别名从 <span className="font-mono">{from}</span> 改为{' '}
|
||||
<span className="font-mono font-semibold">.{proposal.alias}</span>
|
||||
</p>
|
||||
{proposal.reason && (
|
||||
<p className="text-[11px] text-blue-700 mt-0.5">{proposal.reason}</p>
|
||||
)}
|
||||
<p className="text-[10px] text-blue-500 mt-0.5">
|
||||
改名后需用 name@path.{proposal.alias} 寻址;旧别名立即失效。
|
||||
接受后此别名不再被 Agent 平台的自动命名覆盖
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
await accept();
|
||||
setBusy(false);
|
||||
}}
|
||||
className="px-2.5 py-1 rounded bg-blue-600 text-white text-xs hover:bg-blue-600 disabled:opacity-50 shrink-0"
|
||||
>
|
||||
{busy ? '改名中' : '接受'}
|
||||
</button>
|
||||
<button
|
||||
onClick={dismiss}
|
||||
className="px-2.5 py-1 rounded border border-blue-200 text-blue-700 text-xs hover:bg-blue-100 shrink-0"
|
||||
>
|
||||
忽略
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转发面板。与回复并列,二者互斥显示 —— 同时开两个输入框会让人不知道自己在写哪个。
|
||||
* 收件人用与写信页一致的三段式补全,正文引用由服务端生成(保证格式统一)。
|
||||
*/
|
||||
function ForwardBar({ mail, onClose }: { mail: Mail; onClose: () => void }) {
|
||||
const [to, setTo] = useState('');
|
||||
const [cc, setCc] = useState('');
|
||||
const [ccOpen, setCcOpen] = useState(false);
|
||||
const [comment, setComment] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fetchInbox = useMailStore(s => s.fetchInbox);
|
||||
const fetchSent = useMailStore(s => s.fetchSent);
|
||||
const fetchSessions = useSessionStore(s => s.fetchSessions);
|
||||
const fetchContacts = useContactStore(s => s.fetchContacts);
|
||||
|
||||
const submit = async () => {
|
||||
if (!to.trim() || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.forwardMail(mail.mail_id, {
|
||||
to: to.trim(),
|
||||
cc: cc.trim(),
|
||||
comment: comment.trim()
|
||||
});
|
||||
await Promise.all([fetchInbox('all'), fetchSent(), fetchSessions(), fetchContacts()]);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="shrink-0 max-h-[min(65vh,calc(var(--app-height)-3rem))] overflow-y-auto overscroll-contain border-t border-gray-200 bg-white px-4 md:px-6 py-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ForwardIcon className="w-3.5 h-3.5 text-gray-500" />
|
||||
<span className="text-[11px] font-medium text-gray-600">
|
||||
转发「{mail.subject}」
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={() => setCcOpen(o => !o)}
|
||||
className={`tap text-[10px] ${ccOpen ? 'text-blue-600' : 'text-gray-500 hover:text-blue-600'}`}
|
||||
>
|
||||
{ccOpen ? '收起抄送' : '抄送'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AddressInput value={to} onChange={setTo} placeholder="新收件人:pi@root.new" />
|
||||
|
||||
{ccOpen && (
|
||||
<AddressInput
|
||||
value={cc}
|
||||
onChange={setCc}
|
||||
allowMultiple
|
||||
placeholder="抄送:逗号分隔,可多个"
|
||||
/>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
value={comment}
|
||||
onChange={e => setComment(e.target.value)}
|
||||
placeholder="转发说明(可选,置于引用原文之前;原文将以引用块附在下方)"
|
||||
className="w-full h-16 text-sm border border-gray-300 rounded-md p-2.5 resize-none focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{error && <span className="text-xs text-red-600 min-w-0 break-words">{error}</span>}
|
||||
<div className="flex-1" />
|
||||
<button onClick={onClose} className="tap px-3 py-1.5 text-xs text-gray-500 hover:text-gray-800">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={busy || !to.trim()}
|
||||
className="tap px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{busy ? '转发中' : '转发'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header({
|
||||
mail,
|
||||
onRead,
|
||||
onForward,
|
||||
onThread
|
||||
}: {
|
||||
mail: Mail;
|
||||
onRead: () => void;
|
||||
onForward: () => void;
|
||||
onThread: () => void;
|
||||
}) {
|
||||
const time = new Date(mail.created_at).toLocaleString('zh-CN');
|
||||
|
||||
// 发件/收件行显示**各方在这条会话里的完整地址**,人与 Agent 带的段数不同:
|
||||
//
|
||||
// Agent → `pi@/home/program/agentmail.<会话别名>` 三段才唯一确定
|
||||
// 人 → `jianf` 人没有目录也不需要会话位
|
||||
//
|
||||
// 此前这里有两处错:别名被拼给了**发件人**(`jianf.<别名>` —— 既指错归属,
|
||||
// 又因为人没有工作目录而拼出 path 位为空的非法地址),以及收件人**没有**
|
||||
// 别名(`pi@/home/program/agentmail` 指向默认会话,不是人指定的那条)。
|
||||
//
|
||||
// workspace 取 `session_workspace` 而不是 from/to_workspace:后者对 Agent
|
||||
// 存的是 Agent 名而非路径(历史遗留),拿它拼会得到 `dsh@dsh`。
|
||||
const ws = mail.session_workspace || '';
|
||||
const from = participantAddress(
|
||||
mail.from_name,
|
||||
mail.from_human,
|
||||
mail.from_human ? '' : ws,
|
||||
mail.session_alias
|
||||
);
|
||||
const to = participantAddress(
|
||||
mail.to_name,
|
||||
mail.to_human,
|
||||
mail.to_human ? '' : ws,
|
||||
mail.session_alias
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="px-4 md:px-6 py-3 md:py-4 border-b border-gray-200">
|
||||
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
|
||||
<BackButton />
|
||||
<h2 className="text-sm font-semibold text-gray-900 min-w-0 break-words">{mail.subject}</h2>
|
||||
{mail.status === 'unread' && (
|
||||
<span className="px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 text-[10px] font-medium">
|
||||
未读
|
||||
</span>
|
||||
)}
|
||||
{mail.mail_type === 'permission_request' && (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 text-[10px] font-medium">
|
||||
<ShieldIcon className="w-3 h-3" />
|
||||
权限请求
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{mail.status === 'unread' && (
|
||||
<button onClick={onRead} className="tap text-xs text-blue-500 hover:underline">
|
||||
标记已读
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onThread}
|
||||
className="tap inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-900"
|
||||
title="沿回复与转发关系展开整条线索"
|
||||
>
|
||||
<TreeIcon className="w-3.5 h-3.5" />
|
||||
对话树
|
||||
</button>
|
||||
<button
|
||||
onClick={onForward}
|
||||
className="tap inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-900"
|
||||
>
|
||||
<ForwardIcon className="w-3.5 h-3.5" />
|
||||
转发
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<dl className="text-xs text-gray-500 space-y-0.5">
|
||||
<Row label="发件">{from}</Row>
|
||||
<Row label="收件">{to}</Row>
|
||||
{mail.cc_list?.length > 0 && (
|
||||
<Row label="抄送">
|
||||
{mail.cc_list.map(a => a.raw || `${a.name}@${a.path || ''}`).join('、')}
|
||||
</Row>
|
||||
)}
|
||||
<Row label="时间">{time}</Row>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<dt className="w-8 shrink-0 text-gray-400">{label}</dt>
|
||||
{/* min-w-0 + break-all:会话别名可达 128 字节,不给收缩权会把整行撑出容器 */}
|
||||
<dd className="min-w-0 font-mono text-gray-600 break-all">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThreadCard({ mail, onForward }: { mail: Mail; onForward?: () => void }) {
|
||||
// 「这封是我发的吗」而不是「发件人叫 human 吗」:多用户下登录名可能是
|
||||
// jianf,写死 'human' 会让自己发的信显示成机器人图标。
|
||||
const me = useAuthStore(s => s.user?.username || '');
|
||||
const isMine = !!me && mail.from_name === me;
|
||||
const isPermission = mail.mail_type === 'permission_request';
|
||||
const time = new Date(mail.created_at).toLocaleString('zh-CN');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border p-4 ${
|
||||
isPermission
|
||||
? 'border-orange-200 bg-orange-50'
|
||||
: isMine
|
||||
? 'border-blue-200 bg-blue-50/60'
|
||||
: 'border-gray-200 bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 mb-2 text-xs">
|
||||
{isMine ? (
|
||||
<PersonIcon className="w-3.5 h-3.5 text-blue-600" />
|
||||
) : (
|
||||
<BotIcon className="w-3.5 h-3.5 text-slate-600" />
|
||||
)}
|
||||
{/* 显示真实发件人名而不是 'human':会话里可能有多个人类参与方,
|
||||
都渲染成 human 就分不清谁说的话 */}
|
||||
<span className="font-semibold text-gray-800 font-mono">{mail.from_name}</span>
|
||||
{isPermission && (
|
||||
<span className="px-1 py-0.5 rounded bg-orange-200 text-orange-800 text-[9px] font-medium">
|
||||
权限请求
|
||||
</span>
|
||||
)}
|
||||
{mail.cc_list?.length > 0 && (
|
||||
<span
|
||||
className="text-[10px] text-gray-400"
|
||||
title={mail.cc_list.map(c => c.raw || `${c.name}@${c.path || ''}`).join(', ')}
|
||||
>
|
||||
抄送 {mail.cc_list.length}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<span className="text-[10px] text-gray-400">{time}</span>
|
||||
{onForward && (
|
||||
<button
|
||||
onClick={onForward}
|
||||
title="转发这封"
|
||||
className="text-gray-400 hover:text-blue-600"
|
||||
>
|
||||
<ForwardIcon className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="markdown text-sm">
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{mail.body}</Markdown>
|
||||
</div>
|
||||
<AttachmentList items={mail.attachments ?? []} />
|
||||
{isPermission && <PermissionPanel mail={mail} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限请求的决策面板。
|
||||
*
|
||||
* 导出供测试单独渲染:通过整个 MailView 渲染它需要先把 mailStore 与
|
||||
* sessionStore 摆到「当前正看着一封 permission_request 邮件」的状态,
|
||||
* 那些铺垫与这个组件本身的行为无关。
|
||||
*/
|
||||
export function PermissionPanel({ mail }: { mail: Mail }) {
|
||||
const [note, setNote] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [decided, setDecided] = useState(mail.permission_result || '');
|
||||
const fetchInbox = useMailStore(s => s.fetchInbox);
|
||||
const selectSession = useSessionStore(s => s.selectSession);
|
||||
|
||||
const options = mail.permission_options?.length ? mail.permission_options : ['同意', '拒绝'];
|
||||
const isApprove = (s: string) => /同意|允许|批准|approve|yes/i.test(s);
|
||||
|
||||
const decide = async (choice: string) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.decidePermission(mail.mail_id, choice, note || undefined);
|
||||
setDecided(choice);
|
||||
await fetchInbox('all');
|
||||
if (mail.session_id) selectSession(mail.session_id);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (decided) {
|
||||
return (
|
||||
<div className="mt-3 pt-2.5 border-t border-orange-200 text-xs text-gray-600">
|
||||
已处理:<strong className="text-gray-800">{decided}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t border-orange-200">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{options.map(opt => (
|
||||
<button
|
||||
key={opt}
|
||||
onClick={() => decide(opt)}
|
||||
disabled={busy}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md transition-colors disabled:opacity-40 ${
|
||||
isApprove(opt)
|
||||
? 'bg-green-700 text-white hover:bg-green-800'
|
||||
: 'bg-red-50 text-red-700 border border-red-200 hover:bg-red-100'
|
||||
}`}
|
||||
>
|
||||
{isApprove(opt) ? (
|
||||
<CheckIcon className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<CloseIcon className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
value={note}
|
||||
onChange={e => setNote(e.target.value)}
|
||||
placeholder="备注(可选)"
|
||||
className="mt-2 w-full text-xs border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReplyBar({
|
||||
replyTo,
|
||||
/**
|
||||
* 会话视图传进来的目标地址,覆盖「按锚点邮件推断」。
|
||||
*
|
||||
* 会话视图的语义是「跟这个 Agent 的一次任务」,对端是会话的属性;
|
||||
* 单封邮件视图没有这层语境,才回落到按那封邮件推断。
|
||||
*/
|
||||
overrideTarget
|
||||
}: {
|
||||
replyTo?: Mail;
|
||||
overrideTarget?: string;
|
||||
}) {
|
||||
const [body, setBody] = useState('');
|
||||
const [cc, setCc] = useState('');
|
||||
// 抄送默认收起:多数回复不需要它,常驻一行输入框只会挤掉正文空间。
|
||||
// 原邮件带抄送时自动展开并预填 —— 「回复全部」是人在这种场景下的默认预期
|
||||
const [ccOpen, setCcOpen] = useState(false);
|
||||
const [budgetEditing, setBudgetEditing] = useState(false);
|
||||
const [budgetBusy, setBudgetBusy] = useState(false);
|
||||
const [maxRoundsDraft, setMaxRoundsDraft] = useState('');
|
||||
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fetchInbox = useMailStore(s => s.fetchInbox);
|
||||
const fetchSent = useMailStore(s => s.fetchSent);
|
||||
const fetchSessions = useSessionStore(s => s.fetchSessions);
|
||||
const fetchContacts = useContactStore(s => s.fetchContacts);
|
||||
const selectSession = useSessionStore(s => s.selectSession);
|
||||
const budget = useSessionStore(s => s.budget);
|
||||
const setBudget = useSessionStore(s => s.setBudget);
|
||||
const me = useAuthStore(s => s.user?.username || '');
|
||||
|
||||
if (!replyTo) return null;
|
||||
|
||||
// 判据是「当前登录用户名」而不是字面量 'human':后者是单用户时代的遗留,
|
||||
// 多用户下登录名可能是 jianf,判据恒为假 → 对端取成自己 → 信发给自己。
|
||||
const peer = mailCounterpart(replyTo, me);
|
||||
const target = overrideTarget || mailReplyTarget(replyTo, me);
|
||||
|
||||
const send = async () => {
|
||||
if (!body.trim()) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.sendMail(target, `Re: ${replyTo.subject}`, body, {
|
||||
reply_to: replyTo.mail_id,
|
||||
cc: cc.trim(),
|
||||
attachment_ids: attachments.map(a => a.id)
|
||||
});
|
||||
setBody('');
|
||||
setCc('');
|
||||
setCcOpen(false);
|
||||
setAttachments([]);
|
||||
await Promise.all([fetchInbox('all'), fetchSent(), fetchSessions(), fetchContacts()]);
|
||||
if (replyTo.session_id) selectSession(replyTo.session_id);
|
||||
} catch (err) {
|
||||
// 必须显示出来:预算耗尽、地址不存在、速率限制都会走到这里,
|
||||
// 原先只 console.error,用户点了发送什么反应都没有
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 「回复全部」:把原邮件的其他参与方填进抄送。
|
||||
*
|
||||
* cc_list 是结构化的 Address(后端解析过三维寻址)。传当前会话别名进去,
|
||||
* 让 `.new` 被换成真实别名 —— 原样回填 `.new` 会让这封回复给抄送方
|
||||
* **另开一条新会话**,于是同一件事裂成两条线索。
|
||||
*/
|
||||
const replyAll = () => {
|
||||
// 去重与「去掉自己」都在 replyAllCC 里:原先用 !a.startsWith('human')
|
||||
// 去自己,同一个遗留判据 —— 去不掉 jianf,点「回复全部」会把自己抄送进去。
|
||||
setCc(replyAllCC(replyTo, me, peer.name).join(', '));
|
||||
setCcOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="shrink-0 max-h-[min(65vh,calc(var(--app-height)-3rem))] overflow-y-auto overscroll-contain border-t border-gray-200 bg-white px-4 md:px-6 py-3">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<p className="text-[10px] text-gray-400 font-mono min-w-0 truncate">回复 {target}</p>
|
||||
<div className="flex-1" />
|
||||
{(replyTo.cc_list?.length ?? 0) > 0 && (
|
||||
<button
|
||||
onClick={replyAll}
|
||||
className="tap text-[10px] text-gray-500 hover:text-blue-600"
|
||||
>
|
||||
回复全部
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setCcOpen(o => !o)}
|
||||
className={`tap text-[10px] ${ccOpen ? 'text-blue-600' : 'text-gray-500 hover:text-blue-600'}`}
|
||||
>
|
||||
{ccOpen ? '收起抄送' : '抄送'}
|
||||
</button>
|
||||
</div>
|
||||
{ccOpen && (
|
||||
<div className="mb-2">
|
||||
<AddressInput
|
||||
value={cc}
|
||||
onChange={setCc}
|
||||
allowMultiple
|
||||
placeholder="抄送:逗号分隔,可多个(如 pi@root, ops@root)"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={e => setBody(e.target.value)}
|
||||
placeholder="回复内容(Markdown)"
|
||||
className="w-full h-20 text-sm font-mono border border-gray-300 rounded-md p-3 resize-none focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<AttachmentPicker items={attachments} onChange={setAttachments} disabled={busy} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
||||
{error && <span className="text-xs text-red-600 min-w-0 break-words">{error}</span>}
|
||||
<div className="flex-1" />
|
||||
{budget && !budgetEditing && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setMaxRoundsDraft(budget.unlimited ? '' : String(budget.max_rounds));
|
||||
setBudgetEditing(true);
|
||||
}}
|
||||
title="本任务往返预算:Agent 主动发信上限(自动转发与权限询问不占)"
|
||||
className={`tap inline-flex items-center gap-1 text-[10px] px-2 py-1 rounded border ${
|
||||
budget.unlimited
|
||||
? 'border-gray-200 text-gray-500 hover:border-blue-300'
|
||||
: budget.remaining === 0
|
||||
? 'border-red-200 text-red-600'
|
||||
: 'border-gray-200 text-gray-500 hover:border-blue-300'
|
||||
}`}
|
||||
>
|
||||
<GaugeIcon className="w-3 h-3" />
|
||||
{budget.unlimited
|
||||
? '预算不限'
|
||||
: `${budget.used_rounds}/${budget.max_rounds} 来回${budget.remaining === 0 ? ' · 已用尽' : ''}`}
|
||||
</button>
|
||||
)}
|
||||
{budget && budgetEditing && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="text-[10px] text-gray-500">预算</span>
|
||||
<input
|
||||
value={maxRoundsDraft}
|
||||
onChange={e => setMaxRoundsDraft(e.target.value)}
|
||||
inputMode="numeric"
|
||||
placeholder="不限"
|
||||
autoFocus
|
||||
className="w-14 text-xs border border-gray-300 rounded px-1.5 py-0.5 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
||||
/>
|
||||
<button
|
||||
disabled={budgetBusy || (maxRoundsDraft.trim() !== '' && !/^\d+$/.test(maxRoundsDraft.trim()))}
|
||||
onClick={async () => {
|
||||
setBudgetBusy(true);
|
||||
await setBudget({ max_rounds: maxRoundsDraft.trim() === '' ? 0 : Number(maxRoundsDraft.trim()) });
|
||||
setBudgetBusy(false);
|
||||
setBudgetEditing(false);
|
||||
}}
|
||||
className="tap px-2 py-0.5 text-[10px] rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBudgetEditing(false)}
|
||||
className="tap text-[10px] text-gray-400 hover:text-gray-700"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setBody('');
|
||||
setError(null);
|
||||
}}
|
||||
className="tap px-3 py-1.5 text-xs text-gray-500 hover:text-gray-800"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
<button
|
||||
onClick={send}
|
||||
disabled={busy || !body.trim()}
|
||||
className="tap px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{busy ? '发送中' : '发送'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const map: Record<string, { label: string; cls: string }> = {
|
||||
active: { label: '进行中', cls: 'bg-yellow-100 text-yellow-700' },
|
||||
waiting: { label: '等待中', cls: 'bg-blue-100 text-blue-700' },
|
||||
completed: { label: '已完成', cls: 'bg-green-100 text-green-700' },
|
||||
archived: { label: '已归档', cls: 'bg-gray-200 text-gray-600' }
|
||||
};
|
||||
const b = map[status] || map.active;
|
||||
return <span className={`text-[10px] px-1.5 py-0.5 rounded-full ${b.cls}`}>{b.label}</span>;
|
||||
}
|
||||
321
client/electron/src/components/ModelScopePanel.tsx
Normal file
321
client/electron/src/components/ModelScopePanel.tsx
Normal file
@ -0,0 +1,321 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import * as api from '../api/client';
|
||||
import type { AgentStats, CatalogModel, ModelRoute } from '../api/client';
|
||||
import { CheckIcon, SpinnerIcon, ChevronRightIcon, CpuIcon } from './icons';
|
||||
|
||||
/**
|
||||
* 每个 Agent 平台在**邮件场景**下可用的模型范围。
|
||||
*
|
||||
* 为什么是勾选而不是手打模型名:模型清单是平台侧的事实(opencode 的 provider
|
||||
* 配置、DSH 的 llm 适配器注册),手打就会打错,而打错的后果要到真发邮件时
|
||||
* 才暴露成一次失败。插件随心跳上报它当前看得见的目录,这里只做勾选。
|
||||
*
|
||||
* 顺序即优先级:插件按这个顺序逐个尝试,全部失败才回一封说明失败原因的邮件。
|
||||
* 一个都不选 = 不限定,回退到平台自己的默认模型 —— 与「一个都不许用」不同。
|
||||
*/
|
||||
export default function ModelScopePanel({ agents }: { agents: AgentStats[] }) {
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
|
||||
if (agents.length === 0) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<PanelHeader count={0} />
|
||||
<div className="text-xs text-gray-400 py-3">暂无已注册的 Agent</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<PanelHeader count={agents.length} />
|
||||
<div className="border border-gray-200 rounded-md divide-y divide-gray-100">
|
||||
{agents.map(a => (
|
||||
<AgentModelRow
|
||||
key={a.agent_name}
|
||||
agentName={a.agent_name}
|
||||
expanded={expanded === a.agent_name}
|
||||
onToggle={() => setExpanded(expanded === a.agent_name ? null : a.agent_name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelHeader({ count }: { count: number }) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<CpuIcon className="w-4 h-4 text-gray-500" />
|
||||
<h3 className="text-sm font-semibold text-gray-900">Agent 模型范围</h3>
|
||||
{count > 0 && <span className="text-xs text-gray-400">{count}</span>}
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
划定每个平台在邮件场景下可用的模型。勾选顺序即尝试顺序 —— 插件按序降级,
|
||||
全部失败才回一封说明失败原因的邮件。
|
||||
<br />
|
||||
一个都不选 = 不限定,用平台自己的默认模型。清单由插件随心跳上报。
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentModelRow({
|
||||
agentName,
|
||||
expanded,
|
||||
onToggle
|
||||
}: {
|
||||
agentName: string;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const [catalog, setCatalog] = useState<CatalogModel[]>([]);
|
||||
const [stale, setStale] = useState<ModelRoute[]>([]);
|
||||
// picks 是有序的:数组下标就是 rank
|
||||
const [picks, setPicks] = useState<string[]>([]);
|
||||
const [saved, setSaved] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setErr('');
|
||||
try {
|
||||
const res = await api.adminGetAgentModels(agentName);
|
||||
setCatalog(res.catalog);
|
||||
setStale(res.stale || []);
|
||||
// 已选项按 rank 排出初始顺序
|
||||
const chosen = res.catalog
|
||||
.filter(m => m.allowed)
|
||||
.sort((a, b) => (a.rank ?? 0) - (b.rank ?? 0))
|
||||
.map(keyOf);
|
||||
// 已选但已不在目录里的仍要保留:不显示会让人以为没选过,
|
||||
// 而保存时若把它们丢掉就等于静默改了配置
|
||||
const staleKeys = (res.stale || []).map(keyOf);
|
||||
const all = [...chosen, ...staleKeys.filter(k => !chosen.includes(k))];
|
||||
setPicks(all);
|
||||
setSaved(all);
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [agentName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (expanded) load();
|
||||
}, [expanded, load]);
|
||||
|
||||
const toggle = (key: string) => {
|
||||
setPicks(prev => (prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]));
|
||||
};
|
||||
|
||||
const move = (key: string, delta: number) => {
|
||||
setPicks(prev => {
|
||||
const i = prev.indexOf(key);
|
||||
const j = i + delta;
|
||||
if (i < 0 || j < 0 || j >= prev.length) return prev;
|
||||
const next = [...prev];
|
||||
[next[i], next[j]] = [next[j], next[i]];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
setErr('');
|
||||
try {
|
||||
const res = await api.adminSetAgentModels(agentName, picks.map(parseKey));
|
||||
// 用服务端返回的结果而不是本地 picks:repo 层会跳过重复与空字段,
|
||||
// 直接信本地状态会让界面显示保存成功而实际存下来的不同
|
||||
const persisted = res.models.map(keyOf);
|
||||
setPicks(persisted);
|
||||
setSaved(persisted);
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const dirty = picks.join('|') !== saved.join('|');
|
||||
const staleKeys = stale.map(keyOf);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full px-3 py-2.5 flex items-center gap-x-3 gap-y-1 flex-wrap text-left hover:bg-gray-50"
|
||||
>
|
||||
<ChevronRightIcon
|
||||
className={`w-3 h-3 text-gray-400 shrink-0 transition-transform ${expanded ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
<span className="text-xs font-mono text-gray-900 w-32 shrink-0 truncate">{agentName}</span>
|
||||
<span className="min-w-0 flex-1 text-[11px] text-gray-500">
|
||||
{saved.length === 0 ? '不限定(用平台默认模型)' : `${saved.length} 个模型,按序尝试`}
|
||||
</span>
|
||||
{staleKeys.length > 0 && (
|
||||
<span
|
||||
className="shrink-0 px-1 py-0.5 rounded bg-amber-100 text-amber-700 text-[9px]"
|
||||
title="已选但平台当前没有上报这些模型"
|
||||
>
|
||||
{staleKeys.length} 个已失效
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="px-3 pb-3 space-y-3 bg-gray-50 border-t border-gray-100">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400 pt-3">
|
||||
<SpinnerIcon className="w-3.5 h-3.5 animate-spin" />
|
||||
加载中
|
||||
</div>
|
||||
)}
|
||||
{err && <p className="text-xs text-red-600 pt-3">{err}</p>}
|
||||
|
||||
{!loading && catalog.length === 0 && staleKeys.length === 0 && (
|
||||
<p className="text-[11px] text-gray-500 pt-3">
|
||||
该平台还没有上报模型清单。插件会在心跳时上报(约 30 秒一次)——
|
||||
若长时间为空,检查插件是否在运行、以及它能否读到平台的 provider 配置。
|
||||
</p>
|
||||
)}
|
||||
|
||||
{picks.length > 0 && (
|
||||
<div className="pt-3">
|
||||
<p className="text-[10px] font-medium text-gray-500 mb-1.5">
|
||||
尝试顺序(自上而下)
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{picks.map((key, i) => {
|
||||
const meta = catalog.find(m => keyOf(m) === key);
|
||||
const isStale = !meta && staleKeys.includes(key);
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`flex items-center gap-1.5 px-2 py-1 rounded border bg-white ${
|
||||
isStale ? 'border-amber-200' : 'border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<span className="w-4 text-[10px] text-gray-400 shrink-0">{i + 1}</span>
|
||||
<span className="text-xs font-mono text-gray-800 truncate">{key}</span>
|
||||
{isStale && (
|
||||
<span
|
||||
className="shrink-0 text-[9px] text-amber-700"
|
||||
title="平台当前没有上报这个模型,可能已下线"
|
||||
>
|
||||
已失效
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={() => move(key, -1)}
|
||||
disabled={i === 0}
|
||||
title="上移"
|
||||
className="tap shrink-0 text-gray-400 hover:text-gray-900 disabled:opacity-30 text-xs px-1"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => move(key, 1)}
|
||||
disabled={i === picks.length - 1}
|
||||
title="下移"
|
||||
className="tap shrink-0 text-gray-400 hover:text-gray-900 disabled:opacity-30 text-xs px-1"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggle(key)}
|
||||
title="移除"
|
||||
className="tap shrink-0 text-gray-400 hover:text-red-600 text-xs px-1"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{catalog.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] font-medium text-gray-500 mb-1.5">
|
||||
平台上报的模型({catalog.length})
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{catalog.map(m => {
|
||||
const key = keyOf(m);
|
||||
const on = picks.includes(key);
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => toggle(key)}
|
||||
title={m.display_name || key}
|
||||
className={`tap px-2 py-1 text-[11px] font-mono rounded border transition-colors ${
|
||||
on
|
||||
? 'bg-blue-50 border-blue-300 text-blue-700'
|
||||
: 'bg-white border-gray-200 text-gray-600 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{key}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(catalog.length > 0 || picks.length > 0) && (
|
||||
<div className="flex items-center gap-2">
|
||||
{picks.length === 0 && (
|
||||
<span className="text-[10px] text-gray-400">
|
||||
未选 = 不限定,用平台默认模型
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{dirty && (
|
||||
<button
|
||||
onClick={() => setPicks(saved)}
|
||||
className="tap text-[11px] text-gray-500 hover:text-gray-900"
|
||||
>
|
||||
撤销
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={!dirty || saving}
|
||||
className="tap inline-flex items-center gap-1 px-3 py-1 text-[11px] rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40"
|
||||
>
|
||||
{saving ? (
|
||||
<SpinnerIcon className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<CheckIcon className="w-3 h-3" />
|
||||
)}
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** provider/model 拼成一个稳定的键,用于勾选状态与顺序。 */
|
||||
function keyOf(m: { provider: string; model: string }): string {
|
||||
return `${m.provider}/${m.model}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 键拆回 provider 与 model。
|
||||
*
|
||||
* 按**第一个** `/` 切:model id 里可能含 `/`(如 `org/model-name`),
|
||||
* 而 provider id 不含。按最后一个切会把 provider 切错。
|
||||
*/
|
||||
function parseKey(key: string): ModelRoute {
|
||||
const i = key.indexOf('/');
|
||||
if (i < 0) return { provider: key, model: '' };
|
||||
return { provider: key.slice(0, i), model: key.slice(i + 1) };
|
||||
}
|
||||
135
client/electron/src/components/NarrowNav.tsx
Normal file
135
client/electron/src/components/NarrowNav.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
import { useUIStore, type ViewMode } from '../stores/uiStore';
|
||||
import { useMailStore } from '../stores/mailStore';
|
||||
import { useContactStore } from '../stores/contactStore';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import {
|
||||
InboxIcon,
|
||||
SentIcon,
|
||||
ContactsIcon,
|
||||
ComposeIcon,
|
||||
UsersIcon,
|
||||
PersonIcon,
|
||||
ShieldIcon,
|
||||
CalendarIcon
|
||||
} from './icons';
|
||||
import { ConnectionIndicator } from './ConnectionIndicator';
|
||||
import { countPendingPermissions, splitByPermission } from '../lib/mailGroups';
|
||||
|
||||
/**
|
||||
* 窄屏底部导航。
|
||||
*
|
||||
* 移动端把主导航放底部而不是顶部:拇指够得到。
|
||||
* 宽屏用的是左侧竖条(Sidebar),两者共用 uiStore 的 viewMode,
|
||||
* 所以从窄拖到宽不会丢失当前位置。
|
||||
*
|
||||
* 这里只放最常用的几项 + 一个「更多」入口(打开抽屉式 Sidebar)——
|
||||
* 底部塞满图标会挤成一排看不懂的小方块。
|
||||
*/
|
||||
const items: {
|
||||
short: string;
|
||||
mode: ViewMode;
|
||||
Icon: (p: { className?: string }) => JSX.Element;
|
||||
adminOnly?: boolean;
|
||||
}[] = [
|
||||
{ short: '收件', mode: 'inbox', Icon: InboxIcon },
|
||||
{ short: '授权', mode: 'permissions', Icon: ShieldIcon },
|
||||
{ short: '发件', mode: 'sent', Icon: SentIcon },
|
||||
{ short: '日历', mode: 'calendar', Icon: CalendarIcon },
|
||||
{ short: '联系人', mode: 'contacts', Icon: ContactsIcon },
|
||||
{ short: '管理', mode: 'admin', Icon: UsersIcon, adminOnly: true }
|
||||
];
|
||||
|
||||
export default function NarrowNav() {
|
||||
const viewMode = useUIStore(s => s.viewMode);
|
||||
const setViewMode = useUIStore(s => s.setViewMode);
|
||||
const composing = useUIStore(s => s.composing);
|
||||
const startCompose = useUIStore(s => s.startCompose);
|
||||
const narrowPane = useUIStore(s => s.narrowPane);
|
||||
|
||||
const inbox = useMailStore(s => s.inbox);
|
||||
// 与 Sidebar 同一判据:权限请求归「授权」,不算进收件箱未读
|
||||
const { normal, permissions } = splitByPermission(inbox);
|
||||
const unread = normal.filter(m => m.status === 'unread').length;
|
||||
const pendingPerms = countPendingPermissions(permissions);
|
||||
const contacts = useContactStore(s => s.contacts);
|
||||
|
||||
const user = useAuthStore(s => s.user);
|
||||
const isAdmin = user?.role === 'admin';
|
||||
|
||||
const visible = items.filter(n => !n.adminOnly || isAdmin);
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="narrow-nav shrink-0 border-t border-chrome-700 bg-chrome-900 flex items-stretch"
|
||||
// 底部安全区:iPhone 的手势条会盖住最后一排
|
||||
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
|
||||
>
|
||||
{visible.map(({ short, mode, Icon }) => {
|
||||
// 详情栏打开时不高亮任何导航项:此刻用户看的是某封邮件,
|
||||
// 高亮「收件」会让人以为点它能回到列表(其实是同一项)
|
||||
const active = viewMode === mode && !composing && narrowPane === 'list';
|
||||
const badge =
|
||||
mode === 'inbox'
|
||||
? unread
|
||||
: mode === 'permissions'
|
||||
? pendingPerms
|
||||
: mode === 'contacts'
|
||||
? contacts.length
|
||||
: 0;
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => setViewMode(mode)}
|
||||
className={`relative flex-1 py-2 flex flex-col items-center justify-center gap-0.5 transition-colors ${
|
||||
active ? 'text-white' : 'text-chrome-400 active:bg-chrome-800'
|
||||
}`}
|
||||
>
|
||||
<Icon />
|
||||
<span className="text-[10px] leading-none">{short}</span>
|
||||
{badge > 0 && (
|
||||
<span
|
||||
className={`absolute top-1 right-[22%] min-w-[15px] h-[15px] px-1 rounded-full text-[9px] font-bold flex items-center justify-center ${
|
||||
mode === 'inbox'
|
||||
? 'bg-red-600 text-white'
|
||||
: mode === 'permissions'
|
||||
? 'bg-orange-700 text-white'
|
||||
: 'bg-chrome-600 text-chrome-100'
|
||||
}`}
|
||||
>
|
||||
{badge > 99 ? '99+' : badge}
|
||||
</span>
|
||||
)}
|
||||
{active && <span className="absolute top-0 left-1/4 right-1/4 h-0.5 bg-blue-400" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<button
|
||||
onClick={() => startCompose()}
|
||||
className={`flex-1 py-2 flex flex-col items-center justify-center gap-0.5 ${
|
||||
composing ? 'text-blue-300' : 'text-blue-400 active:bg-chrome-800'
|
||||
}`}
|
||||
>
|
||||
<ComposeIcon />
|
||||
<span className="text-[10px] leading-none">新建</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setViewMode('account')}
|
||||
className={`flex-1 py-2 flex flex-col items-center justify-center gap-0.5 ${
|
||||
viewMode === 'account' && !composing
|
||||
? 'text-white'
|
||||
: 'text-chrome-400 active:bg-chrome-800'
|
||||
}`}
|
||||
>
|
||||
<div className="relative">
|
||||
<PersonIcon />
|
||||
<span className="absolute -top-0.5 -right-1.5">
|
||||
<ConnectionIndicator />
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] leading-none">我的</span>
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
11
client/electron/src/components/NarrowOnly.tsx
Normal file
11
client/electron/src/components/NarrowOnly.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { useIsNarrow } from '../hooks/useIsNarrow';
|
||||
|
||||
/**
|
||||
* 只在窄屏渲染子元素。
|
||||
*
|
||||
* 不用 Tailwind 的 `md:hidden`:那只是视觉隐藏,元素仍在 DOM 与 tab 序列里,
|
||||
* 宽屏用户按 Tab 会聚焦到一个看不见的返回按钮上。
|
||||
*/
|
||||
export default function NarrowOnly({ children }: { children: React.ReactNode }) {
|
||||
return useIsNarrow() ? <>{children}</> : null;
|
||||
}
|
||||
86
client/electron/src/components/NarrowStack.tsx
Normal file
86
client/electron/src/components/NarrowStack.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* 窄屏下的「页面覆盖」容器。
|
||||
*
|
||||
* 与分栏的区别:底层页面(列表)始终挂载,详情页从右侧滑入**盖在它上面**。
|
||||
* 这样做的两个实际好处:
|
||||
* - 列表的滚动位置与选中态天然保留 —— 它没被卸载
|
||||
* - 退出动画有东西可播:如果直接卸载再渲染另一个组件,没有任何一帧
|
||||
* 能让旧页面往右滑出去
|
||||
*
|
||||
* 因此这里必须区分「逻辑上是否打开」(open)与「是否还在 DOM 里」(mounted):
|
||||
* 关闭时先播 200ms 滑出动画,动画结束才卸载。
|
||||
*/
|
||||
export default function NarrowStack({
|
||||
base,
|
||||
overlay,
|
||||
open
|
||||
}: {
|
||||
base: React.ReactNode;
|
||||
overlay: React.ReactNode;
|
||||
open: boolean;
|
||||
}) {
|
||||
// mounted:是否在 DOM 里。entered:是否已滑到位(用于触发 transition)
|
||||
const [mounted, setMounted] = useState(open);
|
||||
const [entered, setEntered] = useState(open);
|
||||
const timer = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (timer.current !== null) {
|
||||
clearTimeout(timer.current);
|
||||
timer.current = null;
|
||||
}
|
||||
|
||||
if (open) {
|
||||
setMounted(true);
|
||||
// 必须等浏览器至少绘制一帧「在右侧之外」的状态,否则从挂载到
|
||||
// translate-x-0 是同一帧内完成的,transition 不会触发。
|
||||
// 两层 rAF 是跨浏览器最稳的写法(单层在 Safari 上偶尔仍被合帧)。
|
||||
const raf = requestAnimationFrame(() => requestAnimationFrame(() => setEntered(true)));
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}
|
||||
|
||||
setEntered(false);
|
||||
// 与下面的 duration-200 保持一致;提前卸载会把动画切掉半截
|
||||
timer.current = window.setTimeout(() => {
|
||||
setMounted(false);
|
||||
timer.current = null;
|
||||
}, 200);
|
||||
return () => {
|
||||
if (timer.current !== null) {
|
||||
clearTimeout(timer.current);
|
||||
timer.current = null;
|
||||
}
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 relative overflow-hidden">
|
||||
{/* 底层:始终挂载。打开覆盖层时用 aria-hidden 把它从无障碍树里摘掉,
|
||||
否则屏幕阅读器会读到两层内容。
|
||||
|
||||
`isolate`(isolation: isolate)是必需的:它让底层**自成一个层叠上下文**。
|
||||
不加的后果在日历上实测到过:月视图的星期表头是 `sticky top-0 z-10`,
|
||||
而覆盖层没有 z-index(= auto = 0)—— 两者在同一个层叠上下文里比,
|
||||
`z-10` 赢过 `auto`,于是底层的表头穿透到二级页面之上,把日程内容遮住一条。
|
||||
|
||||
为何不只给覆盖层加 z-10 就完事:那只能治当下这一处。底层是任意业务组件,
|
||||
下一个人在里面写个 `z-20` 就又复现,而这类 bug 只能肉眼看见。
|
||||
isolate 把边界定在容器上,底层写多少 z-index 都出不来。 */}
|
||||
<div className="absolute inset-0 flex isolate" aria-hidden={open ? 'true' : undefined}>
|
||||
{base}
|
||||
</div>
|
||||
|
||||
{mounted && (
|
||||
<div
|
||||
className={`absolute inset-0 z-10 flex bg-white border-l border-gray-200 shadow-2xl transition-transform duration-200 ease-out motion-reduce:transition-none ${
|
||||
entered ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}
|
||||
>
|
||||
{overlay}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
client/electron/src/components/PermissionChip.tsx
Normal file
80
client/electron/src/components/PermissionChip.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 权限档位徽标 —— 卡片/列表上显示本任务的档位与实际强制力。
|
||||
*
|
||||
* 两个字段必须成对显示:
|
||||
* permission_mode 档位(plan / workspace / full)——「要求什么」
|
||||
* permission_enforcement 强制力(native / advisory)——「平台实际做到了什么」
|
||||
*
|
||||
* 为什么强制力也要上界面:只显示档位会让人以为 plan 档管住了 homeagent,
|
||||
* 而 homeagent 没有工具拦截点、档位只是提示词建议(advisory)。
|
||||
* 差异可见才符合 I-5(失败必须当场可见)。
|
||||
*/
|
||||
|
||||
export interface PermissionChipProps {
|
||||
/** 档位:plan / workspace / full */
|
||||
mode?: string;
|
||||
/** 实际强制力:native / advisory */
|
||||
enforcement?: string;
|
||||
/** 紧凑模式(卡片上用);默认常规(详情页用) */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const MODE_LABEL: Record<string, string> = {
|
||||
plan: '只读',
|
||||
workspace: '目录内',
|
||||
full: '全权',
|
||||
};
|
||||
|
||||
/** 档位 → 文字说明(tooltip 用) */
|
||||
export function permissionModeHint(mode?: string, enforcement?: string): string {
|
||||
const enforced = enforcement === 'native';
|
||||
switch (mode) {
|
||||
case 'plan':
|
||||
return enforced
|
||||
? 'plan 档:只读。写/改/执行会被平台强制拦下,本档只用来查与想。'
|
||||
: 'plan 档:只读(advisory,平台不强制)。请把结论写在回信里。';
|
||||
case 'full':
|
||||
return 'full 档:全权。工具调用不需额外授权。';
|
||||
case 'workspace':
|
||||
default:
|
||||
return enforced
|
||||
? 'workspace 档:目录内可动,越界需经授权。'
|
||||
: 'workspace 档(advisory,平台不强制)。请把改动限制在工作目录内。';
|
||||
}
|
||||
}
|
||||
|
||||
export default function PermissionChip({ mode, enforcement, compact }: PermissionChipProps) {
|
||||
// 空档位(人→人的信、旧会话)不显示徽标
|
||||
const normalized = mode || '';
|
||||
if (!['plan', 'workspace', 'full'].includes(normalized)) return null;
|
||||
|
||||
const enforced = enforcement === 'native';
|
||||
|
||||
// 配色按档位:plan 用蓝(只读),workspace 用黄(有边界的动),full 用红/橙(全权)
|
||||
const color = normalized === 'plan'
|
||||
? 'bg-blue-50 text-blue-700 border-blue-200'
|
||||
: normalized === 'full'
|
||||
? 'bg-amber-50 text-amber-700 border-amber-200'
|
||||
: 'bg-green-50 text-green-700 border-green-200';
|
||||
|
||||
const hint = permissionModeHint(normalized, enforcement);
|
||||
const label = MODE_LABEL[normalized] ?? normalized;
|
||||
|
||||
return (
|
||||
<span
|
||||
title={hint + (enforcement ? `(强制力:${enforcement === 'native' ? '平台强制' : '仅提示' })` : '')}
|
||||
className={`inline-flex items-center gap-1 rounded border font-medium ${color} ${
|
||||
compact ? 'px-1 text-[10px]' : 'px-1.5 text-xs'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
{enforced ? (
|
||||
// native:平台强制 —— 实心圆点
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-current" />
|
||||
) : (
|
||||
// advisory:仅提示 —— 空心圆点
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full border border-current" />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
260
client/electron/src/components/PermissionList.tsx
Normal file
260
client/electron/src/components/PermissionList.tsx
Normal file
@ -0,0 +1,260 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMailStore } from '../stores/mailStore';
|
||||
import { useSessionStore } from '../stores/sessionStore';
|
||||
import { useUIStore } from '../stores/uiStore';
|
||||
import type { Mail } from '../types';
|
||||
import { groupPermissions, type PermissionGroup } from '../lib/mailGroups';
|
||||
import { ShieldIcon, ChevronRightIcon, CheckIcon, CloseIcon, BotIcon } from './icons';
|
||||
|
||||
/**
|
||||
* 授权列表:一级是会话,二级是该会话的授权请求。
|
||||
*
|
||||
* 独立于收件箱存在,因为权限请求不是「一封信」而是「一件待办」——
|
||||
* 它的生命周期是「等人点头 → 决策完就作废」,混进收件箱两者互相伤害:
|
||||
* 一次 Agent 任务能产生十几个权限请求(每个被拦下的 bash/write 都是一封),
|
||||
* 把真正需要阅读的来信压到看不见的地方;反过来,人要找「有什么在等我批」
|
||||
* 也得在几十封信里翻。生产实测一个会话独占 17 封权限邮件。
|
||||
*/
|
||||
export default function PermissionList() {
|
||||
const inbox = useMailStore(s => s.inbox);
|
||||
const currentMail = useMailStore(s => s.currentMail);
|
||||
const selectMail = useMailStore(s => s.selectMail);
|
||||
const fetchInbox = useMailStore(s => s.fetchInbox);
|
||||
const clearSession = useSessionStore(s => s.clearSession);
|
||||
const cancelCompose = useUIStore(s => s.cancelCompose);
|
||||
const showDetail = useUIStore(s => s.showDetail);
|
||||
|
||||
const groups = groupPermissions(inbox);
|
||||
const pendingTotal = groups.reduce((n, g) => n + g.pending.length, 0);
|
||||
|
||||
// 有待决策请求的会话默认展开:那些是在等人动手的,藏起来等于没解决问题。
|
||||
// 全部已决策的会话默认折叠 —— 它们只是历史。
|
||||
const [expanded, setExpanded] = useState<Set<string> | null>(null);
|
||||
const autoOpen = groups.filter(g => g.pending.length > 0).map(g => g.sessionId);
|
||||
const openSet = expanded ?? new Set(autoOpen);
|
||||
|
||||
useEffect(() => {
|
||||
fetchInbox('all');
|
||||
}, []);
|
||||
|
||||
const toggle = (sessionId: string) => {
|
||||
setExpanded(prev => {
|
||||
const next = new Set(prev ?? autoOpen);
|
||||
if (next.has(sessionId)) next.delete(sessionId);
|
||||
else next.add(sessionId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const pick = (m: Mail) => {
|
||||
clearSession();
|
||||
cancelCompose();
|
||||
selectMail(m);
|
||||
showDetail();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full lg:w-[340px] shrink-0 border-r border-gray-200 bg-white flex flex-col min-w-0">
|
||||
<div className="px-4 py-3 border-b border-gray-200 flex items-center gap-1">
|
||||
<h2 className="text-sm font-semibold text-gray-800">授权</h2>
|
||||
{pendingTotal > 0 ? (
|
||||
<span className="ml-2 inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full bg-orange-100 text-orange-700 text-[10px] font-semibold">
|
||||
<ShieldIcon className="w-2.5 h-2.5" />
|
||||
{pendingTotal} 待决策
|
||||
</span>
|
||||
) : (
|
||||
<span className="ml-2 text-xs text-gray-400">
|
||||
{groups.length > 0 ? `${groups.length} 个会话` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{groups.map(g => (
|
||||
<PermissionSessionGroup
|
||||
key={g.sessionId}
|
||||
group={g}
|
||||
open={openSet.has(g.sessionId)}
|
||||
onToggle={() => toggle(g.sessionId)}
|
||||
currentMailID={currentMail?.mail_id}
|
||||
onPick={pick}
|
||||
/>
|
||||
))}
|
||||
|
||||
{groups.length === 0 && (
|
||||
<div className="text-center py-10">
|
||||
<ShieldIcon className="w-8 h-8 mx-auto text-gray-300" />
|
||||
<p className="text-xs text-gray-400 mt-2">没有授权请求</p>
|
||||
<p className="text-[10px] text-gray-400 mt-1 px-6">
|
||||
Agent 执行敏感操作(bash、写文件)时会在这里请求你批准
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 一个会话的授权组:组头显示 Agent 与待决策数,展开后是逐条请求。 */
|
||||
function PermissionSessionGroup({
|
||||
group: g,
|
||||
open,
|
||||
onToggle,
|
||||
currentMailID,
|
||||
onPick
|
||||
}: {
|
||||
group: PermissionGroup;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
currentMailID?: string;
|
||||
onPick: (m: Mail) => void;
|
||||
}) {
|
||||
const [showSettled, setShowSettled] = useState(false);
|
||||
const hasPending = g.pending.length > 0;
|
||||
const time = new Date(g.latest.created_at).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border ${
|
||||
hasPending ? 'border-orange-200 bg-orange-50/50' : 'border-gray-100'
|
||||
}`}
|
||||
>
|
||||
<button onClick={onToggle} className="w-full text-left px-3 py-2.5 rounded-lg">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ChevronRightIcon
|
||||
className={`w-3 h-3 text-gray-400 shrink-0 transition-transform ${
|
||||
open ? 'rotate-90' : ''
|
||||
}`}
|
||||
/>
|
||||
<BotIcon className="w-3.5 h-3.5 text-slate-600 shrink-0" />
|
||||
<span className="text-xs font-mono text-gray-900 truncate">
|
||||
{g.agentName}{g.path ? `@${g.path}` : ''}{g.alias ? `.${g.alias}` : ''}
|
||||
</span>
|
||||
<span className="ml-auto text-[10px] text-gray-400 shrink-0">{time}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 mt-1 pl-5">
|
||||
{hasPending ? (
|
||||
<span className="shrink-0 inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-orange-700 text-white text-[9px] font-bold">
|
||||
<ShieldIcon className="w-2.5 h-2.5" />
|
||||
{g.pending.length} 待决策
|
||||
</span>
|
||||
) : (
|
||||
<span className="shrink-0 inline-flex items-center gap-0.5 px-1 py-0.5 rounded bg-gray-100 text-gray-500 text-[9px]">
|
||||
<CheckIcon className="w-2.5 h-2.5" />
|
||||
已全部处理
|
||||
</span>
|
||||
)}
|
||||
{g.settled.length > 0 && (
|
||||
<span className="text-[10px] text-gray-400">历史 {g.settled.length}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!g.alias && (
|
||||
<p className="text-[10px] text-gray-400 font-mono truncate mt-0.5 pl-5">
|
||||
(未命名会话)
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="px-2 pb-2 space-y-0.5">
|
||||
{g.pending.map(m => (
|
||||
<PermissionRow
|
||||
key={m.mail_id}
|
||||
mail={m}
|
||||
active={currentMailID === m.mail_id}
|
||||
onClick={() => onPick(m)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{g.settled.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowSettled(v => !v)}
|
||||
className="w-full text-left px-2 py-1 text-[10px] text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showSettled ? '收起' : '展开'}已决策 {g.settled.length}
|
||||
</button>
|
||||
{showSettled &&
|
||||
g.settled.map(m => (
|
||||
<PermissionRow
|
||||
key={m.mail_id}
|
||||
mail={m}
|
||||
active={currentMailID === m.mail_id}
|
||||
onClick={() => onPick(m)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 一条授权请求。待决策的醒目,已决策的连结果一起显示(批了还是拒了)。 */
|
||||
function PermissionRow({
|
||||
mail,
|
||||
active,
|
||||
onClick
|
||||
}: {
|
||||
mail: Mail;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const settled = !!mail.permission_result;
|
||||
const approved = settled && /同意|允许|批准|approve|yes/i.test(mail.permission_result || '');
|
||||
const time = new Date(mail.created_at).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full text-left px-2.5 py-2 rounded-md border transition-colors ${
|
||||
active
|
||||
? 'bg-blue-50 border-blue-200'
|
||||
: settled
|
||||
? 'border-transparent hover:bg-gray-50'
|
||||
: 'border-orange-200 bg-white hover:bg-orange-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`text-xs truncate flex-1 ${
|
||||
settled ? 'text-gray-500' : 'font-medium text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{mail.subject}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400 shrink-0">{time}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
{settled ? (
|
||||
<span
|
||||
className={`inline-flex items-center gap-0.5 text-[10px] ${
|
||||
approved ? 'text-green-600' : 'text-red-500'
|
||||
}`}
|
||||
>
|
||||
{approved ? <CheckIcon className="w-2.5 h-2.5" /> : <CloseIcon className="w-2.5 h-2.5" />}
|
||||
{mail.permission_result}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-0.5 text-[10px] text-orange-600 font-medium">
|
||||
<ShieldIcon className="w-2.5 h-2.5" />
|
||||
等待你决策
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
231
client/electron/src/components/QuotaPanel.tsx
Normal file
231
client/electron/src/components/QuotaPanel.tsx
Normal file
@ -0,0 +1,231 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import * as api from '../api/client';
|
||||
import { BotIcon, CheckIcon, ArchiveIcon } from './icons';
|
||||
|
||||
/**
|
||||
* Agent 管理面板(管理员):默认预算 + 停用/恢复。
|
||||
*
|
||||
* 预算决定「派给某个 Agent 的新任务默认多少个来回」。
|
||||
* 停用/恢复控制 Agent 的准入:停用后密钥撤销、注册被拒,邮件与会话保留。
|
||||
*/
|
||||
export default function QuotaPanel() {
|
||||
const [stats, setStats] = useState<api.AgentStats[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
||||
const [confirming, setConfirming] = useState<string | null>(null);
|
||||
const [confirmAction, setConfirmAction] = useState<'toggle' | 'delete' | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const r = await api.adminListAgentStats();
|
||||
setStats(r.quotas);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const apply = async (name: string, defaultRounds: number) => {
|
||||
setBusy(name);
|
||||
setError(null);
|
||||
try {
|
||||
await api.adminSetDefaultRounds(name, defaultRounds);
|
||||
await load();
|
||||
setDrafts(d => {
|
||||
const next = { ...d };
|
||||
delete next[name];
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAgent = async (name: string) => {
|
||||
setBusy(name);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const result = await api.adminDeleteAgent(name);
|
||||
await load();
|
||||
setConfirming(null);
|
||||
setConfirmAction(null);
|
||||
const revoked = typeof result.keys_revoked === 'number' && result.keys_revoked > 0
|
||||
? `(已撤销 ${result.keys_revoked} 把密钥)`
|
||||
: '';
|
||||
setNotice(`${name} 已删除${revoked}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleStatus = async (name: string, currentDisabled: boolean) => {
|
||||
setBusy(name);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const result = await api.adminSetAgentStatus(name, !currentDisabled);
|
||||
await load();
|
||||
setConfirming(null);
|
||||
// 撤销了几把密钥是停用操作里最有信息量的部分 —— 恢复后要重新签发几把,
|
||||
// 只说「已停用」的话用户不知道还有这一步。
|
||||
const revoked = typeof result.keys_revoked === 'number' && result.keys_revoked > 0
|
||||
? `(已撤销 ${result.keys_revoked} 把密钥)`
|
||||
: '';
|
||||
// 恢复路径必须把「密钥不会自动回来」说出口。否则用户点完恢复就算完事,
|
||||
// 而插件拿着已撤销的密钥无限重试并被 401 —— 本会话就踩过:
|
||||
// opencode 被停用后拿旧密钥重试了 18 小时,gateway 日志里 2690 次 401。
|
||||
setNotice(result.disabled
|
||||
? `${name} 已停用${revoked}`
|
||||
: `${name} 已恢复为离线。密钥不会自动回来 —— 请到「密钥」面板重新签发一把并写进插件配置,否则它会一直被拒(401)。`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const statusBadge = (s: api.AgentStats) => {
|
||||
const st = s.status ?? 'offline';
|
||||
const cls = st === 'online'
|
||||
? 'bg-green-50 text-green-700'
|
||||
: st === 'disabled'
|
||||
? 'bg-red-50 text-red-600'
|
||||
: 'bg-gray-50 text-gray-500';
|
||||
const label = st === 'online' ? '在线' : st === 'disabled' ? '已停用' : '离线';
|
||||
return <span className={`text-[10px] px-1.5 py-0.5 rounded ${cls}`}>{label}</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<BotIcon className="w-4 h-4 text-gray-500" />
|
||||
<h3 className="text-sm font-semibold text-gray-900">Agent 管理</h3>
|
||||
<span className="text-xs text-gray-400">{stats.length}</span>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-gray-500">
|
||||
<strong className="font-medium text-gray-600">默认预算</strong>:派给某个 Agent 的新任务默认多少个来回(填 0 = 不限)。
|
||||
这只是默认值 —— 写信时可以单独指定,之后在对话页里还能随时调整。
|
||||
插件自动转发的最终总结与权限询问不占用预算。
|
||||
<br />
|
||||
<strong className="font-medium text-gray-600">停用</strong>:撤销该 Agent 的全部密钥、
|
||||
从地址补全与联系人里隐藏,拒绝它重新注册,并让别人发信给它时收到 409。
|
||||
<span className="text-gray-400">邮件、会话、模型范围全部保留,随时可恢复 —— 但密钥不会自动回来,恢复后需重新签发。</span>
|
||||
<br />
|
||||
<strong className="font-medium text-gray-600">删除</strong>:在停用的基础上清掉运行态(日历事件置 cancelled)。
|
||||
<span className="text-gray-400">邮件与会话保留(历史是审计凭据),此名字今后不可再注册。</span>
|
||||
</p>
|
||||
|
||||
{error && <div className="text-[11px] text-red-600 bg-red-50 rounded px-2 py-1.5">{error}</div>}
|
||||
{notice && <div className="text-[11px] text-green-700 bg-green-50 rounded px-2 py-1.5">{notice}</div>}
|
||||
|
||||
{stats.length === 0 ? (
|
||||
<div className="text-xs text-gray-400 py-3">暂无已注册的 Agent</div>
|
||||
) : (
|
||||
<div className="border border-gray-200 rounded-md divide-y divide-gray-100">
|
||||
{stats.map(s => {
|
||||
const draft = drafts[s.agent_name] ?? String(s.default_rounds);
|
||||
const dirty = draft !== String(s.default_rounds);
|
||||
const invalid = draft.trim() !== '' && !/^\d+$/.test(draft.trim());
|
||||
const isDisabled = s.status === 'disabled';
|
||||
const isConfirming = confirming === s.agent_name;
|
||||
|
||||
return (
|
||||
<div key={s.agent_name} className="px-3 py-2.5 flex items-center gap-x-3 gap-y-1 flex-wrap">
|
||||
<span className="text-xs font-mono text-gray-900 w-32 shrink-0 truncate">
|
||||
{s.agent_name}
|
||||
</span>
|
||||
|
||||
{statusBadge(s)}
|
||||
|
||||
<div className="min-w-0 flex-1 text-[11px] text-gray-500">
|
||||
{s.default_rounds === 0 ? '默认不限来回' : `默认 ${s.default_rounds} 个来回`}
|
||||
<span className="text-gray-400">
|
||||
{' · '}进行中 {s.active_sessions} 个任务 · 累计发信 {s.sent_total}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
value={draft}
|
||||
onChange={e => setDrafts(d => ({ ...d, [s.agent_name]: e.target.value }))}
|
||||
inputMode="numeric"
|
||||
title="新任务默认往返数;0 = 不限"
|
||||
className={`w-16 text-xs border rounded px-1.5 py-1 shrink-0 focus:outline-none focus:ring-2 focus:ring-blue-100 ${
|
||||
invalid ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
onClick={() => apply(s.agent_name, Number(draft.trim() || '0'))}
|
||||
disabled={!dirty || invalid || busy === s.agent_name}
|
||||
title="保存默认预算"
|
||||
className="tap shrink-0 text-gray-400 hover:text-blue-600 disabled:opacity-30"
|
||||
>
|
||||
<CheckIcon className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* 停用/恢复/删除 按钮 */}
|
||||
{isConfirming ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[10px] text-gray-500">确认{confirmAction === 'delete' ? '删除' : ''}?</span>
|
||||
<button
|
||||
onClick={() => confirmAction === 'delete'
|
||||
? deleteAgent(s.agent_name)
|
||||
: toggleStatus(s.agent_name, isDisabled)}
|
||||
disabled={busy === s.agent_name}
|
||||
className="tap text-[10px] px-1.5 py-0.5 rounded bg-red-50 text-red-600 hover:bg-red-100"
|
||||
>
|
||||
{confirmAction === 'delete' ? '删除' : (isDisabled ? '恢复' : '停用')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setConfirming(null); setConfirmAction(null); }}
|
||||
className="tap text-[10px] text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => { setConfirming(s.agent_name); setConfirmAction('toggle'); }}
|
||||
disabled={busy === s.agent_name}
|
||||
title={isDisabled
|
||||
? '恢复此 Agent(恢复为离线;停用时撤销的密钥不会自动回来,必须重新签发)'
|
||||
: '停用此 Agent(撤销全部密钥并从补全里隐藏;邮件与会话保留,可恢复)'}
|
||||
className={`tap shrink-0 inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded border ${
|
||||
isDisabled
|
||||
? 'border-green-200 text-green-700 hover:bg-green-50'
|
||||
: 'border-gray-200 text-gray-500 hover:text-red-600 hover:border-red-200'
|
||||
} disabled:opacity-30`}
|
||||
>
|
||||
<ArchiveIcon className="w-3 h-3" />
|
||||
{isDisabled ? '恢复' : '停用'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setConfirming(s.agent_name); setConfirmAction('delete'); }}
|
||||
disabled={busy === s.agent_name}
|
||||
title="彻底删除此 Agent(清除密钥与运行态;邮件保留但此名今后不可再用)"
|
||||
className="tap shrink-0 text-[10px] px-1.5 py-0.5 rounded border border-red-200 text-red-400 hover:text-red-600 hover:border-red-300 disabled:opacity-30"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
140
client/electron/src/components/SetupPage.tsx
Normal file
140
client/electron/src/components/SetupPage.tsx
Normal file
@ -0,0 +1,140 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import * as api from '../api/client';
|
||||
import { MailboxIcon, SpinnerIcon } from './icons';
|
||||
|
||||
/** 首次初始化向导:系统无任何用户时展示,创建首个管理员 */
|
||||
export default function SetupPage({ onDone }: { onDone: () => void }) {
|
||||
const bootstrap = useAuthStore(s => s.bootstrap);
|
||||
const [username, setUsername] = useState('admin');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const nameRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
nameRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const mismatch = confirm !== '' && password !== confirm;
|
||||
const ok =
|
||||
username.trim().length >= 2 &&
|
||||
password.length >= 8 &&
|
||||
!mismatch &&
|
||||
!busy;
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!ok) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.setupAdmin({
|
||||
username: username.trim().toLowerCase(),
|
||||
password,
|
||||
display_name: displayName.trim()
|
||||
});
|
||||
// 初始化后直接登录(后端已经种了 cookie),拉取用户态
|
||||
await bootstrap();
|
||||
onDone();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 卡片高约 371px,比横屏手机(或软键盘弹出后)的可视高度还高。
|
||||
//
|
||||
// 居中用卡片自己的 `my-auto` 而**不是**容器的 `items-center`:后者在内容超高时
|
||||
// 会让卡片上下同时溢出,而溢出到顶部那段滚不到(scrollTop 最小是 0)——
|
||||
// 实测 568x280 下「登录」按钮完全在视口外,光加 overflow-y-auto 也够不着。
|
||||
// auto margin 在空间不足时自动退化为 0,于是矮屏变成正常的顶对齐可滚布局。
|
||||
return (
|
||||
<div className="h-full overflow-y-auto flex justify-center bg-slate-100">
|
||||
<div className="w-[420px] max-w-[92vw] shrink-0 my-auto bg-white rounded-xl shadow-sm border border-gray-200 p-6 sm:p-8">
|
||||
<div className="flex flex-col items-center mb-6">
|
||||
<div className="w-12 h-12 rounded-xl bg-blue-50 text-blue-600 flex items-center justify-center">
|
||||
<MailboxIcon className="w-6 h-6" />
|
||||
</div>
|
||||
<h1 className="mt-3 text-base font-semibold text-gray-900">初始化系统</h1>
|
||||
<p className="mt-1 text-xs text-gray-500 text-center">
|
||||
这是系统首次启动。创建一个管理员账号以开始使用 AgentMail。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1">
|
||||
管理员用户名 <span className="text-red-400">(即三维地址的 name 位)</span>
|
||||
</label>
|
||||
<input
|
||||
ref={nameRef}
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
placeholder="admin"
|
||||
spellCheck={false}
|
||||
className="w-full text-sm font-mono border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-gray-400">
|
||||
小写字母数字 . _ -,2-64 位;后续可以 `admin@.new` 形式作为收件人
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1">显示名</label>
|
||||
<input
|
||||
value={displayName}
|
||||
onChange={e => setDisplayName(e.target.value)}
|
||||
placeholder="系统管理员"
|
||||
className="w-full text-sm border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1">
|
||||
密码 <span className="text-red-400">(至少 8 位)</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
className="w-full text-sm border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-gray-500 mb-1">确认密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={e => setConfirm(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
className={`w-full text-sm border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-100 ${
|
||||
mismatch ? 'border-red-300' : 'border-gray-300 focus:border-blue-400'
|
||||
}`}
|
||||
/>
|
||||
{mismatch && <p className="mt-1 text-[10px] text-red-500">两次输入的密码不一致</p>}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-600 bg-red-50 border border-red-100 rounded-md px-2.5 py-1.5">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!ok}
|
||||
className="w-full inline-flex items-center justify-center gap-2 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{busy && <SpinnerIcon className="w-3.5 h-3.5" />}
|
||||
{busy ? '初始化中' : '创建管理员并进入'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
client/electron/src/components/Sidebar.tsx
Normal file
143
client/electron/src/components/Sidebar.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
import { useUIStore, type ViewMode } from '../stores/uiStore';
|
||||
import { useMailStore } from '../stores/mailStore';
|
||||
import { useContactStore } from '../stores/contactStore';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import {
|
||||
InboxIcon,
|
||||
SentIcon,
|
||||
ContactsIcon,
|
||||
ComposeIcon,
|
||||
UsersIcon,
|
||||
LogoutIcon,
|
||||
ShieldIcon,
|
||||
CalendarIcon
|
||||
} from './icons';
|
||||
import { ConnectionIndicator } from './ConnectionIndicator';
|
||||
import { ThemeToggleButton } from './ThemePicker';
|
||||
import { countPendingPermissions, splitByPermission } from '../lib/mailGroups';
|
||||
|
||||
const navItems: {
|
||||
short: string;
|
||||
title: string;
|
||||
mode: ViewMode;
|
||||
Icon: (p: { className?: string }) => JSX.Element;
|
||||
adminOnly?: boolean;
|
||||
}[] = [
|
||||
{ short: '收件', title: '收件箱', mode: 'inbox', Icon: InboxIcon },
|
||||
// 授权紧跟收件箱:它是收件箱的「要动手」那一半,放在联系人之后会让人找不到
|
||||
{ short: '授权', title: '授权请求', mode: 'permissions', Icon: ShieldIcon },
|
||||
{ short: '发件', title: '发件箱', mode: 'sent', Icon: SentIcon },
|
||||
// 日历排在联系人之前:它是「我要安排什么」,与收发信同属日常动作;
|
||||
// 联系人是「查谁在哪」,用得少
|
||||
{ short: '日历', title: '日历', mode: 'calendar', Icon: CalendarIcon },
|
||||
{ short: '联系', title: '联系人', mode: 'contacts', Icon: ContactsIcon },
|
||||
{ short: '用户', title: '用户管理', mode: 'admin', Icon: UsersIcon, adminOnly: true }
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
const viewMode = useUIStore(s => s.viewMode);
|
||||
const setViewMode = useUIStore(s => s.setViewMode);
|
||||
const composing = useUIStore(s => s.composing);
|
||||
const startCompose = useUIStore(s => s.startCompose);
|
||||
|
||||
const inbox = useMailStore(s => s.inbox);
|
||||
// 收件箱的未读数只算普通邮件:权限请求归「授权」那一项,
|
||||
// 两处都数会让一个待批的 bash 在界面上显示成两件事
|
||||
const { normal, permissions } = splitByPermission(inbox);
|
||||
const unread = normal.filter(m => m.status === 'unread').length;
|
||||
const pendingPerms = countPendingPermissions(permissions);
|
||||
const contacts = useContactStore(s => s.contacts);
|
||||
|
||||
const user = useAuthStore(s => s.user);
|
||||
const logout = useAuthStore(s => s.logout);
|
||||
const isAdmin = user?.role === 'admin';
|
||||
|
||||
return (
|
||||
<div className="w-[60px] h-full shrink-0 flex flex-col items-center py-3 gap-1 bg-chrome-900"
|
||||
style={{ paddingBottom: 'calc(0.75rem + env(safe-area-inset-bottom))' }}
|
||||
>
|
||||
{navItems
|
||||
.filter(n => !n.adminOnly || isAdmin)
|
||||
.map(({ short, title, mode, Icon }) => {
|
||||
const active = viewMode === mode && !composing;
|
||||
const badge =
|
||||
mode === 'inbox'
|
||||
? unread
|
||||
: mode === 'permissions'
|
||||
? pendingPerms
|
||||
: mode === 'contacts'
|
||||
? contacts.length
|
||||
: 0;
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => setViewMode(mode)}
|
||||
title={title}
|
||||
className={`relative w-12 h-12 rounded-lg flex flex-col items-center justify-center gap-0.5 transition-colors ${
|
||||
active
|
||||
? 'bg-chrome-700 text-white'
|
||||
: 'text-chrome-400 hover:bg-chrome-800 hover:text-chrome-100'
|
||||
}`}
|
||||
>
|
||||
<Icon />
|
||||
<span className="text-[9px] leading-none">{short}</span>
|
||||
{badge > 0 && (
|
||||
<span
|
||||
className={`absolute top-0.5 right-1 min-w-[15px] h-[15px] px-1 rounded-full text-[9px] font-bold flex items-center justify-center ${
|
||||
mode === 'inbox'
|
||||
? 'bg-red-600 text-white'
|
||||
: mode === 'permissions'
|
||||
? // 待决策的授权用橙色:它跟未读不是一类紧急 ——
|
||||
// 未读是「有内容没看」,待决策是「有 Agent 卡在那儿等我」
|
||||
'bg-orange-700 text-white'
|
||||
: 'bg-chrome-600 text-chrome-100'
|
||||
}`}
|
||||
>
|
||||
{badge > 99 ? '99+' : badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<button
|
||||
onClick={() => startCompose()}
|
||||
title="新建邮件"
|
||||
className={`w-12 h-12 rounded-lg flex flex-col items-center justify-center gap-0.5 text-white transition-colors ${
|
||||
composing ? 'bg-blue-700 ring-2 ring-blue-300' : 'bg-blue-600 hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
<ComposeIcon />
|
||||
<span className="text-[9px] leading-none">新建</span>
|
||||
</button>
|
||||
|
||||
<div className="mt-2 pt-2 w-full flex flex-col items-center gap-1 border-t border-chrome-700">
|
||||
<button
|
||||
onClick={() => setViewMode('account')}
|
||||
title={`${user?.display_name || user?.username}(点击管理账号)`}
|
||||
className={`relative w-9 h-9 rounded-full flex items-center justify-center text-[11px] font-semibold transition-colors ${
|
||||
viewMode === 'account' && !composing
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-chrome-700 text-chrome-200 hover:bg-chrome-600'
|
||||
}`}
|
||||
>
|
||||
{(user?.display_name || user?.username || '?').slice(0, 2)}
|
||||
{/* 连接状态点:不遮挡文字,贴在右下角 */}
|
||||
<span className="absolute -bottom-0.5 -right-0.5">
|
||||
<ConnectionIndicator />
|
||||
</span>
|
||||
</button>
|
||||
<ThemeToggleButton className="w-9 h-7 rounded flex items-center justify-center text-chrome-400 hover:text-white hover:bg-chrome-800" />
|
||||
<button
|
||||
onClick={logout}
|
||||
title="退出登录"
|
||||
className="w-9 h-7 rounded flex items-center justify-center text-chrome-400 hover:text-white hover:bg-chrome-800"
|
||||
>
|
||||
<LogoutIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
client/electron/src/components/ThemePicker.tsx
Normal file
83
client/electron/src/components/ThemePicker.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import { useThemeStore, type ThemePref } from '../stores/themeStore';
|
||||
import { SunIcon, MoonIcon, MonitorIcon } from './icons';
|
||||
|
||||
/**
|
||||
* 主题切换。
|
||||
*
|
||||
* 两种形态共用一份状态:
|
||||
* - `compact`(侧栏 / 底部导航):单按钮,点一下翻转
|
||||
* - 默认(「我的」页):三选一,因为 `system` 只有在能明确选中时才有意义
|
||||
*
|
||||
* 单按钮不足以表达三态,但侧栏放不下三个选项;而只给单按钮的话
|
||||
* 用户一旦点过就永久脱离了「跟随系统」—— 那是个回不去的单向门。
|
||||
* 所以两个入口都提供,compact 用于快速切换,完整形态用于设定偏好。
|
||||
*/
|
||||
|
||||
const OPTIONS: { value: ThemePref; label: string; hint: string; Icon: (p: { className?: string }) => JSX.Element }[] = [
|
||||
{ value: 'light', label: '浅色', hint: '始终使用浅色', Icon: SunIcon },
|
||||
{ value: 'dark', label: '深色', hint: '始终使用深色', Icon: MoonIcon },
|
||||
{ value: 'system', label: '跟随系统', hint: '随系统的深浅色设置切换', Icon: MonitorIcon }
|
||||
];
|
||||
|
||||
/** 侧栏用的单按钮:点一下在浅/深之间翻转。 */
|
||||
export function ThemeToggleButton({ className = '' }: { className?: string }) {
|
||||
const resolved = useThemeStore(s => s.resolved);
|
||||
const pref = useThemeStore(s => s.pref);
|
||||
const toggle = useThemeStore(s => s.toggle);
|
||||
|
||||
const dark = resolved === 'dark';
|
||||
return (
|
||||
<button
|
||||
onClick={toggle}
|
||||
title={
|
||||
pref === 'system'
|
||||
? `跟随系统(当前${dark ? '深色' : '浅色'})—— 点击固定为${dark ? '浅色' : '深色'}`
|
||||
: `当前${dark ? '深色' : '浅色'} —— 点击切换`
|
||||
}
|
||||
aria-label="切换主题"
|
||||
className={className}
|
||||
>
|
||||
{dark ? <MoonIcon className="w-3.5 h-3.5" /> : <SunIcon className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** 「我的」页用的三选一。 */
|
||||
export default function ThemePicker() {
|
||||
const pref = useThemeStore(s => s.pref);
|
||||
const resolved = useThemeStore(s => s.resolved);
|
||||
const setPref = useThemeStore(s => s.setPref);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className="text-sm font-medium text-gray-900">外观</h3>
|
||||
{pref === 'system' && (
|
||||
<span className="text-xs text-gray-500">
|
||||
当前跟随系统:{resolved === 'dark' ? '深色' : '浅色'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{OPTIONS.map(o => {
|
||||
const active = pref === o.value;
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
onClick={() => setPref(o.value)}
|
||||
title={o.hint}
|
||||
className={`px-3 py-2.5 rounded border text-xs flex flex-col items-center gap-1.5 transition-colors ${
|
||||
active
|
||||
? 'border-blue-500 bg-blue-50 text-blue-700'
|
||||
: 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<o.Icon className="w-4 h-4" />
|
||||
{o.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
299
client/electron/src/components/ThreadView.tsx
Normal file
299
client/electron/src/components/ThreadView.tsx
Normal file
@ -0,0 +1,299 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import * as api from '../api/client';
|
||||
import { useMailStore } from '../stores/mailStore';
|
||||
import { participantAddress } from '../lib/replyTarget';
|
||||
import type { ThreadNode } from '../types';
|
||||
import { CloseIcon, PaperclipIcon, PersonIcon, BotIcon, ShieldIcon, SpinnerIcon } from './icons';
|
||||
|
||||
import BackButton from './BackButton';
|
||||
import { useIsNarrow } from '../hooks/useIsNarrow';
|
||||
|
||||
/**
|
||||
* 对话树视图(从线索根整树展开,分块加载)。
|
||||
*
|
||||
* 树由服务端沿 parent_mail_id 展开,因此**可以跨会话** —— 转发把线索引到新会话,
|
||||
* 但仍属同一条线索。这正是树视图比会话内平铺更有价值的地方:能看出线索分叉去了哪里。
|
||||
*
|
||||
* 展开的起点是**线索的根**而不是当前这封。早先的实现是「锚点的祖先链 + 锚点的子树」
|
||||
* 两个方向各自分页,结果兄弟节点整条分支都在盲区里:一封抄送给两个 Agent 的邮件
|
||||
* 会收到两个回复,它们互为兄弟,从其中一个看树永远看不到另一个;挂在原件上的
|
||||
* 转发分支同理。从根 BFS 之后,兄弟、抄送产生的平行回复、转发分支都是根的子孙。
|
||||
*
|
||||
* 只剩一个加载方向(往后翻),因此不需要滚动位置补偿 —— 新内容追加在末尾。
|
||||
*
|
||||
* 不用 react-d3-tree 之类的图形库:这里的树又浅又窄(邮件往来通常是一条主链
|
||||
* 加几个转发分支),缩进 + 连接线足够表达层级,还能直接复用列表的交互与样式,
|
||||
* 省掉一个渲染 SVG 的依赖和它带来的布局/缩放问题。
|
||||
*/
|
||||
export default function ThreadView({ mailID, onClose }: { mailID: string; onClose: () => void }) {
|
||||
const [nodes, setNodes] = useState<ThreadNode[]>([]);
|
||||
const [hidden, setHidden] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [nextOffset, setNextOffset] = useState(0);
|
||||
const [err, setErr] = useState('');
|
||||
const [initial, setInitial] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const bottomSentinel = useRef<HTMLDivElement>(null);
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
// 请求代次:mailID 变了就作废在飞的响应,避免慢请求后到覆盖新线索
|
||||
const gen = useRef(0);
|
||||
// loading 的同步副本。setState 是异步的,哨兵连续进入视口时
|
||||
// 读 state 会看到旧的 false 而并发发两个请求。
|
||||
const busy = useRef(false);
|
||||
|
||||
const sortNodes = (list: ThreadNode[]) =>
|
||||
list
|
||||
.slice()
|
||||
.sort((a, b) =>
|
||||
a.depth - b.depth || a.created_at.localeCompare(b.created_at)
|
||||
);
|
||||
|
||||
const merge = useCallback((incoming: ThreadNode[]) => {
|
||||
setNodes(prev => {
|
||||
const seen = new Set(prev.map(n => n.mail_id));
|
||||
return sortNodes([...prev, ...incoming.filter(n => !seen.has(n.mail_id))]);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 首屏
|
||||
useEffect(() => {
|
||||
const myGen = ++gen.current;
|
||||
setNodes([]);
|
||||
setHidden(0);
|
||||
setErr('');
|
||||
setInitial(true);
|
||||
busy.current = true;
|
||||
api
|
||||
.getMailThread(mailID, { offset: 0, limit: 60 })
|
||||
.then(p => {
|
||||
if (gen.current !== myGen) return;
|
||||
setNodes(sortNodes(p.nodes));
|
||||
setHidden(p.hidden);
|
||||
setHasMore(p.has_more);
|
||||
setNextOffset(p.next_offset);
|
||||
})
|
||||
.catch(e => {
|
||||
if (gen.current === myGen) setErr(e instanceof Error ? e.message : '加载失败');
|
||||
})
|
||||
.finally(() => {
|
||||
if (gen.current === myGen) {
|
||||
setInitial(false);
|
||||
busy.current = false;
|
||||
}
|
||||
});
|
||||
}, [mailID]);
|
||||
|
||||
// 首屏渲染完把当前这封滚进视野。长线索里锚点可能在几十封之后,
|
||||
// 不滚过去的话用户点开一封邮件却停在整条线索的开头。
|
||||
useEffect(() => {
|
||||
if (initial || !anchorRef.current) return;
|
||||
anchorRef.current.scrollIntoView({ block: 'center' });
|
||||
}, [initial]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (busy.current || !hasMore) return;
|
||||
const myGen = gen.current;
|
||||
busy.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const p = await api.getMailThread(mailID, { offset: nextOffset, limit: 60 });
|
||||
if (gen.current !== myGen) return;
|
||||
merge(p.nodes);
|
||||
setHidden(h => h + p.hidden);
|
||||
setHasMore(p.has_more);
|
||||
setNextOffset(p.next_offset);
|
||||
} catch (e) {
|
||||
if (gen.current === myGen) setErr(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
if (gen.current === myGen) setLoading(false);
|
||||
busy.current = false;
|
||||
}
|
||||
}, [mailID, merge, hasMore, nextOffset]);
|
||||
|
||||
// 底部哨兵进入视口就续取。rootMargin 提前 200px 触发,
|
||||
// 让加载在用户滑到边界前完成。
|
||||
useEffect(() => {
|
||||
const root = scrollRef.current;
|
||||
if (!root) return;
|
||||
const obs = new IntersectionObserver(
|
||||
entries => {
|
||||
for (const e of entries) if (e.isIntersecting) loadMore();
|
||||
},
|
||||
{ root, rootMargin: '200px' }
|
||||
);
|
||||
if (bottomSentinel.current) obs.observe(bottomSentinel.current);
|
||||
return () => obs.disconnect();
|
||||
}, [loadMore]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-w-0 flex flex-col bg-gray-50">
|
||||
<div className="px-4 md:px-6 py-3 border-b border-gray-200 bg-white flex items-center gap-2">
|
||||
{/* 窄屏下对话树是盖在列表上的一层,得有返回出口。
|
||||
它与右侧的「关闭」语义不同:返回退出整个详情栏回到列表,
|
||||
关闭只收起树、留在这封邮件上。 */}
|
||||
<BackButton />
|
||||
<span className="text-sm font-semibold text-gray-900">对话树</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
已加载 {nodes.length} 封
|
||||
{hasMore && ',滑动加载更多'}
|
||||
{hidden > 0 && `,${hidden} 封无权查看`}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
{loading && <SpinnerIcon className="w-3.5 h-3.5 animate-spin text-gray-400" />}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="tap inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-900"
|
||||
>
|
||||
<CloseIcon className="w-3.5 h-3.5" />
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 md:px-6 py-4">
|
||||
{initial && (
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<SpinnerIcon className="w-3.5 h-3.5 animate-spin" />
|
||||
加载中
|
||||
</div>
|
||||
)}
|
||||
{err && <p className="text-xs text-red-600">{err}</p>}
|
||||
|
||||
{!initial && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
{nodes.map(n => (
|
||||
<Node
|
||||
key={n.mail_id}
|
||||
node={n}
|
||||
anchorID={mailID}
|
||||
anchorRef={n.mail_id === mailID ? anchorRef : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasMore && (
|
||||
<button
|
||||
onClick={loadMore}
|
||||
className="tap w-full mt-2 py-1.5 rounded border border-dashed border-gray-300 text-xs text-gray-500 hover:border-blue-300 hover:text-blue-600"
|
||||
>
|
||||
加载后续往来
|
||||
</button>
|
||||
)}
|
||||
<div ref={bottomSentinel} className="h-px" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Node({
|
||||
node,
|
||||
anchorID,
|
||||
anchorRef
|
||||
}: {
|
||||
node: ThreadNode;
|
||||
anchorID: string;
|
||||
anchorRef?: React.RefObject<HTMLDivElement>;
|
||||
}) {
|
||||
const openMailByID = useMailStore(s => s.openMailByID);
|
||||
const narrow = useIsNarrow();
|
||||
const isPermission = node.mail_type === 'permission_request';
|
||||
const isAnchor = node.mail_id === anchorID;
|
||||
const ccCount = node.cc_list?.length ?? 0;
|
||||
// 转发是一条新线索:主题带 Fwd: 前缀,且落在别的会话里。
|
||||
// 树里把它标出来,否则一个分支为什么突然换了收件人无从判断。
|
||||
const isForward = node.subject.startsWith('Fwd: ');
|
||||
// 缩进:每级的像素数与上限都随屏宽变。
|
||||
//
|
||||
// 原先固定「每级 20px、上限 8 级」= 最多 160px。在 320px 屏上容器还要去掉
|
||||
// px-4 的 32px 与连接线的 18px,卡片只剩 110px —— 发件人一行就被 truncate 吃掉。
|
||||
// 窄屏改成每级 10px、上限 5 级(最多 50px),层级仍然看得出来,卡片还有余地。
|
||||
const step = narrow ? 10 : 20;
|
||||
const maxDepth = narrow ? 5 : 8;
|
||||
const indent = Math.min(Math.max(node.depth, 0), maxDepth) * step;
|
||||
const time = new Date(node.created_at).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={anchorRef} className="flex items-stretch" style={{ paddingLeft: indent }}>
|
||||
{indent > 0 && (
|
||||
<div className="w-3 shrink-0 border-l border-b border-gray-200 rounded-bl mr-1.5 -mt-1.5 mb-3" />
|
||||
)}
|
||||
<button
|
||||
onClick={() => openMailByID(node.mail_id)}
|
||||
className={`flex-1 min-w-0 text-left px-3 py-2 rounded-lg border bg-white transition-colors ${
|
||||
isAnchor ? 'border-blue-300 ring-1 ring-blue-100' : 'border-gray-200 hover:border-blue-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{node.from_workspace ? (
|
||||
<BotIcon className="w-3 h-3 text-gray-400 shrink-0" />
|
||||
) : (
|
||||
<PersonIcon className="w-3 h-3 text-gray-400 shrink-0" />
|
||||
)}
|
||||
{/* 树节点一行里塞了 from → to、转发标记与时间,不带会话位:
|
||||
整棵树本来就在同一条线索上,每个节点重复一遍别名毫无信息量。
|
||||
人还是 Agent 走显式布尔;workspace 从会话取(from_workspace
|
||||
对 Agent 存的是 Agent 名)。 */}
|
||||
<span className="text-xs font-mono text-gray-700 truncate">
|
||||
{participantAddress(
|
||||
node.from_name,
|
||||
node.from_human,
|
||||
node.from_human ? '' : node.session_workspace || ''
|
||||
)}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400">→</span>
|
||||
<span className="text-xs font-mono text-gray-500 truncate">{node.to_name}</span>
|
||||
<div className="flex-1" />
|
||||
{isForward && (
|
||||
<span className="px-1 py-0.5 rounded bg-blue-100 text-blue-700 text-[9px]">
|
||||
转发
|
||||
</span>
|
||||
)}
|
||||
{node.parent_hidden && (
|
||||
<span
|
||||
className="px-1 py-0.5 rounded bg-gray-100 text-gray-500 text-[9px]"
|
||||
title="上一封不在你的可见范围内"
|
||||
>
|
||||
上游不可见
|
||||
</span>
|
||||
)}
|
||||
{isPermission && (
|
||||
<span className="inline-flex items-center gap-0.5 px-1 py-0.5 rounded bg-orange-100 text-orange-700 text-[9px]">
|
||||
<ShieldIcon className="w-2.5 h-2.5" />
|
||||
权限
|
||||
</span>
|
||||
)}
|
||||
{node.attachment_count > 0 && (
|
||||
<span className="inline-flex items-center gap-0.5 text-[10px] text-gray-400">
|
||||
<PaperclipIcon className="w-2.5 h-2.5" />
|
||||
{node.attachment_count}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] text-gray-400 shrink-0">{time}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-800 mt-0.5 truncate">{node.subject}</p>
|
||||
{/* 抄送人要显示出来:一封邮件收到两个回复,正是因为它抄送给了两个人。
|
||||
不显示抄送,树上那两个兄弟节点为什么并列就没有解释。 */}
|
||||
{ccCount > 0 && (
|
||||
<p className="text-[10px] text-gray-400 mt-0.5 truncate">
|
||||
抄送 {node.cc_list.map(c => c.raw || `${c.name}@${c.path || ''}${c.session ? '.' + c.session : ''}`).join('、')}
|
||||
</p>
|
||||
)}
|
||||
{node.body_preview && (
|
||||
<p className="text-[11px] text-gray-400 mt-0.5 line-clamp-2">{node.body_preview}</p>
|
||||
)}
|
||||
{node.session_alias && (
|
||||
<span className="text-[10px] text-blue-500 font-mono">.{node.session_alias}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
150
client/electron/src/components/WorkCard.tsx
Normal file
150
client/electron/src/components/WorkCard.tsx
Normal file
@ -0,0 +1,150 @@
|
||||
import type { Contact } from '../types';
|
||||
import {
|
||||
ArchiveIcon,
|
||||
ComposeIcon,
|
||||
ChevronRightIcon,
|
||||
GaugeIcon,
|
||||
PersonIcon,
|
||||
BotIcon
|
||||
} from './icons';
|
||||
import PermissionChip from './PermissionChip';
|
||||
|
||||
/**
|
||||
* 工作卡片:中间栏的另一种呈现。
|
||||
*
|
||||
* 与列表行(ContactPanel 的 ContactRow)的分工:
|
||||
* 列表答「跟谁在聊」,卡片答「在聊什么、进展如何」。
|
||||
* 一条线索是一件正在进行的工作,卡片上要能直接看出:
|
||||
* - 主题(多由 Agent 平台的模型生成的摘要)
|
||||
* - 最新一封说了什么、谁说的
|
||||
* - 往返预算还剩多少 —— 预算是任务的属性,快跑满的任务需要人介入
|
||||
*
|
||||
* 容器(列表/滚动/空态)由 ContactPanel 负责:两种视图共用同一份数据与同一套
|
||||
* 打开/写信/归档动作,只有单项的渲染不同。竖向堆叠而非网格 ——
|
||||
* 卡片在中间栏里,320~400px 放不下多列。
|
||||
*/
|
||||
export function WorkCard({
|
||||
contact: c,
|
||||
active,
|
||||
onOpen,
|
||||
onCompose,
|
||||
onArchive
|
||||
}: {
|
||||
contact: Contact;
|
||||
active: boolean;
|
||||
onOpen: () => void;
|
||||
onCompose: () => void;
|
||||
onArchive: () => void;
|
||||
}) {
|
||||
const time = new Date(c.last_activity).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
const fromHuman = c.last_from !== c.agent_name;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group flex flex-col rounded-lg border bg-white transition-colors ${
|
||||
active ? 'border-blue-300 ring-1 ring-blue-100' : 'border-gray-200 hover:border-blue-300'
|
||||
}`}
|
||||
>
|
||||
<button onClick={onOpen} className="flex-1 text-left p-3 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-semibold text-gray-900 truncate">{c.agent_name}</span>
|
||||
<span className="text-[10px] text-gray-400 font-mono truncate">{c.path}</span>
|
||||
{c.unread_count > 0 && (
|
||||
<span className="ml-auto shrink-0 min-w-[16px] h-4 px-1 rounded-full bg-blue-600 text-white text-[9px] font-bold flex items-center justify-center">
|
||||
{c.unread_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<ChevronRightIcon className="w-3 h-3 text-blue-400 shrink-0" />
|
||||
<span className="text-[11px] text-blue-600 font-mono truncate">
|
||||
{c.session_alias || '(未命名会话)'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 主题是这张卡片的主角:它回答「这条线索在干什么」 */}
|
||||
<p className="text-xs text-gray-800 mt-1.5 line-clamp-2 leading-snug">
|
||||
{c.subject || '(无主题)'}
|
||||
</p>
|
||||
|
||||
{c.last_preview && (
|
||||
<div className="flex items-start gap-1 mt-1.5">
|
||||
{fromHuman ? (
|
||||
<PersonIcon className="w-3 h-3 text-gray-400 shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<BotIcon className="w-3 h-3 text-gray-400 shrink-0 mt-0.5" />
|
||||
)}
|
||||
<p className="text-[11px] text-gray-500 line-clamp-2 leading-snug">
|
||||
{c.last_preview}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className="text-[10px] text-gray-400">
|
||||
{c.mail_count} 封 · {time}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<PermissionChip mode={c.permission_mode} enforcement={c.permission_enforcement} compact />
|
||||
<BudgetChip max={c.max_rounds} used={c.used_rounds} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="reveal flex gap-1 px-3 pb-2.5">
|
||||
<button
|
||||
onClick={onCompose}
|
||||
title="写信给该地址"
|
||||
className="tap inline-flex items-center gap-1 px-2 py-0.5 rounded border border-gray-300 text-[10px] text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
<ComposeIcon className="w-3 h-3" />
|
||||
写信
|
||||
</button>
|
||||
<button
|
||||
onClick={onArchive}
|
||||
title="归档该 name@path.session"
|
||||
className="tap inline-flex items-center gap-1 px-2 py-0.5 rounded border border-gray-300 text-[10px] text-gray-600 hover:bg-gray-50 hover:text-red-600 hover:border-red-300"
|
||||
>
|
||||
<ArchiveIcon className="w-3 h-3" />
|
||||
归档
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 预算指示条。
|
||||
*
|
||||
* 0 = 不限,此时不显示 —— 一个「0/0」或「不限」的徽标对每张卡片都成立,
|
||||
* 等于纯噪声。只在真正设了上限时才占位置。
|
||||
* 剩 1 个来回时转红:那是需要人介入的时刻(要么加预算,要么让它收尾)。
|
||||
*
|
||||
* 导出供测试单独渲染 —— 它是纯展示件,而通过 WorkCard 渲染要先造一整个 Contact。
|
||||
*/
|
||||
export function BudgetChip({ max, used }: { max: number; used: number }) {
|
||||
if (!max || max <= 0) return null;
|
||||
|
||||
const remaining = Math.max(max - used, 0);
|
||||
const tone =
|
||||
remaining === 0
|
||||
? 'bg-red-100 text-red-700'
|
||||
: remaining <= 1
|
||||
? 'bg-orange-100 text-orange-700'
|
||||
: 'bg-gray-100 text-gray-500';
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-[9px] font-medium shrink-0 ${tone}`}
|
||||
title={`往返预算:已用 ${used}/${max}${remaining === 0 ? '(已用尽)' : ''}`}
|
||||
>
|
||||
<GaugeIcon className="w-2.5 h-2.5" />
|
||||
{remaining}/{max}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
413
client/electron/src/components/icons.tsx
Normal file
413
client/electron/src/components/icons.tsx
Normal file
@ -0,0 +1,413 @@
|
||||
// 纯 SVG 图标,全站不使用 emoji
|
||||
type P = { className?: string };
|
||||
|
||||
const D = 'w-5 h-5';
|
||||
|
||||
function Svg({ className = D, children }: P & { children: React.ReactNode }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function InboxIcon(p: P) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<path d="M22 12h-6l-2 3h-4l-2-3H2" />
|
||||
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SentIcon(p: P) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<path d="m22 2-7 20-4-9-9-4Z" />
|
||||
<path d="M22 2 11 13" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContactsIcon(p: P) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ComposeIcon(p: P) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MailIcon(p: P) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<rect width="20" height="16" x="2" y="4" rx="2" />
|
||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShieldIcon(p: P) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PersonIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BotIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect width="18" height="10" x="3" y="11" rx="2" />
|
||||
<circle cx="12" cy="5" r="2" />
|
||||
<path d="M12 7v4" />
|
||||
<path d="M8 16h.01" />
|
||||
<path d="M16 16h.01" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CheckIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CloseIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrashIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
|
||||
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArchiveIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect width="20" height="5" x="2" y="3" rx="1" />
|
||||
<path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8" />
|
||||
<path d="M10 12h4" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChevronRightIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogoutIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<path d="m16 17 5-5-5-5" />
|
||||
<path d="M21 12H9" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function UsersIcon({ className = D }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LockIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect width="18" height="11" x="3" y="11" rx="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MailboxIcon({ className = 'w-8 h-8' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M22 17a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9.5C2 7 4 5 6.5 5H18c2.2 0 4 1.8 4 4z" />
|
||||
<path d="M6 8h4" />
|
||||
<path d="M12 19V5" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpinnerIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<svg className={`${className} animate-spin`} viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" opacity="0.25" />
|
||||
<path
|
||||
d="M12 2a10 10 0 0 1 10 10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function KeyIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<circle cx="7.5" cy="15.5" r="4.5" />
|
||||
<path d="M10.7 12.3 21 2" />
|
||||
<path d="m17 6 3 3" />
|
||||
<path d="m14 9 3 3" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CopyIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect width="12" height="12" x="9" y="9" rx="2" />
|
||||
<path d="M5 15V5a2 2 0 0 1 2-2h10" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlusIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ForwardIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="m15 17 5-5-5-5" />
|
||||
<path d="M4 18v-2a4 4 0 0 1 4-4h12" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PaperclipIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M13.2 2.8a5 5 0 0 1 7 7l-8.5 8.5a3.2 3.2 0 0 1-4.5-4.5l8-8a1.4 1.4 0 0 1 2 2l-7.5 7.5" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function DownloadIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M12 3v12" />
|
||||
<path d="m7 11 5 5 5-5" />
|
||||
<path d="M4 20h16" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" />
|
||||
<path d="M14 3v5h5" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TreeIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect x="9" y="3" width="6" height="4" rx="1" />
|
||||
<rect x="3" y="17" width="6" height="4" rx="1" />
|
||||
<rect x="15" y="17" width="6" height="4" rx="1" />
|
||||
<path d="M12 7v4M6 17v-3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v3" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TagIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M20.6 13.4 13.4 20.6a2 2 0 0 1-2.8 0l-7.2-7.2A2 2 0 0 1 3 12V4a1 1 0 0 1 1-1h8a2 2 0 0 1 1.4.6l7.2 7.2a2 2 0 0 1 0 2.8Z" />
|
||||
<circle cx="7.5" cy="7.5" r="1" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function GaugeIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M12 14 15.5 9" />
|
||||
<path d="M3.5 17a9 9 0 1 1 17 0" />
|
||||
<circle cx="12" cy="14" r="1.2" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChevronLeftIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MenuIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M4 7h16M4 12h16M4 17h16" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListViewIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M8 6h12M8 12h12M8 18h12M4 6h.01M4 12h.01M4 18h.01" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardViewIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect x="3" y="4" width="18" height="7" rx="1.5" />
|
||||
<rect x="3" y="14" width="18" height="6" rx="1.5" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CpuIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" />
|
||||
<rect x="9" y="9" width="6" height="6" />
|
||||
<path d="M9 2v2M15 2v2M9 20v2M15 20v2M2 9h2M2 15h2M20 9h2M20 15h2" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CalendarIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect x="3" y="5" width="18" height="16" rx="2" />
|
||||
<path d="M3 10h18M8 3v4M16 3v4" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function BellIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||
<path d="M13.7 21a2 2 0 0 1-3.4 0" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function RepeatIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M17 2l4 4-4 4" />
|
||||
<path d="M3 11v-1a4 4 0 0 1 4-4h14" />
|
||||
<path d="M7 22l-4-4 4-4" />
|
||||
<path d="M21 13v1a4 4 0 0 1-4 4H3" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function UploadIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<path d="M17 8l-5-5-5 5M12 3v12" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PauseIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect x="6" y="4" width="4" height="16" />
|
||||
<rect x="14" y="4" width="4" height="16" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlayIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M5 3l14 9-14 9V3z" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SunIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MoonIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<path d="M21 12.8A8.5 8.5 0 1 1 11.2 3a6.6 6.6 0 0 0 9.8 9.8z" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MonitorIcon({ className = 'w-4 h-4' }: P) {
|
||||
return (
|
||||
<Svg className={className}>
|
||||
<rect x="2" y="4" width="20" height="13" rx="2" />
|
||||
<path d="M8 21h8M12 17v4" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
30
client/electron/src/hooks/useIsNarrow.ts
Normal file
30
client/electron/src/hooks/useIsNarrow.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* 窄屏断点。
|
||||
*
|
||||
* 三栏布局需要 60(导航)+ 320(列表)+ 至少 520(详情)≈ 900px;
|
||||
* 还要给滚动条、长地址和触摸留余量,因此与 Tailwind 的 lg 断点统一:
|
||||
* 1024px 以下(含常见 768/820px 竖屏平板)都使用单栏。
|
||||
*
|
||||
* 用 matchMedia 而不是监听 resize:后者每变化一像素都触发,还得自己节流;
|
||||
* matchMedia 只在跨过阈值时回调一次。
|
||||
*/
|
||||
export const NARROW_QUERY = '(max-width: 1023px)';
|
||||
|
||||
export function useIsNarrow(): boolean {
|
||||
const [narrow, setNarrow] = useState(() =>
|
||||
typeof window === 'undefined' ? false : window.matchMedia(NARROW_QUERY).matches
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia(NARROW_QUERY);
|
||||
const onChange = (e: MediaQueryListEvent) => setNarrow(e.matches);
|
||||
mq.addEventListener('change', onChange);
|
||||
// 挂载时同步一次:首次渲染到 effect 之间窗口可能已变化(例如手机旋屏)
|
||||
setNarrow(mq.matches);
|
||||
return () => mq.removeEventListener('change', onChange);
|
||||
}, []);
|
||||
|
||||
return narrow;
|
||||
}
|
||||
494
client/electron/src/index.css
Normal file
494
client/electron/src/index.css
Normal file
@ -0,0 +1,494 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/*
|
||||
* ─── 主题色板 ───
|
||||
*
|
||||
* 全站颜色都经 tailwind.config.js 指向这些变量,因此深色模式**不需要**
|
||||
* 在组件里写 dark: 前缀。逐处加前缀的方案在这里必然失败:约 700 处颜色
|
||||
* 散在 21 个组件里,漏一处就是深色下的白底白字,而且只有肉眼能发现。
|
||||
*
|
||||
* 深色模式的做法是**反转灰阶**:white → 近黑、gray-900 → 近白。
|
||||
* 这套代码里灰阶本身就是语义色阶(表面层次 / 分隔线 / 文字主次),
|
||||
* 反转之后 `bg-white text-gray-900` 自动变成「深色卡片 + 浅色文字」。
|
||||
* 新加的组件照常写浅色类名也自动适配。
|
||||
*
|
||||
* 值存 RGB 三元组而不是 #hex:代码里有 bg-blue-50/70 这类透明度修饰符,
|
||||
* Tailwind 会生成 rgb(var(--x) / 0.7),而 rgb(#f9fafb / 0.7) 是无效 CSS
|
||||
* —— 那些半透明高亮会静默失效(不报错,只是不透明)。
|
||||
*/
|
||||
:root {
|
||||
/*
|
||||
* 表面色(卡片 / 输入框底)。深色模式下变暗。
|
||||
*/
|
||||
--c-white: 255 255 255;
|
||||
|
||||
/*
|
||||
* 彩色按钮与深色框架上的文字。**不随主题反转。**
|
||||
*
|
||||
* 它与 --c-white 必须分开,因为 `white` 在这套代码里服务两种互相冲突的用途:
|
||||
* - `bg-white` = 卡片表面 → 深色模式必须变暗
|
||||
* - `text-white` = 按钮上文字 → 深色模式必须保持浅色
|
||||
*
|
||||
* 共用一个变量时后者跟着变暗,实测激活导航项的「收件」在 bg-chrome-700 上
|
||||
* 只剩 1.34:1 —— 几乎看不见。见 tailwind.config.js 的 textColor 覆盖。
|
||||
*/
|
||||
--c-on-accent: 255 255 255;
|
||||
|
||||
--c-gray-50: 249 250 251;
|
||||
--c-gray-100: 243 244 246;
|
||||
--c-gray-200: 229 231 235;
|
||||
--c-gray-300: 209 213 219;
|
||||
/* 最小字号与 placeholder 也会使用这一档;在白底上保持至少 4.5:1。 */
|
||||
--c-gray-400: 107 114 128;
|
||||
--c-gray-500: 107 114 128;
|
||||
--c-gray-600: 75 85 99;
|
||||
--c-gray-700: 55 65 81;
|
||||
--c-gray-800: 31 41 55;
|
||||
--c-gray-900: 17 24 39;
|
||||
--c-gray-950: 3 7 18;
|
||||
|
||||
/*
|
||||
* 应用框架(侧栏 / 底部导航)。
|
||||
*
|
||||
* 独立成一条色阶而不跟 gray 走:这两块**在浅色模式下本来就是深色的**
|
||||
* (深色侧栏配浅色内容区是这套 UI 的原本设计)。并入反转的 gray 之后,
|
||||
* 深色模式下 bg-slate-900 会变成近白色 —— 侧栏比内容区还亮,
|
||||
* 整个层次翻过来(实测 rgb(243,245,248) vs 内容区 rgb(17,19,24))。
|
||||
*/
|
||||
--c-chrome-100: 241 245 249;
|
||||
--c-chrome-200: 226 232 240;
|
||||
--c-chrome-400: 148 163 184;
|
||||
--c-chrome-600: 71 85 105;
|
||||
--c-chrome-700: 51 65 85;
|
||||
--c-chrome-800: 30 41 59;
|
||||
--c-chrome-900: 15 23 42;
|
||||
|
||||
/*
|
||||
* ─── 强调色(浅色)───
|
||||
*
|
||||
* 值就是 Tailwind 的官方色阶,浅色模式下视觉零变化。
|
||||
*
|
||||
* 这条色阶与灰阶一样是**语义色阶**,而且两段的用途相反:
|
||||
* - 50–300 = 表面(chip 底、提示条底、徽标底、边框)→ 深色下变暗
|
||||
* - 400–900 = 前景(文字、图标) → 深色下变亮
|
||||
*
|
||||
* 上一版把它们写成 tailwind.config.js 里的固定 hex,同时又留了一份
|
||||
* accent() 定义 —— JS 对象字面量重复键后者胜出,而当时 index.css 里
|
||||
* 没有对应的 --c-red-* 等变量。`rgb(var(--c-red-600) / 1)` 里变量未定义会让
|
||||
* **整条声明失效**,于是 bg-red-600 退回透明、白字落在白卡片上:
|
||||
* 按钮看不见但点得动(生产实测,32 个变量全部缺失)。
|
||||
*/
|
||||
/* blue */
|
||||
--c-blue-50: 239 246 255;
|
||||
--c-blue-100: 219 234 254;
|
||||
--c-blue-200: 191 219 254;
|
||||
--c-blue-300: 147 197 253;
|
||||
--c-blue-400: 96 165 250;
|
||||
--c-blue-500: 59 130 246;
|
||||
--c-blue-600: 37 99 235;
|
||||
--c-blue-700: 29 78 216;
|
||||
--c-blue-800: 30 64 175;
|
||||
--c-blue-900: 30 58 138;
|
||||
/* red */
|
||||
--c-red-50: 254 242 242;
|
||||
--c-red-100: 254 226 226;
|
||||
--c-red-200: 254 202 202;
|
||||
--c-red-300: 252 165 165;
|
||||
--c-red-400: 248 113 113;
|
||||
--c-red-500: 239 68 68;
|
||||
/* 比 Tailwind 官方的 220 38 38 略暗:官方值落在 red-50 上只有 4.41:1,
|
||||
而 `bg-red-50 text-red-600` 正是错误提示条 —— 差 2% 也是差。
|
||||
这一档同时用在白卡片上(4.83 → 5.10),压暗后两处都过线。 */
|
||||
--c-red-600: 213 37 37;
|
||||
--c-red-700: 185 28 28;
|
||||
--c-red-800: 153 27 27;
|
||||
--c-red-900: 127 29 29;
|
||||
/* green */
|
||||
--c-green-50: 240 253 244;
|
||||
--c-green-100: 220 252 231;
|
||||
--c-green-200: 187 247 208;
|
||||
--c-green-300: 134 239 172;
|
||||
--c-green-400: 74 222 128;
|
||||
--c-green-500: 34 197 94;
|
||||
--c-green-600: 22 163 74;
|
||||
--c-green-700: 21 128 61;
|
||||
--c-green-800: 22 101 52;
|
||||
--c-green-900: 20 83 45;
|
||||
/* amber */
|
||||
--c-amber-50: 255 251 235;
|
||||
--c-amber-100: 254 243 199;
|
||||
--c-amber-200: 253 230 138;
|
||||
--c-amber-300: 252 211 77;
|
||||
--c-amber-400: 251 191 36;
|
||||
--c-amber-500: 245 158 11;
|
||||
--c-amber-600: 217 119 6;
|
||||
--c-amber-700: 180 83 9;
|
||||
--c-amber-800: 146 64 14;
|
||||
--c-amber-900: 120 53 15;
|
||||
/* orange */
|
||||
--c-orange-50: 255 247 237;
|
||||
--c-orange-100: 255 237 213;
|
||||
--c-orange-200: 254 215 170;
|
||||
--c-orange-300: 253 186 116;
|
||||
--c-orange-400: 251 146 60;
|
||||
--c-orange-500: 249 115 22;
|
||||
--c-orange-600: 234 88 12;
|
||||
--c-orange-700: 194 65 12;
|
||||
--c-orange-800: 154 52 18;
|
||||
--c-orange-900: 124 45 18;
|
||||
/* yellow */
|
||||
--c-yellow-50: 254 252 232;
|
||||
--c-yellow-100: 254 249 195;
|
||||
--c-yellow-200: 254 240 138;
|
||||
--c-yellow-300: 253 224 71;
|
||||
--c-yellow-400: 250 204 21;
|
||||
--c-yellow-500: 234 179 8;
|
||||
--c-yellow-600: 202 138 4;
|
||||
--c-yellow-700: 161 98 7;
|
||||
--c-yellow-800: 133 77 14;
|
||||
--c-yellow-900: 113 63 18;
|
||||
/*
|
||||
* ─── 实心按钮/徽标的底色 ───
|
||||
*
|
||||
* 与上面 --c-* 的 400–900 **同值,但两种模式下都不变**。
|
||||
*
|
||||
* 为什么要单独一组:那六档在深色模式下被提亮成了浅色(red-600 →
|
||||
* rgb(246,141,141)),因为 `text-red-600` 得在深底上读得动。而
|
||||
* `bg-red-600 text-white` 的白字落在那个浅红上只有 1.6:1。
|
||||
* 一个名字服务两种语义就必然坏掉一头 —— 与 text-white/bg-white 那次同理。
|
||||
*
|
||||
* 只有 backgroundColor 走这组(见 tailwind.config.js)。
|
||||
*/
|
||||
--s-blue-400: 96 165 250;
|
||||
--s-blue-500: 59 130 246;
|
||||
--s-blue-600: 37 99 235;
|
||||
--s-blue-700: 29 78 216;
|
||||
--s-blue-800: 30 64 175;
|
||||
--s-blue-900: 30 58 138;
|
||||
--s-red-400: 248 113 113;
|
||||
--s-red-500: 239 68 68;
|
||||
--s-red-600: 220 38 38;
|
||||
--s-red-700: 185 28 28;
|
||||
--s-red-800: 153 27 27;
|
||||
--s-red-900: 127 29 29;
|
||||
--s-green-400: 74 222 128;
|
||||
--s-green-500: 34 197 94;
|
||||
--s-green-600: 22 163 74;
|
||||
--s-green-700: 21 128 61;
|
||||
--s-green-800: 22 101 52;
|
||||
--s-green-900: 20 83 45;
|
||||
--s-amber-400: 251 191 36;
|
||||
--s-amber-500: 245 158 11;
|
||||
--s-amber-600: 217 119 6;
|
||||
--s-amber-700: 180 83 9;
|
||||
--s-amber-800: 146 64 14;
|
||||
--s-amber-900: 120 53 15;
|
||||
--s-orange-400: 251 146 60;
|
||||
--s-orange-500: 249 115 22;
|
||||
--s-orange-600: 234 88 12;
|
||||
--s-orange-700: 194 65 12;
|
||||
--s-orange-800: 154 52 18;
|
||||
--s-orange-900: 124 45 18;
|
||||
--s-yellow-400: 250 204 21;
|
||||
--s-yellow-500: 234 179 8;
|
||||
--s-yellow-600: 202 138 4;
|
||||
--s-yellow-700: 161 98 7;
|
||||
--s-yellow-800: 133 77 14;
|
||||
--s-yellow-900: 113 63 18;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/*
|
||||
* 深色模式。
|
||||
*
|
||||
* # 灰阶整体反转
|
||||
*
|
||||
* - `white`(卡片底)→ 近黑的深灰。**不用纯黑**:纯黑上的浅色文字
|
||||
* 对比过强,长时间看更累,也看不出层次。
|
||||
* - `gray-50`(页面底)→ 比卡片**更暗**。浅色下页面底比卡片浅,
|
||||
* 深色下必须反过来,否则卡片会陷进背景失去边界。
|
||||
* - `gray-200/300`(分隔线)→ 中低亮度灰。照搬浅色值会得到刺眼的白线。
|
||||
* - `gray-400/500`(次要文字)→ **提亮**。深底上的浅色 gray-400 只有
|
||||
* 约 2:1 对比度,远低于 WCAG AA 的 4.5:1 —— 看得见但读不动。
|
||||
*
|
||||
* # 强调色不反转
|
||||
*
|
||||
* blue/red/green/... 在 tailwind.config.js 里是**固定值**,不走变量。
|
||||
* 按钮底色在深色模式下依然是 blue-600 那样的彩色,跟着变会让主按钮
|
||||
* 在深色页面上失去「这是主操作」的视觉重量。
|
||||
*
|
||||
* # 框架色阶只微调
|
||||
*
|
||||
* 见下面 --c-chrome-* 的注释。
|
||||
*/
|
||||
.dark {
|
||||
--c-white: 24 27 33;
|
||||
|
||||
/* 近白而非纯白:深色页面上纯白字偏刺眼。关键是它不跟着 --c-white 变暗。 */
|
||||
--c-on-accent: 244 246 250;
|
||||
|
||||
--c-gray-50: 17 19 24;
|
||||
--c-gray-100: 32 36 44;
|
||||
--c-gray-200: 44 49 59;
|
||||
--c-gray-300: 61 68 81;
|
||||
--c-gray-400: 138 146 161;
|
||||
--c-gray-500: 165 173 186;
|
||||
--c-gray-600: 190 197 208;
|
||||
--c-gray-700: 212 217 225;
|
||||
--c-gray-800: 231 235 240;
|
||||
--c-gray-900: 243 245 248;
|
||||
--c-gray-950: 250 251 253;
|
||||
|
||||
/*
|
||||
* 框架色阶**不反转,只微调**:比内容区(--c-gray-50 = 17 19 24)
|
||||
* 再深一档,保持「框架比内容更沉」这个浅色下就有的关系。
|
||||
* 文字档位相应提亮 —— 底色变深后原来的 chrome-400 只剩约 2.9:1。
|
||||
*/
|
||||
--c-chrome-100: 236 240 246;
|
||||
--c-chrome-200: 214 221 232;
|
||||
--c-chrome-400: 148 158 175;
|
||||
--c-chrome-600: 58 65 78;
|
||||
--c-chrome-700: 44 50 61;
|
||||
--c-chrome-800: 30 35 44;
|
||||
--c-chrome-900: 12 14 18;
|
||||
|
||||
/*
|
||||
* ─── 强调色(深色)───
|
||||
*
|
||||
* 两段走向相反,理由都是实测出来的:
|
||||
*
|
||||
* **表面段 50–300 变暗**(朝色相方向偏移卡片色)。照搬浅色值的话
|
||||
* red-50 (#fef2f2) 在深色页面上是一块近白亮斑 —— 那是「错误提示条」的底,
|
||||
* 结果比正文还抢眼,而它上面的红字反而读不动。
|
||||
*
|
||||
* **前景段 400–900 变亮**。照搬浅色值时 red-700 (#b91c1c) 落在深色卡片上
|
||||
* 只有 2.67:1、amber-900 只有 1.90:1 —— 远低于 WCAG AA 的 4.5:1。
|
||||
* 这里每一档都拉到 ≥4.5(实测最低 red-400 = 5.93),
|
||||
* 由 test/theme.test.mjs 逐档断言。
|
||||
*
|
||||
* 实心按钮底不在这里 —— 那组是 --s-*,定义在 :root 且两种模式同值。
|
||||
*/
|
||||
/* blue */
|
||||
--c-blue-50: 28 37 54;
|
||||
--c-blue-100: 30 43 67;
|
||||
--c-blue-200: 32 52 84;
|
||||
--c-blue-300: 36 62 105;
|
||||
--c-blue-400: 92 152 247;
|
||||
--c-blue-500: 108 162 248;
|
||||
--c-blue-600: 128 175 249;
|
||||
--c-blue-700: 154 191 250;
|
||||
--c-blue-800: 183 210 251;
|
||||
--c-blue-900: 213 228 253;
|
||||
/* red */
|
||||
--c-red-50: 46 31 37;
|
||||
--c-red-100: 58 34 39;
|
||||
--c-red-200: 76 37 41;
|
||||
--c-red-300: 97 41 45;
|
||||
--c-red-400: 243 109 109;
|
||||
--c-red-500: 244 124 124;
|
||||
--c-red-600: 246 141 141;
|
||||
--c-red-700: 248 164 164;
|
||||
--c-red-800: 250 191 191;
|
||||
--c-red-900: 252 217 217;
|
||||
/* green */
|
||||
--c-green-50: 25 44 39;
|
||||
--c-green-100: 26 54 43;
|
||||
--c-green-200: 26 68 48;
|
||||
--c-green-300: 27 85 54;
|
||||
--c-green-400: 34 197 94;
|
||||
--c-green-500: 56 203 110;
|
||||
--c-green-600: 83 210 129;
|
||||
--c-green-700: 118 219 155;
|
||||
--c-green-800: 158 229 184;
|
||||
--c-green-900: 198 240 213;
|
||||
/* amber */
|
||||
--c-amber-50: 46 40 31;
|
||||
--c-amber-100: 59 48 29;
|
||||
--c-amber-200: 77 58 28;
|
||||
--c-amber-300: 99 72 26;
|
||||
--c-amber-400: 245 158 11;
|
||||
--c-amber-500: 246 168 35;
|
||||
--c-amber-600: 247 179 65;
|
||||
--c-amber-700: 249 195 104;
|
||||
--c-amber-800: 251 212 148;
|
||||
--c-amber-900: 252 230 192;
|
||||
/* orange */
|
||||
--c-orange-50: 47 36 32;
|
||||
--c-orange-100: 60 41 31;
|
||||
--c-orange-200: 78 48 30;
|
||||
--c-orange-300: 101 57 29;
|
||||
--c-orange-400: 249 115 22;
|
||||
--c-orange-500: 250 129 45;
|
||||
--c-orange-600: 250 146 73;
|
||||
--c-orange-700: 251 168 111;
|
||||
--c-orange-800: 252 193 152;
|
||||
--c-orange-900: 253 219 194;
|
||||
/* yellow */
|
||||
--c-yellow-50: 45 42 31;
|
||||
--c-yellow-100: 58 51 29;
|
||||
--c-yellow-200: 74 63 27;
|
||||
--c-yellow-300: 95 79 25;
|
||||
--c-yellow-400: 234 179 8;
|
||||
--c-yellow-500: 236 187 33;
|
||||
--c-yellow-600: 239 196 62;
|
||||
--c-yellow-700: 242 208 102;
|
||||
--c-yellow-800: 246 222 146;
|
||||
--c-yellow-900: 250 235 191;
|
||||
/* 让浏览器把滚动条、表单控件、autofill 一并切深色 */
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* main.tsx 会用 Visual Viewport 覆盖;这里保证脚本执行前也有正确高度。 */
|
||||
--app-height: 100vh;
|
||||
}
|
||||
@supports (height: 100dvh) {
|
||||
:root {
|
||||
--app-height: 100dvh;
|
||||
}
|
||||
}
|
||||
html, body, #root {
|
||||
height: var(--app-height);
|
||||
min-height: 0;
|
||||
}
|
||||
body {
|
||||
overflow: hidden;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
/*
|
||||
* 显式给 body 底色。移动端橡皮筋回弹时露出的是 body 背景 ——
|
||||
* 不设的话深色模式下滑到边界会闪出一条白边。
|
||||
*/
|
||||
background-color: rgb(var(--c-gray-50));
|
||||
color: rgb(var(--c-gray-900));
|
||||
}
|
||||
|
||||
/* 统一作者样式,避免 Chromium/Electron autofill 在深色卡片上画白底。 */
|
||||
input:not([type='checkbox']):not([type='radio']):not([type='file']),
|
||||
textarea,
|
||||
select {
|
||||
background-color: rgb(var(--c-white));
|
||||
color: rgb(var(--c-gray-900));
|
||||
}
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: rgb(var(--c-gray-400));
|
||||
}
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus {
|
||||
-webkit-text-fill-color: rgb(var(--c-gray-900));
|
||||
box-shadow: 0 0 0 1000px rgb(var(--c-white)) inset;
|
||||
transition: background-color 9999s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* react-markdown 的本地排版层;不依赖固定色板的 typography 插件。 */
|
||||
.markdown {
|
||||
color: rgb(var(--c-gray-800));
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.markdown > * + * { margin-top: 0.75rem; }
|
||||
.markdown h1 { font-size: 1.5rem; line-height: 2rem; font-weight: 700; }
|
||||
.markdown h2 { font-size: 1.25rem; line-height: 1.75rem; font-weight: 700; }
|
||||
.markdown h3 { font-size: 1.05rem; line-height: 1.5rem; font-weight: 600; }
|
||||
.markdown ul { list-style: disc; padding-left: 1.35rem; }
|
||||
.markdown ol { list-style: decimal; padding-left: 1.35rem; }
|
||||
.markdown li + li { margin-top: 0.25rem; }
|
||||
.markdown a { color: rgb(var(--c-blue-600)); text-decoration: underline; }
|
||||
.markdown blockquote {
|
||||
border-left: 3px solid rgb(var(--c-gray-300));
|
||||
padding-left: 0.75rem;
|
||||
color: rgb(var(--c-gray-600));
|
||||
}
|
||||
.markdown code {
|
||||
border-radius: 0.25rem;
|
||||
background: rgb(var(--c-gray-100));
|
||||
padding: 0.1rem 0.3rem;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
.markdown pre {
|
||||
overflow-x: auto;
|
||||
border: 1px solid rgb(var(--c-gray-200));
|
||||
border-radius: 0.5rem;
|
||||
background: rgb(var(--c-gray-100));
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.markdown pre code { background: transparent; padding: 0; }
|
||||
.markdown table { width: 100%; border-collapse: collapse; display: block; overflow-x: auto; }
|
||||
.markdown th, .markdown td { border: 1px solid rgb(var(--c-gray-200)); padding: 0.4rem 0.55rem; }
|
||||
.markdown th { background: rgb(var(--c-gray-100)); text-align: left; }
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/*
|
||||
* .tap —— 保证 44x44 的触摸命中区,但不改变视觉尺寸。
|
||||
*
|
||||
* 移动端 44x44 是通行下限(Apple HIG 与 Material 都取这个数),而这些图标/
|
||||
* 小字按钮视觉上只有 15-24px 高 —— 实测「标记已读」48x16、「转发」42x16、
|
||||
* 「抄送」20x15。直接加 padding 会把本来就挤的头部撑散,在 320px 屏上还会换行。
|
||||
*
|
||||
* 改用居中的透明伪元素扩大命中区:视觉一像素不动,手指够得到。
|
||||
*
|
||||
* 只在窄屏生效:桌面用鼠标,精度足够;而扩大后的命中区在密排的工具栏里
|
||||
* 会互相重叠,点一个可能命中隔壁那个。
|
||||
*/
|
||||
/* 与 useIsNarrow / Tailwind lg 统一:竖屏平板也需要触摸命中区。 */
|
||||
@media (max-width: 1023px) {
|
||||
.safe-frame {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-left: env(safe-area-inset-left);
|
||||
padding-right: env(safe-area-inset-right);
|
||||
}
|
||||
.narrow-nav {
|
||||
padding-left: env(safe-area-inset-left);
|
||||
padding-right: env(safe-area-inset-right);
|
||||
}
|
||||
.form-editing .narrow-nav {
|
||||
display: none;
|
||||
}
|
||||
.tap {
|
||||
position: relative;
|
||||
}
|
||||
.tap::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* .reveal —— 悬停才显形的次要动作(写信 / 归档)。
|
||||
*
|
||||
* 原先直接写 `opacity-0 group-hover:opacity-100`。**触摸设备没有 hover**,
|
||||
* 于是这些按钮永远是透明的,却仍然接收点击 —— 实测在联系人列表上
|
||||
* elementFromPoint 命中的就是那个看不见的「归档」。一个看不见却按得动的
|
||||
* 破坏性按钮比没有按钮更糟:人以为自己点的是卡片,实际归档了一条会话。
|
||||
*
|
||||
* 因此默认可见,只在**真的支持悬停**的设备上才隐藏。判据用
|
||||
* `(hover: hover) and (pointer: fine)`:单看 hover 会把带触摸板的平板算进去。
|
||||
*/
|
||||
.reveal {
|
||||
opacity: 1;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
transition: opacity 150ms;
|
||||
}
|
||||
.group:hover .reveal,
|
||||
.reveal:focus-within {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
208
client/electron/src/lib/calendar.ts
Normal file
208
client/electron/src/lib/calendar.ts
Normal file
@ -0,0 +1,208 @@
|
||||
import type { CalendarEvent } from '../types';
|
||||
|
||||
/**
|
||||
* 日历的日期计算与事件分桶。
|
||||
*
|
||||
* 全部是纯函数,跟 React 无关,因此能单独测。月视图的格子、事件落到哪一天、
|
||||
* 提醒的实际触发时刻 —— 这些算错了没有任何报错,只是提醒发在错误的时间。
|
||||
*
|
||||
* 时区一律用**本地时区**:日历是给人看的,人说「9 月 3 日」指的是自己那天。
|
||||
* 与后端交互时才转 ISO(`toISOString()` 给 UTC,后端存 UTC)。
|
||||
*/
|
||||
|
||||
/** 一天的开始(本地时区 00:00:00.000)。 */
|
||||
export function startOfDay(d: Date): Date {
|
||||
const x = new Date(d);
|
||||
x.setHours(0, 0, 0, 0);
|
||||
return x;
|
||||
}
|
||||
|
||||
/** 一天的结束(本地时区 23:59:59.999)。 */
|
||||
export function endOfDay(d: Date): Date {
|
||||
const x = new Date(d);
|
||||
x.setHours(23, 59, 59, 999);
|
||||
return x;
|
||||
}
|
||||
|
||||
/** 月首(本地时区)。 */
|
||||
export function startOfMonth(d: Date): Date {
|
||||
return new Date(d.getFullYear(), d.getMonth(), 1);
|
||||
}
|
||||
|
||||
/** 月末最后一刻。 */
|
||||
export function endOfMonth(d: Date): Date {
|
||||
return new Date(d.getFullYear(), d.getMonth() + 1, 0, 23, 59, 59, 999);
|
||||
}
|
||||
|
||||
/**
|
||||
* 周首。
|
||||
*
|
||||
* 中文语境下一周从**周一**开始,而 `getDay()` 把周日算作 0 ——
|
||||
* 直接减 `getDay()` 会让周日被归到上一周的末尾,月视图第一行就错位。
|
||||
*/
|
||||
export function startOfWeek(d: Date): Date {
|
||||
const x = startOfDay(d);
|
||||
const dow = x.getDay(); // 0=周日
|
||||
const diff = dow === 0 ? 6 : dow - 1;
|
||||
x.setDate(x.getDate() - diff);
|
||||
return x;
|
||||
}
|
||||
|
||||
export function endOfWeek(d: Date): Date {
|
||||
const x = startOfWeek(d);
|
||||
x.setDate(x.getDate() + 6);
|
||||
return endOfDay(x);
|
||||
}
|
||||
|
||||
/** 同一天?(本地时区,只比年月日) */
|
||||
export function isSameDay(a: Date, b: Date): boolean {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
/** 加天数,返回新对象(不修改入参)。 */
|
||||
export function addDays(d: Date, n: number): Date {
|
||||
const x = new Date(d);
|
||||
x.setDate(x.getDate() + n);
|
||||
return x;
|
||||
}
|
||||
|
||||
export function addMonths(d: Date, n: number): Date {
|
||||
// 先归到 1 号再加月:从 1 月 31 日加一个月,setMonth 会溢出到 3 月 2/3 日
|
||||
return new Date(d.getFullYear(), d.getMonth() + n, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 月视图的 42 个格子(6 行 × 7 列)。
|
||||
*
|
||||
* 固定 6 行而不是按需 4~6 行:行数变化会让整个网格高度跳动,
|
||||
* 翻月时页面内容上下弹。多出来的格子显示邻月日期并置灰。
|
||||
*/
|
||||
export function monthGrid(anchor: Date): Date[] {
|
||||
const first = startOfWeek(startOfMonth(anchor));
|
||||
const cells: Date[] = [];
|
||||
for (let i = 0; i < 42; i++) cells.push(addDays(first, i));
|
||||
return cells;
|
||||
}
|
||||
|
||||
/** 周视图的 7 天。 */
|
||||
export function weekDays(anchor: Date): Date[] {
|
||||
const first = startOfWeek(anchor);
|
||||
return Array.from({ length: 7 }, (_, i) => addDays(first, i));
|
||||
}
|
||||
|
||||
/**
|
||||
* 事件的实际提醒时刻 = 事件时间 − remind_before 分钟。
|
||||
*
|
||||
* 这是调度器真正比较的那个时间点。UI 上要显示它,否则人设了「提前 30 分钟」
|
||||
* 却在事件时间那一刻才反应过来 —— 提醒早就发出去了。
|
||||
*/
|
||||
export function remindAt(e: CalendarEvent): Date {
|
||||
const t = new Date(e.event_time).getTime();
|
||||
return new Date(t - (e.remind_before || 0) * 60_000);
|
||||
}
|
||||
|
||||
/** 事件时间解析失败时给 epoch 0 而不是 NaN(NaN 参与排序会让顺序不确定)。 */
|
||||
function eventTime(e: CalendarEvent): number {
|
||||
const t = new Date(e.event_time).getTime();
|
||||
return Number.isNaN(t) ? 0 : t;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把事件按天分桶,键是 `YYYY-MM-DD`(本地时区)。
|
||||
*
|
||||
* 用本地日期串而不是 ISO 前缀切片:`toISOString().slice(0,10)` 给的是 UTC 日期,
|
||||
* 东八区晚上 8 点之后的事件会被归到**第二天**的格子里。
|
||||
*/
|
||||
export function bucketByDay(events: CalendarEvent[]): Map<string, CalendarEvent[]> {
|
||||
const map = new Map<string, CalendarEvent[]>();
|
||||
for (const e of events) {
|
||||
const d = new Date(e.event_time);
|
||||
if (Number.isNaN(d.getTime())) continue; // 脏数据不该让整个视图空白
|
||||
const key = dayKey(d);
|
||||
const bucket = map.get(key);
|
||||
if (bucket) bucket.push(e);
|
||||
else map.set(key, [e]);
|
||||
}
|
||||
// 同一天内按时间正序:日历格子里人从上往下读就是时间顺序
|
||||
for (const list of map.values()) {
|
||||
list.sort((a, b) => eventTime(a) - eventTime(b) || a.event_id.localeCompare(b.event_id));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** 本地时区的 `YYYY-MM-DD`。 */
|
||||
export function dayKey(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${dd}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染提醒正文:把 `{title}` `{time}` `{description}` 替换成实际值。
|
||||
*
|
||||
* 必须与后端 `scheduler.RenderReminder` 的行为一致 —— 前端预览显示的
|
||||
* 与实际发出的不是一个东西,那比不预览更糟。变量名两处都是硬编码的字面量,
|
||||
* 改动时要同步。
|
||||
*/
|
||||
export function renderReminder(tpl: string, e: { title: string; description?: string; event_time: string }): string {
|
||||
const t = new Date(e.event_time);
|
||||
const time = Number.isNaN(t.getTime())
|
||||
? e.event_time
|
||||
: `${t.getFullYear()}-${String(t.getMonth() + 1).padStart(2, '0')}-${String(
|
||||
t.getDate()
|
||||
).padStart(2, '0')} ${String(t.getHours()).padStart(2, '0')}:${String(
|
||||
t.getMinutes()
|
||||
).padStart(2, '0')}`;
|
||||
// 用 split/join 而不是 replaceAll:tsconfig 的 target 是 ES2020,
|
||||
// replaceAll 在那个 lib 里不存在(TS2550)。也不能用 replace ——
|
||||
// 它只换第一个,同一变量写两次时第二个会原样漏到邮件里。
|
||||
const sub = (s: string, from: string, to: string) => s.split(from).join(to);
|
||||
return sub(sub(sub(tpl, '{title}', e.title || ''), '{time}', time), '{description}', e.description || '');
|
||||
}
|
||||
|
||||
/** `<input type="datetime-local">` 要的格式:本地时区的 `YYYY-MM-DDTHH:mm`。 */
|
||||
export function toLocalInput(d: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(
|
||||
d.getHours()
|
||||
)}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `datetime-local` 的值转 ISO(给后端)。
|
||||
*
|
||||
* `new Date('2026-09-03T14:30')` 按**本地时区**解析(无 Z 后缀),
|
||||
* 这正是想要的:人在输入框里写的就是本地时间。
|
||||
*/
|
||||
export function fromLocalInput(v: string): string {
|
||||
const d = new Date(v);
|
||||
return Number.isNaN(d.getTime()) ? '' : d.toISOString();
|
||||
}
|
||||
|
||||
/** 人类可读的重复规则。 */
|
||||
export function describeRecurrence(e: CalendarEvent): string {
|
||||
const map: Record<string, string> = {
|
||||
none: '不重复',
|
||||
daily: '每天',
|
||||
weekly: '每周',
|
||||
monthly: '每月'
|
||||
};
|
||||
const base = map[e.recurrence] || '不重复';
|
||||
if (e.recurrence === 'none' || !e.recurrence_end) return base;
|
||||
const end = new Date(e.recurrence_end);
|
||||
if (Number.isNaN(end.getTime())) return base;
|
||||
return `${base},至 ${dayKey(end)}`;
|
||||
}
|
||||
|
||||
/** 提前提醒的人类描述。 */
|
||||
export function describeRemindBefore(min: number): string {
|
||||
if (!min) return '到点提醒';
|
||||
if (min < 60) return `提前 ${min} 分钟`;
|
||||
if (min % 60 === 0) return `提前 ${min / 60} 小时`;
|
||||
return `提前 ${Math.floor(min / 60)} 小时 ${min % 60} 分钟`;
|
||||
}
|
||||
327
client/electron/src/lib/lunar.ts
Normal file
327
client/electron/src/lib/lunar.ts
Normal file
@ -0,0 +1,327 @@
|
||||
import { Solar, Lunar, LunarYear } from 'lunar-javascript';
|
||||
|
||||
/**
|
||||
* 农历换算与「按农历推进」的重复规则计算。
|
||||
*
|
||||
* 这一层是后端 `internal/lunar/lunar.go` 的镜像 —— 两边用的是同一作者
|
||||
* (6tail)的库(lunar-javascript / lunar-go),换算结果一致。
|
||||
* 前端需要它是因为**日历格子上要显示农历日**,而且事件编辑器要在保存前
|
||||
* 预览「这条规则接下来几次落在哪天」。让后端算再拉一次网络请求太慢,
|
||||
* 而完全不显示农历会让人无法确认规则没被理解错。
|
||||
*
|
||||
* 与后端保持同步的两条硬约定:
|
||||
*
|
||||
* 1. **闰月用负数月份表示**(-6 = 闰六月)。
|
||||
* 2. **非法日期必须夹取而不是抛错**。库在 `Lunar.fromYmd(2027, 9, 30)`
|
||||
* 上直接 throw(农历月是 29 或 30 天不定),「每月农历三十」这条规则
|
||||
* 必然撞上 29 天的月份。
|
||||
*/
|
||||
|
||||
/** 一个农历日期。month 为负数表示闰月。 */
|
||||
export interface LunarDate {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
}
|
||||
|
||||
/** 公历 Date → 农历日期(只取年月日)。 */
|
||||
export function fromSolar(d: Date): LunarDate {
|
||||
const l = Solar.fromYmd(d.getFullYear(), d.getMonth() + 1, d.getDate()).getLunar();
|
||||
return { year: l.getYear(), month: l.getMonth(), day: l.getDay() };
|
||||
}
|
||||
|
||||
/** 某农历年的闰月(0 = 无闰月)。 */
|
||||
export function leapMonth(year: number): number {
|
||||
try {
|
||||
return LunarYear.fromYear(year).getLeapMonth();
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 某个农历月有多少天(29 或 30)。
|
||||
*
|
||||
* 返回 0 表示该月不存在(例如问一个没有闰六月的年份要闰六月)——
|
||||
* 这不是异常:「每年农历闰六月十五」在无闰六月的年份本来就无法落地,
|
||||
* 调用方需要据此跳过而不是猜一个日子。
|
||||
*/
|
||||
export function daysInMonth(year: number, month: number): number {
|
||||
try {
|
||||
const ly = LunarYear.fromYear(year);
|
||||
const months = ly.getMonths();
|
||||
for (const lm of months) {
|
||||
if (lm.getYear() === year && lm.getMonth() === month) {
|
||||
return lm.getDayCount();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 农历日期 → 公历 Date,带上给定的时分秒。
|
||||
*
|
||||
* 日期会被**夹到该农历月的实际天数内**:请求农历三十而该月只有 29 天时
|
||||
* 返回廿九,而不是抛错也不是滚到下个月初一。
|
||||
*
|
||||
* 夹而不滚:「每月农历三十」的语义是「月末那天」,滚到下月初一会让提醒
|
||||
* 出现在完全错误的日子(且与前一次只隔一天)。
|
||||
*
|
||||
* 返回 null 表示该农历月根本不存在(无效的闰月)。
|
||||
*/
|
||||
export function toSolar(
|
||||
d: LunarDate,
|
||||
hour = 0,
|
||||
minute = 0,
|
||||
second = 0
|
||||
): { date: Date; clamped: boolean } | null {
|
||||
const days = daysInMonth(d.year, d.month);
|
||||
if (days === 0) return null;
|
||||
let day = d.day;
|
||||
let clamped = false;
|
||||
if (day > days) {
|
||||
day = days;
|
||||
clamped = true;
|
||||
}
|
||||
if (day < 1) return null;
|
||||
|
||||
try {
|
||||
const s = Lunar.fromYmd(d.year, d.month, day).getSolar();
|
||||
return {
|
||||
date: new Date(s.getYear(), s.getMonth() - 1, s.getDay(), hour, minute, second, 0),
|
||||
clamped
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在农历上推进若干个月。
|
||||
*
|
||||
* 逐月走而不是「月份数 + n 取模」:中间可能夹着闰月,而闰月是否存在
|
||||
* 取决于年份,没有闭式公式。
|
||||
*
|
||||
* **推进时跳过闰月**:从六月推一个月得七月,不是闰六月。「每月十五」
|
||||
* 这类规则的用户期望是一年 12 次,把闰月算进去会让闰年多出一次提醒 ——
|
||||
* 那是农历年的性质,不是提醒的性质。
|
||||
*/
|
||||
export function addLunarMonths(d: LunarDate, n: number): LunarDate {
|
||||
let { year, month } = d;
|
||||
// 从闰月出发时先归到对应的正月份:闰六月 +1 → 七月
|
||||
if (month < 0) month = -month;
|
||||
for (let i = 0; i < n; i++) {
|
||||
month++;
|
||||
if (month > 12) {
|
||||
month = 1;
|
||||
year++;
|
||||
}
|
||||
}
|
||||
return { year, month, day: d.day };
|
||||
}
|
||||
|
||||
/**
|
||||
* 在农历上推进若干年,月份与日期保持不变。
|
||||
*
|
||||
* 从闰月出发而目标年没有同一个闰月时,退回对应的正月份 ——
|
||||
* 「去年闰六月十五」在今年最接近的对应日就是六月十五。
|
||||
* 直接放弃(不再提醒)更糟:那是静默地让重复事件消失。
|
||||
*/
|
||||
export function addLunarYears(d: LunarDate, n: number): LunarDate {
|
||||
const year = d.year + n;
|
||||
let month = d.month;
|
||||
if (month < 0 && leapMonth(year) !== -month) {
|
||||
month = -month;
|
||||
}
|
||||
return { year, month, day: d.day };
|
||||
}
|
||||
|
||||
/** 「二〇二六年七月廿二」这样的完整中文农历表示。 */
|
||||
export function formatLunarFull(d: LunarDate): string {
|
||||
const days = daysInMonth(d.year, d.month);
|
||||
if (days === 0) return `农历 ${d.year}-${d.month}-${d.day}(无效)`;
|
||||
const day = Math.min(d.day, days);
|
||||
try {
|
||||
return Lunar.fromYmd(d.year, d.month, day).toString();
|
||||
} catch {
|
||||
return `农历 ${d.year}-${d.month}-${d.day}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 只要月日的简短农历,如「七月廿二」。
|
||||
*
|
||||
* 日历格子里用这个:年份已经在页头写了,每格重复一遍挤不下也没意义。
|
||||
*/
|
||||
export function formatLunarShort(d: LunarDate): string {
|
||||
const full = formatLunarFull(d);
|
||||
// 「二〇二六年」= 4 个数字字 + 「年」
|
||||
const chars = Array.from(full);
|
||||
if (chars.length > 5 && chars[4] === '年') {
|
||||
return chars.slice(5).join('');
|
||||
}
|
||||
return full;
|
||||
}
|
||||
|
||||
/**
|
||||
* 农历日名,如 1 → 初一、20 → 二十、22 → 廿二、30 → 三十。
|
||||
*
|
||||
* 用查表而不是从 `Lunar.toString()` 里正则截取:实测日名有五种前缀形态
|
||||
* (初一/十一/二十/廿一/三十),写一个覆盖全部的正则既难读又容易漏 ——
|
||||
* 之前那版就漏了「二十」,20 号会退化成显示整串「七月二十」。
|
||||
*/
|
||||
const LUNAR_DAY_NAMES = [
|
||||
'', '初一', '初二', '初三', '初四', '初五', '初六', '初七', '初八', '初九', '初十',
|
||||
'十一', '十二', '十三', '十四', '十五', '十六', '十七', '十八', '十九', '二十',
|
||||
'廿一', '廿二', '廿三', '廿四', '廿五', '廿六', '廿七', '廿八', '廿九', '三十'
|
||||
];
|
||||
|
||||
export function lunarDayName(day: number): string {
|
||||
return LUNAR_DAY_NAMES[day] ?? String(day);
|
||||
}
|
||||
|
||||
/**
|
||||
* 日历格子里显示的农历标记:初一显示月名,其余显示日名。
|
||||
*
|
||||
* 每格都写完整「七月廿二」会让格子里全是重复的月份字样,而格子只有
|
||||
* 几十像素宽。月初那天写月名(如「七月」)就够定位了 —— 纸质日历的惯例。
|
||||
*/
|
||||
export function cellLunarLabel(date: Date): string {
|
||||
const d = fromSolar(date);
|
||||
if (d.day === 1) {
|
||||
// 初一:写月名。闰月要带「闰」字,否则闰六月与六月在格子里长得一样
|
||||
const leap = d.month < 0 ? '闰' : '';
|
||||
return `${leap}${LUNAR_MONTH_NAMES[Math.abs(d.month)] ?? Math.abs(d.month)}月`;
|
||||
}
|
||||
return lunarDayName(d.day);
|
||||
}
|
||||
|
||||
/** 农历月名。十一/十二月习惯写「冬月」「腊月」,与 lunar-javascript 一致。 */
|
||||
const LUNAR_MONTH_NAMES = [
|
||||
'', '正', '二', '三', '四', '五', '六', '七', '八', '九', '十', '冬', '腊'
|
||||
];
|
||||
|
||||
/** 公历 Date → 「2026-09-03(农历七月廿二)」。给提醒预览与详情用。 */
|
||||
export function formatSolarWithLunar(d: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const ymd = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
return `${ymd}(农历${formatLunarShort(fromSolar(d))})`;
|
||||
}
|
||||
|
||||
/** 是否是按农历推进的规则。 */
|
||||
export function isLunarRecurrence(r: string): boolean {
|
||||
return r === 'lunar_monthly' || r === 'lunar_yearly';
|
||||
}
|
||||
|
||||
/**
|
||||
* 算出下一次触发时刻。必须与后端 `repo.NextOccurrence` 行为一致 ——
|
||||
* 前端用它预览「接下来几次在哪天」,与实际触发不符比不预览更糟。
|
||||
*
|
||||
* 返回 null 表示不重复或算不出来。
|
||||
*/
|
||||
export function nextOccurrence(recurrence: string, from: Date): Date | null {
|
||||
switch (recurrence) {
|
||||
case 'daily':
|
||||
return shiftDays(from, 1);
|
||||
case 'weekly':
|
||||
return shiftDays(from, 7);
|
||||
case 'monthly':
|
||||
return addSolarMonthsClamped(from, 1);
|
||||
case 'yearly':
|
||||
return addSolarMonthsClamped(from, 12);
|
||||
case 'lunar_monthly': {
|
||||
const r = toSolar(
|
||||
addLunarMonths(fromSolar(from), 1),
|
||||
from.getHours(),
|
||||
from.getMinutes(),
|
||||
from.getSeconds()
|
||||
);
|
||||
return r ? r.date : null;
|
||||
}
|
||||
case 'lunar_yearly': {
|
||||
const r = toSolar(
|
||||
addLunarYears(fromSolar(from), 1),
|
||||
from.getHours(),
|
||||
from.getMinutes(),
|
||||
from.getSeconds()
|
||||
);
|
||||
return r ? r.date : null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function shiftDays(d: Date, n: number): Date {
|
||||
const x = new Date(d);
|
||||
x.setDate(x.getDate() + n);
|
||||
return x;
|
||||
}
|
||||
|
||||
/**
|
||||
* 公历加月份,日期夹到目标月的实际天数内。
|
||||
*
|
||||
* `setMonth` 的溢出行为(3 月 31 日 +1 月 = 5 月 1 日)对「每月同一日」
|
||||
* 是错的:31 日的事件在 2 月会变成 3 月 3 日,然后从此每月 3 日提醒 ——
|
||||
* 一次溢出永久改变了规则。
|
||||
*/
|
||||
export function addSolarMonthsClamped(d: Date, n: number): Date {
|
||||
const y = d.getFullYear();
|
||||
const m = d.getMonth() + n;
|
||||
// 目标月第 0 天 = 上个月最后一天,用它拿月长
|
||||
const last = new Date(y, m + 1, 0).getDate();
|
||||
const day = Math.min(d.getDate(), last);
|
||||
return new Date(y, m, day, d.getHours(), d.getMinutes(), d.getSeconds(), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览接下来 n 次触发。事件编辑器里用它让人确认规则没被理解错 ——
|
||||
* 农历规则的公历日期每次都在变,光看规则名分辨不出对不对。
|
||||
*/
|
||||
export function upcomingOccurrences(recurrence: string, from: Date, n = 3): Date[] {
|
||||
const out: Date[] = [];
|
||||
let cur = from;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const next = nextOccurrence(recurrence, cur);
|
||||
// 算不出来(例如目标年没有那个闰月)就停在这里,
|
||||
// 而不是跳过继续试 —— 后端的 AdvanceRecurrence 也会在这一步放弃。
|
||||
if (!next || next <= cur) break;
|
||||
out.push(next);
|
||||
cur = next;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 重复规则的人类可读标签。含农历时把农历日子写出来。 */
|
||||
export function describeRecurrenceRule(recurrence: string, eventTime?: Date): string {
|
||||
switch (recurrence) {
|
||||
case 'none':
|
||||
return '不重复';
|
||||
case 'daily':
|
||||
return '每天';
|
||||
case 'weekly':
|
||||
return '每周';
|
||||
case 'monthly':
|
||||
return '每月';
|
||||
case 'yearly':
|
||||
return '每年';
|
||||
case 'lunar_monthly':
|
||||
return eventTime
|
||||
? `每农历月${dayNameOf(eventTime)}`
|
||||
: '每农历月同一日';
|
||||
case 'lunar_yearly':
|
||||
return eventTime
|
||||
? `每年农历${formatLunarShort(fromSolar(eventTime))}`
|
||||
: '每农历年同月同日';
|
||||
default:
|
||||
return '不重复';
|
||||
}
|
||||
}
|
||||
|
||||
/** 取「廿二」这样的农历日名。 */
|
||||
function dayNameOf(d: Date): string {
|
||||
return lunarDayName(fromSolar(d).day);
|
||||
}
|
||||
178
client/electron/src/lib/mailGroups.ts
Normal file
178
client/electron/src/lib/mailGroups.ts
Normal file
@ -0,0 +1,178 @@
|
||||
import type { Mail } from '../types';
|
||||
|
||||
/**
|
||||
* 邮件列表的两种分组:收件箱按会话折叠,授权请求单独成项。
|
||||
*
|
||||
* 为什么需要它:`/me/mail/inbox` 返回的是平铺的邮件流(`ORDER BY created_at DESC`),
|
||||
* 而一次 Agent 任务会在同一会话里产生几十封邮件 —— 权限询问尤其密集,
|
||||
* 每个被拦下的 bash/write 都是一封。实测生产库里一个会话独占 17 封权限邮件,
|
||||
* 把整个收件箱挤满,另外两个会话的信被压到看不见的地方。
|
||||
*
|
||||
* 缺了分组的后果不是「不好看」,而是收件箱失去了它唯一的作用:
|
||||
* 让人知道「有哪几件事在等我」。17 行同一件事和 3 件不同的事,占的视觉权重一样。
|
||||
*/
|
||||
|
||||
/** 一个会话在列表里折叠成的一组。 */
|
||||
export interface MailGroup {
|
||||
sessionId: string;
|
||||
/** 会话别名,空串表示尚未命名 */
|
||||
alias: string;
|
||||
/** 组标题:取最新一封的主题(会话主题会随任务推进被改写,最新的那个最贴切) */
|
||||
subject: string;
|
||||
/** 最新一封,组头的摘要与时间都取自它 */
|
||||
latest: Mail;
|
||||
/** 组内全部邮件,时间倒序 */
|
||||
mails: Mail[];
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
function timeOf(m: Mail): number {
|
||||
const t = new Date(m.created_at).getTime();
|
||||
// created_at 解析失败时给 0 而不是 NaN:NaN 参与比较恒为 false,
|
||||
// 会让排序结果依赖于原数组顺序,表现为「刷新一次顺序就变了」
|
||||
return Number.isNaN(t) ? 0 : t;
|
||||
}
|
||||
|
||||
/** 时间倒序;同一时刻用 mail_id 兜底,与后端 `ORDER BY created_at DESC, mail_id DESC` 一致。 */
|
||||
function byNewest(a: Mail, b: Mail): number {
|
||||
const d = timeOf(b) - timeOf(a);
|
||||
return d !== 0 ? d : b.mail_id.localeCompare(a.mail_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 session_id 把邮件桶化。
|
||||
*
|
||||
* session_id 缺失的邮件(理论上不该有,但前端不该因为一条脏数据整栏空白)
|
||||
* 各自成组:用 mail_id 兜底键,保证它至少能被看见。
|
||||
*/
|
||||
function bucketBySession(mails: Mail[], keep: (m: Mail) => boolean): Map<string, Mail[]> {
|
||||
const bySession = new Map<string, Mail[]>();
|
||||
for (const m of mails) {
|
||||
if (!keep(m)) continue;
|
||||
const key = m.session_id || `mail:${m.mail_id}`;
|
||||
const bucket = bySession.get(key);
|
||||
if (bucket) bucket.push(m);
|
||||
else bySession.set(key, [m]);
|
||||
}
|
||||
return bySession;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把平铺的邮件流按 session_id 折叠成组,组之间按最新邮件时间倒序。
|
||||
*
|
||||
* 不修改入参;对同一输入永远给出同一输出(组内、组间都有确定的排序),
|
||||
* 因此可以直接在 render 里调用。
|
||||
*/
|
||||
export function groupMailsBySession(mails: Mail[]): MailGroup[] {
|
||||
const groups: MailGroup[] = [];
|
||||
|
||||
for (const [sessionId, bucket] of bucketBySession(mails, () => true)) {
|
||||
const sorted = [...bucket].sort(byNewest);
|
||||
const latest = sorted[0];
|
||||
groups.push({
|
||||
sessionId,
|
||||
alias: latest.session_alias || '',
|
||||
subject: latest.subject,
|
||||
latest,
|
||||
mails: sorted,
|
||||
unreadCount: sorted.filter(m => m.status === 'unread').length
|
||||
});
|
||||
}
|
||||
|
||||
return groups.sort((a, b) => byNewest(a.latest, b.latest));
|
||||
}
|
||||
|
||||
/**
|
||||
* 单封邮件的组不算「组」,平铺显示即可。
|
||||
*
|
||||
* 给一封孤立的邮件套上可折叠的组头会多一次点击才能读到内容,
|
||||
* 而收件箱里大多数人类来信就是孤立的一封。
|
||||
*/
|
||||
export function isFlatGroup(g: MailGroup): boolean {
|
||||
return g.mails.length === 1;
|
||||
}
|
||||
|
||||
/** 权限邮件且尚无决策结果。空串与 null 都算未决策(后端用 COALESCE 归一成空串)。 */
|
||||
export function isPendingPermission(m: Mail): boolean {
|
||||
return m.mail_type === 'permission_request' && !m.permission_result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把权限请求从普通邮件里分出来。
|
||||
*
|
||||
* 权限请求不是「一封信」而是「一件待办」:它的生命周期是「等人点头 → 决策完就作废」,
|
||||
* 而普通邮件是要读的内容。混在一个收件箱里两者互相伤害 ——
|
||||
* 一次 Agent 任务能产生十几个权限请求,把真正需要阅读的来信压到看不见的地方;
|
||||
* 反过来,人要找「有什么在等我批」也得在几十封信里翻。
|
||||
*
|
||||
* 所以它们各归各的导航项:收件箱只放要读的,授权项只放要批的。
|
||||
*/
|
||||
export function splitByPermission(mails: Mail[]): { normal: Mail[]; permissions: Mail[] } {
|
||||
const normal: Mail[] = [];
|
||||
const permissions: Mail[] = [];
|
||||
for (const m of mails) {
|
||||
if (m.mail_type === 'permission_request') permissions.push(m);
|
||||
else normal.push(m);
|
||||
}
|
||||
return { normal, permissions };
|
||||
}
|
||||
|
||||
/** 待决策的权限请求数 —— 授权项的徽标数字,也是「要人动手」的唯一信号。 */
|
||||
export function countPendingPermissions(mails: Mail[]): number {
|
||||
let n = 0;
|
||||
for (const m of mails) if (isPendingPermission(m)) n += 1;
|
||||
return n;
|
||||
}
|
||||
|
||||
/** 授权项里的一个会话分组:一级是会话,二级是该会话的授权请求。 */
|
||||
export interface PermissionGroup {
|
||||
sessionId: string;
|
||||
alias: string;
|
||||
/** 发起请求的 Agent(权限请求一定由 Agent 发出) */
|
||||
agentName: string;
|
||||
/** Agent 的工作目录,同名 Agent 在不同目录是不同的活 */
|
||||
path: string;
|
||||
/** 还等着人点头的,时间倒序 */
|
||||
pending: Mail[];
|
||||
/** 已决策的历史记录,时间倒序 */
|
||||
settled: Mail[];
|
||||
/** 组内最新一封,组头时间取自它 */
|
||||
latest: Mail;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把权限请求按会话折成组,**有待决策的会话永远排在前面**。
|
||||
*
|
||||
* 排序判据不是时间而是「要不要我动手」:一个三天前发起、至今还卡着的授权请求
|
||||
* 比十分钟前刚批完的那条重要得多。纯按时间排会把它压到列表底部,
|
||||
* 而 Agent 那条会话正在那儿等着 —— 这正是权限死锁在 UI 上的样子。
|
||||
*/
|
||||
export function groupPermissions(mails: Mail[]): PermissionGroup[] {
|
||||
const groups: PermissionGroup[] = [];
|
||||
|
||||
for (const [sessionId, bucket] of bucketBySession(
|
||||
mails,
|
||||
m => m.mail_type === 'permission_request'
|
||||
)) {
|
||||
const sorted = [...bucket].sort(byNewest);
|
||||
const latest = sorted[0];
|
||||
groups.push({
|
||||
sessionId,
|
||||
alias: latest.session_alias || '',
|
||||
agentName: latest.from_name,
|
||||
path: latest.session_workspace || '',
|
||||
pending: sorted.filter(isPendingPermission),
|
||||
settled: sorted.filter(m => !isPendingPermission(m)),
|
||||
latest
|
||||
});
|
||||
}
|
||||
|
||||
return groups.sort((a, b) => {
|
||||
// 有待决策的先来;组内待决策数多的更靠前(那条会话卡得更久)
|
||||
if ((a.pending.length > 0) !== (b.pending.length > 0)) {
|
||||
return a.pending.length > 0 ? -1 : 1;
|
||||
}
|
||||
if (a.pending.length !== b.pending.length) return b.pending.length - a.pending.length;
|
||||
return byNewest(a.latest, b.latest);
|
||||
});
|
||||
}
|
||||
214
client/electron/src/lib/replyTarget.ts
Normal file
214
client/electron/src/lib/replyTarget.ts
Normal file
@ -0,0 +1,214 @@
|
||||
import type { Mail, Session } from '../types';
|
||||
|
||||
/**
|
||||
* 「这封回复该发给谁」。
|
||||
*
|
||||
* 原先这件事被写成一行三元表达式,判据是 `from_name === 'human'` ——
|
||||
* 那是多用户认证之前的遗留:当时人类只有一个身份 `human@`。改成多用户后
|
||||
* `users.username` 与 `agents.agent_name` 共用命名空间,登录名可能是 `jianf`,
|
||||
* 于是判据恒为假,回复对端就取成了 `from_name`(也就是自己)。
|
||||
*
|
||||
* 后果是**信发给了自己**:在会话视图里回复时尤其必然发生 —— 那里的锚点是
|
||||
* 「最后一封」,而最后一封常常就是自己刚发的那封。生产实测链条:
|
||||
* pi → jianf 权限请求
|
||||
* jianf → pi Re: 权限请求(对的,因为锚点是 pi 发来的)
|
||||
* pi → jianf 权限请求
|
||||
* jianf → jianf Re: Re: 权限请求(错的,锚点是自己发的)
|
||||
*
|
||||
* 更根本的问题是**判据本身选错了**。会话视图的语义是「跟这个 Agent 的一次
|
||||
* 任务」,人在这里打字就是「给对方追加一句」;对端是**会话的属性**,
|
||||
* 不该由「最后一封是谁发的」这种偶然状态决定。所以会话视图用
|
||||
* `sessionCounterpart` 扫全会话定对端,只有单封邮件视图才用 `mailCounterpart`。
|
||||
*/
|
||||
|
||||
/** 一个可投递的对端。 */
|
||||
export interface Counterpart {
|
||||
name: string;
|
||||
/** 工作目录,可能为空(人类没有工作目录) */
|
||||
path: string;
|
||||
/** 这一方是人类用户还是 Agent —— 决定地址拼几段 */
|
||||
isHuman: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼三维地址 `name@path.session`。**逐字节对齐后端 `models.FormatAddress`。**
|
||||
*
|
||||
* 三个分支缺一不可:
|
||||
*
|
||||
* session 为空 + path 为空 → `jianf` (裸名字 = 默认会话)
|
||||
* session 为空 + 有 path → `pi@/home`
|
||||
* session 非空 → `pi@/home.任务` / `jianf@.任务`
|
||||
*
|
||||
* **最后一个分支在 path 为空时仍要保留 `@`。**
|
||||
* 地址按**最后一个 `.`** 切分:`jianf@.任务` 能正确还原成
|
||||
* name=`jianf` / path=`` / session=`任务`,而漏掉 `@` 的 `jianf.任务`
|
||||
* 会被整串当成**名字**(实测 ParseAddress 返回 name="jianf.任务")——
|
||||
* 那是个不存在的 Agent,投递必然 404。
|
||||
*
|
||||
* 人类没有工作目录,所以 path 为空是界面上的常态而非边界情形:
|
||||
* 给人类回信时若丢掉 `@`,整条地址就废了。
|
||||
*
|
||||
* 展示用途请走 `participantAddress` —— 它按「人 / Agent」决定带几段。
|
||||
*/
|
||||
export function formatAddress(name: string, path: string, session?: string | null): string {
|
||||
const n = (name || '').trim();
|
||||
if (!n) return '';
|
||||
const p = (path || '').trim();
|
||||
const s = (session || '').trim();
|
||||
if (!s) return p ? `${n}@${p}` : n;
|
||||
return `${n}@${p}.${s}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个参与方在**这封邮件所属会话**里的完整地址。
|
||||
*
|
||||
* # 人与 Agent 的地址维度不同
|
||||
*
|
||||
* **Agent 要三段**:`name@path.session` 才唯一确定「哪个 Agent、在哪个目录、
|
||||
* 哪条线索」。同名 Agent 在不同目录是不同的活,同一目录下不同会话是不同的任务
|
||||
* —— 少任何一段都不是个可投递的地址。
|
||||
*
|
||||
* **人只要名字**:人没有工作目录,也不需要指定会话(发给人就是进他的收件箱)。
|
||||
* 给人拼 `jianf@.某会话` 或 `jianf.某会话` 都是把 Agent 的维度硬套在人身上。
|
||||
*
|
||||
* 判据从「workspace 是否为空」的启发式改成**显式布尔**:
|
||||
* `mails.from_workspace` 对 Agent 存的是 Agent 名而不是路径(历史遗留),
|
||||
* 拿它当「是不是 Agent」的代理变量会在边界上猜错。
|
||||
* 服务端用 `EXISTS (SELECT 1 FROM users …)` 判人/Agent,那条布尔才是权威。
|
||||
*
|
||||
* workspace 必须从**会话**取(`session_workspace`),不能用 from_workspace:
|
||||
* 后者对 Agent 存的是 Agent 名,拿它拼会得到 `dsh@dsh`。
|
||||
*/
|
||||
export function participantAddress(
|
||||
name: string,
|
||||
isHuman: boolean,
|
||||
workspace?: string | null,
|
||||
sessionAlias?: string | null
|
||||
): string {
|
||||
// 人(无工作目录):只有名字,不带 path 也不带会话位
|
||||
if (isHuman) return formatAddress(name, '');
|
||||
return formatAddress(name, (workspace || '').trim(), sessionAlias || null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单封邮件的对端:我发的就回给收件人,别人发的就回给发件人。
|
||||
*
|
||||
* `me` 必须是当前登录用户名。传空串时退化为「回给发件人」——
|
||||
* 那比回给自己安全:最坏的情况是回错人,而不是把信发进虚空。
|
||||
*/
|
||||
export function mailCounterpart(mail: Mail, me: string): Counterpart {
|
||||
const iSent = !!me && mail.from_name === me;
|
||||
const ws = mail.session_workspace || '';
|
||||
if (iSent) {
|
||||
return { name: mail.to_name, path: mail.to_human ? '' : ws, isHuman: mail.to_human };
|
||||
}
|
||||
return { name: mail.from_name, path: mail.from_human ? '' : ws, isHuman: mail.from_human };
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话的对端:扫全会话找第一个不是我的参与方。
|
||||
*
|
||||
* 为什么扫全会话而不看某一封:会话视图里人的意图是「给这次任务的对方追加一句」,
|
||||
* 对端是会话的属性。只看最后一封时,自己刚发过信就会把自己算成对端。
|
||||
*
|
||||
* 按时间正序扫,取**首个**非我参与方 —— 会话的发起对象就是这次任务的主体,
|
||||
* 后来被抄送进来的第三方不该抢走这个位置。同名参与方保留**首个非空 path**:
|
||||
* 同名 Agent 在不同目录是不同的活,而某些邮件的 workspace 字段可能为空。
|
||||
*/
|
||||
export function sessionCounterpart(mails: Mail[], me: string): Counterpart | null {
|
||||
if (!mails.length) return null;
|
||||
|
||||
// 与后端 `ORDER BY created_at ASC, mail_id ASC` 一致:
|
||||
// SQLite 时间戳精度有限,同刻插入的多封靠 mail_id 定序
|
||||
const sorted = [...mails].sort((a, b) => {
|
||||
const ta = new Date(a.created_at).getTime();
|
||||
const tb = new Date(b.created_at).getTime();
|
||||
const na = Number.isNaN(ta) ? 0 : ta;
|
||||
const nb = Number.isNaN(tb) ? 0 : tb;
|
||||
return na !== nb ? na - nb : a.mail_id.localeCompare(b.mail_id);
|
||||
});
|
||||
|
||||
const ws = (m: Mail) => m.session_workspace || '';
|
||||
let found: Counterpart | null = null;
|
||||
for (const m of sorted) {
|
||||
// 收件人优先于发件人:会话首封多是「我 → Agent」,
|
||||
// 那个 to_name 就是这次任务派给了谁
|
||||
const candidates: Counterpart[] = [
|
||||
{ name: m.to_name, path: m.to_human ? '' : ws(m), isHuman: m.to_human },
|
||||
{ name: m.from_name, path: m.from_human ? '' : ws(m), isHuman: m.from_human }
|
||||
];
|
||||
for (const c of candidates) {
|
||||
if (!c.name || c.name === me) continue;
|
||||
if (!found) {
|
||||
found = c;
|
||||
} else if (found.name === c.name && !found.path && c.path) {
|
||||
// 补上首次出现时缺失的 path
|
||||
found = c;
|
||||
}
|
||||
if (found.path) return found;
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话视图的回复目标地址。
|
||||
*
|
||||
* 会话别名必须带上:不带就落到该 Agent 的**默认会话**,
|
||||
* 而人明明是在某条具体线索里打字 —— 那会让追加的一句跑到另一条任务里去。
|
||||
*/
|
||||
export function sessionReplyTarget(
|
||||
mails: Mail[],
|
||||
session: Session | null,
|
||||
me: string
|
||||
): string {
|
||||
const peer = sessionCounterpart(mails, me);
|
||||
if (!peer) return '';
|
||||
return formatAddress(peer.name, peer.path, session?.session_alias || null);
|
||||
}
|
||||
|
||||
/** 单封邮件视图的回复目标地址。 */
|
||||
export function mailReplyTarget(mail: Mail, me: string): string {
|
||||
const peer = mailCounterpart(mail, me);
|
||||
return formatAddress(peer.name, peer.path, mail.session_alias || null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 「回复全部」的抄送清单:会话/邮件的其他参与方,去掉自己与主收件人。
|
||||
*
|
||||
* 原先用 `!a.startsWith('human')` 去掉自己 —— 同一个遗留判据,
|
||||
* 结果是点「回复全部」会把自己抄送进去。
|
||||
*
|
||||
* 地址拼法与 participantAddress 一致:人只有名字,Agent 拼 `name@path.session`。
|
||||
* path 从 session_workspace 取,不能从 from_workspace/to_workspace 取
|
||||
* (后者对 Agent 存的是 Agent 名)。
|
||||
*/
|
||||
export function replyAllCC(
|
||||
mail: Mail,
|
||||
me: string,
|
||||
primaryName: string
|
||||
): string[] {
|
||||
const ws = mail.session_workspace || '';
|
||||
const raw = [
|
||||
formatAddress(mail.from_name, mail.from_human ? '' : ws),
|
||||
formatAddress(mail.to_name, mail.to_human ? '' : ws),
|
||||
// cc_list 使用 raw 字段(用户输入的原文,保留 .new 等原始意图)
|
||||
...(mail.cc_list ?? []).map(c => c.raw || formatAddress(c.name, c.path || '', c.session || null))
|
||||
];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const addr of raw) {
|
||||
if (!addr) continue;
|
||||
const name = addr.split('@')[0];
|
||||
// 自己收不到自己的信没意义;主收件人已经在 to 里
|
||||
if (me && name === me) continue;
|
||||
if (primaryName && name === primaryName) continue;
|
||||
if (seen.has(addr)) continue;
|
||||
seen.add(addr);
|
||||
out.push(addr);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
44
client/electron/src/main.tsx
Normal file
44
client/electron/src/main.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import { initTheme } from './stores/themeStore';
|
||||
import './index.css';
|
||||
|
||||
// 必须在 render 之前:晚一步就会让深色偏好的用户看到一帧白色闪屏。
|
||||
// index.html 里还有一段更早的内联脚本处理「JS bundle 到达前」那段空窗,
|
||||
// 这里做的是把 store 状态与 DOM 对齐并订阅系统主题变化。
|
||||
initTheme();
|
||||
|
||||
/**
|
||||
* 用 Visual Viewport 驱动应用高度。
|
||||
*
|
||||
* 手机软键盘通常只缩小「可见视口」,不缩小传统 100%/100vh;如果外壳还按
|
||||
* 布局视口排版,正文和发送按钮就会落到键盘后面。dvh 是 CSS 兜底,这个变量
|
||||
* 处理 iOS WebView 与部分旧 Chromium 对动态视口更新不及时的情况。
|
||||
*/
|
||||
const syncVisibleViewport = () => {
|
||||
const viewport = window.visualViewport;
|
||||
const height = viewport?.height ?? window.innerHeight;
|
||||
document.documentElement.style.setProperty('--app-height', `${Math.round(height)}px`);
|
||||
};
|
||||
|
||||
syncVisibleViewport();
|
||||
window.addEventListener('resize', syncVisibleViewport, { passive: true });
|
||||
window.visualViewport?.addEventListener('resize', syncVisibleViewport, { passive: true });
|
||||
window.visualViewport?.addEventListener('scroll', syncVisibleViewport, { passive: true });
|
||||
|
||||
// 编辑表单时隐藏窄屏底栏,把有限的键盘上方空间留给正文与操作按钮。
|
||||
// focusout 要延后一帧:从一个输入框切到另一个时 activeElement 会短暂回到 body。
|
||||
const syncEditingState = () => {
|
||||
const el = document.activeElement;
|
||||
const editing = el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement;
|
||||
document.documentElement.classList.toggle('form-editing', editing);
|
||||
};
|
||||
document.addEventListener('focusin', syncEditingState);
|
||||
document.addEventListener('focusout', () => requestAnimationFrame(syncEditingState));
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
70
client/electron/src/stores/authStore.ts
Normal file
70
client/electron/src/stores/authStore.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { create } from 'zustand';
|
||||
import type { User } from '../types';
|
||||
import * as api from '../api/client';
|
||||
import { ApiError } from '../api/client';
|
||||
|
||||
type Phase = 'checking' | 'anonymous' | 'authenticated';
|
||||
|
||||
interface AuthState {
|
||||
phase: Phase;
|
||||
user: User | null;
|
||||
error: string | null;
|
||||
retryAfter: number | null;
|
||||
submitting: boolean;
|
||||
|
||||
bootstrap: () => Promise<void>;
|
||||
login: (username: string, password: string) => Promise<boolean>;
|
||||
logout: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
/** 401 时由 api client 回调 */
|
||||
markAnonymous: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>(set => ({
|
||||
phase: 'checking',
|
||||
user: null,
|
||||
error: null,
|
||||
retryAfter: null,
|
||||
submitting: false,
|
||||
|
||||
bootstrap: async () => {
|
||||
try {
|
||||
const { user } = await api.me();
|
||||
set({ phase: 'authenticated', user, error: null });
|
||||
} catch {
|
||||
set({ phase: 'anonymous', user: null });
|
||||
}
|
||||
},
|
||||
|
||||
login: async (username, password) => {
|
||||
set({ submitting: true, error: null, retryAfter: null });
|
||||
try {
|
||||
const { user } = await api.login(username.trim(), password);
|
||||
set({ phase: 'authenticated', user, submitting: false });
|
||||
return true;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const retry = err instanceof ApiError ? err.retryAfter ?? null : null;
|
||||
set({ error: msg, retryAfter: retry, submitting: false });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
try {
|
||||
await api.logout();
|
||||
} catch {
|
||||
/* 即使请求失败也在前端登出 */
|
||||
}
|
||||
set({ phase: 'anonymous', user: null, error: null });
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null, retryAfter: null }),
|
||||
|
||||
markAnonymous: () => set({ phase: 'anonymous', user: null })
|
||||
}));
|
||||
|
||||
// 注册全局 401 处理:任何接口返回 401 即回到登录页
|
||||
api.setUnauthorizedHandler(() => {
|
||||
useAuthStore.getState().markAnonymous();
|
||||
});
|
||||
113
client/electron/src/stores/contactStore.ts
Normal file
113
client/electron/src/stores/contactStore.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Contact } from '../types';
|
||||
import * as api from '../api/client';
|
||||
|
||||
/** 中间栏的呈现方式:列表(紧凑,答"跟谁在聊")或卡片(答"在聊什么、进展如何") */
|
||||
export type ContactView = 'list' | 'card';
|
||||
|
||||
const VIEW_KEY = 'agentmail.contactView';
|
||||
|
||||
/**
|
||||
* 视图偏好存 localStorage。
|
||||
*
|
||||
* 这是纯展示偏好,不值得为它建一张表、加一个 API —— 而每次刷新都退回默认视图
|
||||
* 会让人反复点同一个按钮。读取时容错:localStorage 在隐私模式下可能抛异常。
|
||||
*/
|
||||
function loadView(): ContactView {
|
||||
try {
|
||||
return localStorage.getItem(VIEW_KEY) === 'card' ? 'card' : 'list';
|
||||
} catch {
|
||||
return 'list';
|
||||
}
|
||||
}
|
||||
|
||||
interface ContactState {
|
||||
contacts: Contact[];
|
||||
archivedContacts: Contact[];
|
||||
showArchived: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
/** 正在等待归档确认的地址 */
|
||||
pendingArchive: string | null;
|
||||
/** 中间栏呈现方式(持久化到 localStorage) */
|
||||
view: ContactView;
|
||||
setView: (v: ContactView) => void;
|
||||
|
||||
fetchContacts: () => Promise<void>;
|
||||
fetchArchived: () => Promise<void>;
|
||||
toggleArchivedView: () => void;
|
||||
requestArchive: (address: string) => void;
|
||||
cancelArchive: () => void;
|
||||
archive: (contact: Contact) => Promise<void>;
|
||||
/** SSE session_archived 到达时本地即时移除 */
|
||||
removeSessionLocally: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
export const useContactStore = create<ContactState>((set, get) => ({
|
||||
contacts: [],
|
||||
archivedContacts: [],
|
||||
showArchived: false,
|
||||
loading: false,
|
||||
error: null,
|
||||
pendingArchive: null,
|
||||
view: loadView(),
|
||||
|
||||
setView: v => {
|
||||
set({ view: v });
|
||||
try {
|
||||
localStorage.setItem(VIEW_KEY, v);
|
||||
} catch {
|
||||
/* 存不下只是下次回到默认视图,不该让切换本身失败 */
|
||||
}
|
||||
},
|
||||
|
||||
fetchContacts: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const { contacts } = await api.listContacts(false);
|
||||
set({ contacts: contacts || [], loading: false });
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err), loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
fetchArchived: async () => {
|
||||
try {
|
||||
const { contacts } = await api.listContacts(true);
|
||||
set({ archivedContacts: contacts || [] });
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
|
||||
toggleArchivedView: () => {
|
||||
const next = !get().showArchived;
|
||||
set({ showArchived: next });
|
||||
if (next) get().fetchArchived();
|
||||
},
|
||||
|
||||
requestArchive: address => set({ pendingArchive: address }),
|
||||
cancelArchive: () => set({ pendingArchive: null }),
|
||||
|
||||
archive: async contact => {
|
||||
try {
|
||||
await api.archiveContact({ session_id: contact.session_id });
|
||||
// 本地即时移除,不等 SSE
|
||||
set(state => ({
|
||||
contacts: state.contacts.filter(c => c.session_id !== contact.session_id),
|
||||
pendingArchive: null
|
||||
}));
|
||||
if (get().showArchived) get().fetchArchived();
|
||||
} catch (err) {
|
||||
set({
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
pendingArchive: null
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
removeSessionLocally: sessionId =>
|
||||
set(state => ({
|
||||
contacts: state.contacts.filter(c => c.session_id !== sessionId)
|
||||
}))
|
||||
}));
|
||||
91
client/electron/src/stores/mailStore.ts
Normal file
91
client/electron/src/stores/mailStore.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Mail } from '../types';
|
||||
import * as api from '../api/client';
|
||||
|
||||
interface MailState {
|
||||
inbox: Mail[];
|
||||
sent: Mail[];
|
||||
currentMail: Mail | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
fetchInbox: (status?: string) => Promise<void>;
|
||||
fetchSent: () => Promise<void>;
|
||||
selectMail: (mail: Mail) => void;
|
||||
/**
|
||||
* 按 id 打开邮件。
|
||||
*
|
||||
* 列表与对话树里的邮件只带正文预览(整棵树带全文可能几百 KB),
|
||||
* 所以点开时得单取一次拿全文与附件清单。
|
||||
*/
|
||||
openMailByID: (id: string) => Promise<void>;
|
||||
clearCurrentMail: () => void;
|
||||
markRead: (id: string) => Promise<void>;
|
||||
/** 某会话归档后,把它的邮件从列表与选中态里剔除 */
|
||||
dropSession: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
export const useMailStore = create<MailState>((set, get) => ({
|
||||
inbox: [],
|
||||
sent: [],
|
||||
currentMail: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
fetchInbox: async (status = 'all') => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const { mails } = await api.getInbox(status);
|
||||
set({ inbox: mails || [], loading: false });
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err), loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
fetchSent: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const { mails } = await api.getSent();
|
||||
set({ sent: mails || [], loading: false });
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err), loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
selectMail: mail => set({ currentMail: mail }),
|
||||
|
||||
openMailByID: async id => {
|
||||
try {
|
||||
const mail = await api.getMail(id);
|
||||
set({ currentMail: mail });
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
|
||||
clearCurrentMail: () => set({ currentMail: null }),
|
||||
|
||||
markRead: async id => {
|
||||
try {
|
||||
await api.markMailRead(id);
|
||||
set(state => ({
|
||||
inbox: state.inbox.map(m => (m.mail_id === id ? { ...m, status: 'read' as const } : m)),
|
||||
currentMail:
|
||||
state.currentMail?.mail_id === id
|
||||
? { ...state.currentMail, status: 'read' as const }
|
||||
: state.currentMail
|
||||
}));
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
|
||||
dropSession: sessionId => {
|
||||
const { currentMail } = get();
|
||||
set(state => ({
|
||||
inbox: state.inbox.filter(m => m.session_id !== sessionId),
|
||||
sent: state.sent.filter(m => m.session_id !== sessionId),
|
||||
currentMail: currentMail?.session_id === sessionId ? null : currentMail
|
||||
}));
|
||||
}
|
||||
}));
|
||||
168
client/electron/src/stores/sessionStore.ts
Normal file
168
client/electron/src/stores/sessionStore.ts
Normal file
@ -0,0 +1,168 @@
|
||||
import { create } from 'zustand';
|
||||
import type { HumanSession, Mail, RenameProposal, Session, SessionBudget } from '../types';
|
||||
import * as api from '../api/client';
|
||||
|
||||
interface SessionState {
|
||||
sessions: HumanSession[];
|
||||
currentSession: Session | null;
|
||||
currentSessionMails: Mail[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
/** Agent 在正文里提的改名建议;null = 无待处理建议 */
|
||||
renameProposal: RenameProposal | null;
|
||||
|
||||
fetchSessions: () => Promise<void>;
|
||||
selectSession: (sessionId: string) => Promise<void>;
|
||||
clearSession: () => void;
|
||||
/** 重新拉取当前会话的改名建议(收到新邮件时调) */
|
||||
refreshRenameProposal: () => Promise<void>;
|
||||
/** 接受建议:改名成功后清掉提示条并刷新会话 */
|
||||
acceptRename: () => Promise<void>;
|
||||
/** 驳回建议:服务端记下来,不再反复弹同一个 */
|
||||
dismissRename: () => Promise<void>;
|
||||
|
||||
/** 本任务的往返预算;null = 尚未取到 */
|
||||
budget: SessionBudget | null;
|
||||
/** 改本会话预算(对话页里随时调)。reset 把已用次数归零。 */
|
||||
/** 改本会话预算(对话页里随时调)。reset 把已用次数归零。 */
|
||||
setBudget: (patch: { max_rounds?: number; reset?: boolean }) => Promise<void>;
|
||||
/** 改本会话权限档位(对话页里随时调)。人是权限的源头,可以任改三档。 */
|
||||
setPermissionMode: (mode: string) => Promise<void>;
|
||||
/** 重新拉取预算(Agent 发信后剩余会变) */
|
||||
refreshBudget: () => Promise<void>;
|
||||
/** 归档后若正查看该会话则退出 */
|
||||
dropSessionIfCurrent: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
export const useSessionStore = create<SessionState>((set, get) => ({
|
||||
sessions: [],
|
||||
currentSession: null,
|
||||
currentSessionMails: [],
|
||||
renameProposal: null,
|
||||
budget: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
fetchSessions: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const { sessions } = await api.getHumanSessions();
|
||||
set({ sessions: sessions || [], loading: false });
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err), loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
selectSession: async sessionId => {
|
||||
set({ loading: true, error: null, renameProposal: null, budget: null });
|
||||
try {
|
||||
const detail = await api.getSessionDetail(sessionId);
|
||||
set({
|
||||
currentSession: detail.session,
|
||||
currentSessionMails: detail.mails || [],
|
||||
loading: false
|
||||
});
|
||||
// 改名建议与预算单独取:拿不到不该让整个会话打不开
|
||||
get().refreshRenameProposal();
|
||||
get().refreshBudget();
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err), loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
refreshRenameProposal: async () => {
|
||||
const id = get().currentSession?.session_id;
|
||||
if (!id) return;
|
||||
try {
|
||||
const { proposal } = await api.getRenameProposal(id);
|
||||
// 期间用户可能已切走,别把上一会话的建议贴到新会话上
|
||||
if (get().currentSession?.session_id === id) {
|
||||
set({ renameProposal: proposal });
|
||||
}
|
||||
} catch {
|
||||
// 建议是锦上添花,失败静默
|
||||
}
|
||||
},
|
||||
|
||||
acceptRename: async () => {
|
||||
const s = get();
|
||||
const id = s.currentSession?.session_id;
|
||||
const alias = s.renameProposal?.alias;
|
||||
if (!id || !alias) return;
|
||||
try {
|
||||
await api.updateSessionAlias(id, alias);
|
||||
set({ renameProposal: null });
|
||||
// 别名变了,会话详情与列表里的地址都要跟着更新
|
||||
await get().selectSession(id);
|
||||
await get().fetchSessions();
|
||||
} catch (err) {
|
||||
// 别名被别人占用(409)等情况要让用户看到,不能默默失败
|
||||
set({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
|
||||
dismissRename: async () => {
|
||||
const id = get().currentSession?.session_id;
|
||||
if (!id) return;
|
||||
// 先隐藏提示条再发请求:驳回是纯本地意图,等一个往返才消失显得迟钝
|
||||
set({ renameProposal: null });
|
||||
try {
|
||||
await api.dismissRenameProposal(id);
|
||||
} catch {
|
||||
// 记不下来最坏的后果是下次打开又弹一次,不值得打扰用户
|
||||
}
|
||||
},
|
||||
|
||||
refreshBudget: async () => {
|
||||
const id = get().currentSession?.session_id;
|
||||
if (!id) return;
|
||||
try {
|
||||
const b = await api.getSessionBudget(id);
|
||||
// 期间用户可能已切走,别把上一会话的预算贴到新会话上
|
||||
if (get().currentSession?.session_id === id) set({ budget: b });
|
||||
} catch {
|
||||
// 预算读不到不影响看邮件,静默
|
||||
}
|
||||
},
|
||||
|
||||
setBudget: async patch => {
|
||||
const id = get().currentSession?.session_id;
|
||||
if (!id) return;
|
||||
try {
|
||||
const b = await api.updateSessionBudget(id, patch);
|
||||
set({ budget: b });
|
||||
// 列表里也显示预算,改完要跟着刷新
|
||||
await get().fetchSessions();
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
|
||||
setPermissionMode: async mode => {
|
||||
const id = get().currentSession?.session_id;
|
||||
if (!id) return;
|
||||
try {
|
||||
const res = await api.updateSessionPermission(id, mode);
|
||||
const cur = get().currentSession;
|
||||
if (cur && cur.session_id === id) {
|
||||
set({ currentSession: { ...cur, permission_mode: res.permission_mode, permission_enforcement: res.permission_enforcement } });
|
||||
}
|
||||
await get().fetchSessions();
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
|
||||
clearSession: () =>
|
||||
set({ currentSession: null, currentSessionMails: [], renameProposal: null, budget: null }),
|
||||
|
||||
dropSessionIfCurrent: sessionId => {
|
||||
if (get().currentSession?.session_id === sessionId) {
|
||||
set({ currentSession: null, currentSessionMails: [], renameProposal: null, budget: null });
|
||||
}
|
||||
set(state => ({
|
||||
sessions: state.sessions.filter(s => s.session_id !== sessionId)
|
||||
}));
|
||||
}
|
||||
}));
|
||||
114
client/electron/src/stores/themeStore.ts
Normal file
114
client/electron/src/stores/themeStore.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
/**
|
||||
* 主题偏好。
|
||||
*
|
||||
* 三态而不是「开/关」:`system` 是有意义的第三个值,不是 light 的别名。
|
||||
* 只给开关的话,用户在白天设成浅色之后,晚上系统切深色时应用不会跟着变 ——
|
||||
* 而那恰恰是大多数人想要的默认行为。
|
||||
*/
|
||||
export type ThemePref = 'light' | 'dark' | 'system';
|
||||
|
||||
/** 实际生效的主题(system 解析之后的结果)。 */
|
||||
export type ResolvedTheme = 'light' | 'dark';
|
||||
|
||||
const STORAGE_KEY = 'agentmail.theme';
|
||||
|
||||
const DARK_QUERY = '(prefers-color-scheme: dark)';
|
||||
|
||||
function readStored(): ThemePref {
|
||||
try {
|
||||
const v = localStorage.getItem(STORAGE_KEY);
|
||||
if (v === 'light' || v === 'dark' || v === 'system') return v;
|
||||
} catch {
|
||||
// 隐私模式下 localStorage 抛异常。跟随系统是最安全的退路 ——
|
||||
// 硬编码 light 会让深色偏好的用户每次开页面都被闪一下白屏
|
||||
}
|
||||
return 'system';
|
||||
}
|
||||
|
||||
function systemPrefersDark(): boolean {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return false;
|
||||
return window.matchMedia(DARK_QUERY).matches;
|
||||
}
|
||||
|
||||
export function resolveTheme(pref: ThemePref): ResolvedTheme {
|
||||
if (pref === 'system') return systemPrefersDark() ? 'dark' : 'light';
|
||||
return pref;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把主题写进 DOM。
|
||||
*
|
||||
* 类名挂在 `<html>` 而不是 `<body>`:tailwind 的 darkMode:'class' 默认
|
||||
* 从根元素找,而且 `<html>` 上的 background-color 才管得到 overscroll
|
||||
* 露出的那一片。
|
||||
*/
|
||||
function apply(resolved: ResolvedTheme) {
|
||||
if (typeof document === 'undefined') return;
|
||||
const root = document.documentElement;
|
||||
root.classList.toggle('dark', resolved === 'dark');
|
||||
// 让浏览器把滚动条、表单控件、autofill 背景一并切换。
|
||||
// 不设的话深色页面上会出现一条浅色滚动条与白底的自动填充输入框。
|
||||
root.style.colorScheme = resolved;
|
||||
}
|
||||
|
||||
interface ThemeState {
|
||||
pref: ThemePref;
|
||||
resolved: ResolvedTheme;
|
||||
setPref: (p: ThemePref) => void;
|
||||
/** 在 light / dark 间直接翻转(顶栏那个按钮用)。 */
|
||||
toggle: () => void;
|
||||
}
|
||||
|
||||
export const useThemeStore = create<ThemeState>((set, get) => ({
|
||||
pref: readStored(),
|
||||
resolved: resolveTheme(readStored()),
|
||||
|
||||
setPref: p => {
|
||||
const resolved = resolveTheme(p);
|
||||
apply(resolved);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, p);
|
||||
} catch {
|
||||
// 存不下不影响本次会话
|
||||
}
|
||||
set({ pref: p, resolved });
|
||||
},
|
||||
|
||||
/**
|
||||
* 翻转。
|
||||
*
|
||||
* 从 `system` 翻转时落到「与当前生效值相反」的显式值,而不是回到
|
||||
* system —— 人点这个按钮的意图是「现在换个样子」,把它变成
|
||||
* system→light(可能毫无变化)会让按钮看起来坏了。
|
||||
*/
|
||||
toggle: () => {
|
||||
const next: ThemePref = get().resolved === 'dark' ? 'light' : 'dark';
|
||||
get().setPref(next);
|
||||
}
|
||||
}));
|
||||
|
||||
/**
|
||||
* 启动时立刻套用主题,并订阅系统变化。
|
||||
*
|
||||
* 在 main.tsx 里于 render 之前调用:晚一步就会让深色偏好的用户
|
||||
* 看到一帧白色闪屏。
|
||||
*
|
||||
* 返回取消订阅函数(实际不会用到 —— 应用生命周期内一直需要监听)。
|
||||
*/
|
||||
export function initTheme(): () => void {
|
||||
const store = useThemeStore.getState();
|
||||
apply(store.resolved);
|
||||
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return () => {};
|
||||
const mq = window.matchMedia(DARK_QUERY);
|
||||
const onChange = () => {
|
||||
// 只有 pref 为 system 时才跟随系统。显式选了 light/dark 的人
|
||||
// 不该因为日落而被切换主题。
|
||||
const { pref, setPref } = useThemeStore.getState();
|
||||
if (pref === 'system') setPref('system');
|
||||
};
|
||||
mq.addEventListener('change', onChange);
|
||||
return () => mq.removeEventListener('change', onChange);
|
||||
}
|
||||
61
client/electron/src/stores/uiStore.ts
Normal file
61
client/electron/src/stores/uiStore.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export type ViewMode = 'inbox' | 'sent' | 'permissions' | 'calendar' | 'contacts' | 'admin' | 'account';
|
||||
|
||||
interface UIState {
|
||||
viewMode: ViewMode;
|
||||
setViewMode: (mode: ViewMode) => void;
|
||||
|
||||
/** 右侧主区域是否处于「新建邮件」整页编写态 */
|
||||
composing: boolean;
|
||||
composePrefill: { to?: string; cc?: string } | null;
|
||||
startCompose: (prefill?: { to?: string; cc?: string }) => void;
|
||||
cancelCompose: () => void;
|
||||
|
||||
/**
|
||||
* 窄屏下当前显示哪一栏。
|
||||
*
|
||||
* 宽屏是「列表 + 详情」并排,窄屏放不下,只能一次显示一栏:
|
||||
* 选中邮件 → 切到 detail,点返回 → 回 list。
|
||||
*
|
||||
* 这个状态在宽屏下**也维护**(只是不影响渲染):否则从窄屏拖宽再拖回来,
|
||||
* 用户会发现自己回到了列表页,刚打开的邮件不见了。
|
||||
*/
|
||||
narrowPane: 'list' | 'detail';
|
||||
showDetail: () => void;
|
||||
showList: () => void;
|
||||
|
||||
/** 登出后重置回默认视图 */
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIState>(set => ({
|
||||
viewMode: 'inbox',
|
||||
// 切换主视图时回到列表栏:窄屏下停在上一封邮件的详情页会让人不知道自己在哪
|
||||
setViewMode: mode =>
|
||||
set({
|
||||
viewMode: mode,
|
||||
composing: false,
|
||||
composePrefill: null,
|
||||
narrowPane: 'list'
|
||||
}),
|
||||
|
||||
composing: false,
|
||||
composePrefill: null,
|
||||
// 写信占满整个主区域,窄屏下等价于切到 detail 栏
|
||||
startCompose: prefill =>
|
||||
set({ composing: true, composePrefill: prefill ?? null, narrowPane: 'detail' }),
|
||||
cancelCompose: () => set({ composing: false, composePrefill: null, narrowPane: 'list' }),
|
||||
|
||||
narrowPane: 'list',
|
||||
showDetail: () => set({ narrowPane: 'detail' }),
|
||||
showList: () => set({ narrowPane: 'list' }),
|
||||
|
||||
reset: () =>
|
||||
set({
|
||||
viewMode: 'inbox',
|
||||
composing: false,
|
||||
composePrefill: null,
|
||||
narrowPane: 'list'
|
||||
})
|
||||
}));
|
||||
380
client/electron/src/types/index.ts
Normal file
380
client/electron/src/types/index.ts
Normal file
@ -0,0 +1,380 @@
|
||||
export interface User {
|
||||
user_id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
role: 'admin' | 'user';
|
||||
status: 'active' | 'disabled';
|
||||
allowed_agents: string[];
|
||||
allowed_paths: string[];
|
||||
last_login?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface Workspace {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
agent_id?: string;
|
||||
agent_name: string;
|
||||
workspaces: Workspace[];
|
||||
platform: string;
|
||||
status: string;
|
||||
/** 派给该 Agent 的新任务默认多少个来回(0 = 不限) */
|
||||
default_rounds?: number;
|
||||
}
|
||||
|
||||
export interface Address {
|
||||
name: string;
|
||||
path: string;
|
||||
session: string;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
session_id: string;
|
||||
session_alias: string | null;
|
||||
from_agent: string;
|
||||
subject: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
mail_count?: number;
|
||||
/**
|
||||
* 别名是谁定的:
|
||||
* platform = Agent 平台自动同步来的,后续同步可以覆盖
|
||||
* manual = 人显式指定(手工改名或接受了 Agent 的提议),平台同步不得覆盖
|
||||
*/
|
||||
alias_source?: 'platform' | 'manual';
|
||||
/** 用户驳回过的改名提议 */
|
||||
rename_dismissed?: string;
|
||||
/**
|
||||
* 本任务的往返预算(0 = 本会话不限,仅受 Agent 全局配额约束)。
|
||||
*
|
||||
* 配额的语义是「这件事值得多少个来回」—— 那是任务的属性而非 Agent 的属性,
|
||||
* 所以在写信时给、在对话页里随时调,而不是去管理员页面改某个 Agent 的全局配额。
|
||||
*/
|
||||
max_rounds?: number;
|
||||
used_rounds?: number;
|
||||
/** 权限档位:plan / workspace / full */
|
||||
permission_mode?: string;
|
||||
/** 档位实际强制力:native / advisory */
|
||||
permission_enforcement?: string;
|
||||
}
|
||||
|
||||
/** 会话往返预算快照 */
|
||||
export interface SessionBudget {
|
||||
session_id: string;
|
||||
max_rounds: number;
|
||||
used_rounds: number;
|
||||
/** 不限时为 -1 */
|
||||
remaining: number;
|
||||
unlimited: boolean;
|
||||
}
|
||||
|
||||
/** 附件元数据。内容存盘,按 sha256 内容寻址;同内容重复上传不占额外空间。 */
|
||||
export interface Attachment {
|
||||
attachment_id: string;
|
||||
/** 为 null 表示已上传但尚未随邮件发出 */
|
||||
mail_id: string | null;
|
||||
uploader: string;
|
||||
filename: string;
|
||||
content_type: string;
|
||||
size_bytes: number;
|
||||
sha256: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Mail {
|
||||
mail_id: string;
|
||||
session_id: string;
|
||||
parent_mail_id: string | null;
|
||||
from_name: string;
|
||||
from_workspace: string;
|
||||
to_name: string;
|
||||
to_workspace: string;
|
||||
cc_list: Address[];
|
||||
subject: string;
|
||||
body: string;
|
||||
mail_type: 'normal' | 'permission_request';
|
||||
permission_options: string[] | null;
|
||||
permission_result: string | null;
|
||||
status: 'unread' | 'read' | 'archived';
|
||||
created_at: string;
|
||||
hop_limit?: number;
|
||||
session_alias?: string;
|
||||
/**
|
||||
* **这条会话**的工作目录(sessions.workspace)。
|
||||
*
|
||||
* 不能用 from_workspace / to_workspace 代替:
|
||||
* - 人 → Agent:to_workspace 是真路径,from_workspace 为空(人没有工作目录)
|
||||
* - Agent → 人:to_workspace 为空,而 **from_workspace 存的是 Agent 名**
|
||||
* 而不是路径(历史遗留)
|
||||
*
|
||||
* 于是「Agent 发来的这封信,那个 Agent 在哪个目录干活」只能从会话上取。
|
||||
* 界面上曾显示成 `dsh@dsh`,就是拿 from_workspace 当路径拼的。
|
||||
*/
|
||||
session_workspace?: string;
|
||||
body_preview?: string;
|
||||
attachments?: Attachment[];
|
||||
/** 发件方是人类用户而不是 Agent(服务端 EXISTS users 判的) */
|
||||
from_human: boolean;
|
||||
/** 收件方是人类用户而不是 Agent */
|
||||
to_human: boolean;
|
||||
permission_mode?: string;
|
||||
permission_enforcement?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对话树节点。
|
||||
*
|
||||
* 树由 mails.parent_mail_id 编码:回复指向来信,转发指向被转发的原件。
|
||||
* 因此树可以跨会话 —— 转发把线索引到新会话,却仍属同一条线索。
|
||||
*
|
||||
* depth 是**相对锚点**的层级:0 = 锚点,负数 = 祖先,正数 = 子孙。
|
||||
* 分块加载时根可能还没取到,所以不用「距根深度」。
|
||||
*/
|
||||
export interface ThreadNode extends Omit<Mail, 'body'> {
|
||||
/** 距**线索根**的层级:0 = 根,1 = 它的直接回复 */
|
||||
depth: number;
|
||||
attachment_count: number;
|
||||
/** 父邮件不在当前已加载集合里(无权查看,或还没滑到) */
|
||||
detached?: boolean;
|
||||
/** 父邮件确实存在但无权查看(区别于「尚未加载」) */
|
||||
parent_hidden?: boolean;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
export interface ThreadPage {
|
||||
anchor_mail_id: string;
|
||||
/** 线索根的 mail_id:整棵树从它展开 */
|
||||
root_mail_id: string;
|
||||
/** 锚点距根的层数,用于高亮定位 */
|
||||
anchor_depth: number;
|
||||
nodes: ThreadNode[];
|
||||
total: number;
|
||||
/** 因权限被过滤掉的节点数 */
|
||||
hidden: number;
|
||||
has_more: boolean;
|
||||
/** 下一页 offset,原样回传即可 */
|
||||
next_offset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent 在邮件正文里提议的新会话别名。
|
||||
*
|
||||
* 为什么是提议而不是 Agent 直接改:别名是**人**的寻址入口
|
||||
* (name@path.别名)。Agent 干到一半自己改掉,人上一秒记住的地址下一秒就失效。
|
||||
* 提议 + 人点头,既让 Agent 表达意图,又保证寻址稳定性由人掌握。
|
||||
*/
|
||||
export interface RenameProposal {
|
||||
/** 已由服务端规范化,可直接提交给 PUT /sessions/:id/alias */
|
||||
alias: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface Contact {
|
||||
session_id: string;
|
||||
agent_name: string;
|
||||
path: string;
|
||||
session_alias: string;
|
||||
address: string;
|
||||
status: string;
|
||||
mail_count: number;
|
||||
unread_count: number;
|
||||
last_activity: string;
|
||||
/** 会话主题(多由 Agent 平台的模型生成的摘要) */
|
||||
subject: string;
|
||||
/** 本任务的往返预算(0 = 不限) */
|
||||
max_rounds: number;
|
||||
used_rounds: number;
|
||||
permission_mode?: string;
|
||||
permission_enforcement?: string;
|
||||
/** 最后一封邮件的发件人与正文摘要(服务端已按字符截断) */
|
||||
last_from: string;
|
||||
last_preview: string;
|
||||
}
|
||||
|
||||
export interface PermissionRequest {
|
||||
request_id: string;
|
||||
mail_id: string;
|
||||
session_id: string;
|
||||
agent_name: string;
|
||||
question: string;
|
||||
options: string[];
|
||||
context: string;
|
||||
result: string | null;
|
||||
decided_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SessionDetail {
|
||||
session: Session;
|
||||
mails: Mail[];
|
||||
}
|
||||
|
||||
export interface HumanSession {
|
||||
session_id: string;
|
||||
session_alias: string | null;
|
||||
from_agent: string;
|
||||
subject: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
mail_count: number;
|
||||
unread_count: number;
|
||||
/** 本任务的往返预算(0 = 不限) */
|
||||
max_rounds?: number;
|
||||
used_rounds?: number;
|
||||
permission_mode?: string;
|
||||
permission_enforcement?: string;
|
||||
}
|
||||
|
||||
export type SuggestKind = 'name' | 'path' | 'session';
|
||||
|
||||
/**
|
||||
* 会话候选项的来源。
|
||||
* mail 本侧邮件线索 —— 这个别名一定送得到
|
||||
* platform 平台侧会话镜像(人直接在 opencode/DSH 界面上开的)
|
||||
* new 新建会话的哨兵项
|
||||
*/
|
||||
export type SessionCandidateSource = 'mail' | 'platform' | 'new';
|
||||
|
||||
export interface SessionCandidate {
|
||||
/** 填进 session 位的值 */
|
||||
alias: string;
|
||||
/** 给人看,用来分辨两条别名相似的会话在谈什么 */
|
||||
title?: string;
|
||||
source: SessionCandidateSource;
|
||||
/** 仅 mail 来源有意义 */
|
||||
unread?: number;
|
||||
}
|
||||
|
||||
export interface SuggestResult {
|
||||
kind: SuggestKind;
|
||||
suggestions: string[];
|
||||
/**
|
||||
* 带标题与来源的完整候选,与 suggestions 同序。
|
||||
* 仅 kind === 'session' 时返回;suggestions 保留纯字符串形式是为了
|
||||
* 不打破已部署的前端与第三方客户端。
|
||||
*/
|
||||
candidates?: SessionCandidate[];
|
||||
}
|
||||
|
||||
/** 系统初始化状态 */
|
||||
export interface SetupStatus {
|
||||
needs_setup: boolean;
|
||||
}
|
||||
|
||||
/** 管理员可授权范围候选 */
|
||||
export interface AdminScopes {
|
||||
agents: string[];
|
||||
paths: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 日历事件。
|
||||
*
|
||||
* 三层分离:事件是日历实体,提醒是触发器,邮件是投递通道。
|
||||
* 提醒到点时调度器发一封 from_name="calendar" 的邮件给 to_address ——
|
||||
* 对 Agent 来说就是一封普通邮件,它不知道也不需要知道信来自日历。
|
||||
*/
|
||||
export interface CalendarEvent {
|
||||
event_id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
/**
|
||||
* 提醒邮件的正文。留空时后端按 title/time/description 生成默认模板。
|
||||
* 支持 {title} {time} {description} 三个变量,触发时替换。
|
||||
*/
|
||||
reminder_text: string;
|
||||
/**
|
||||
* 收件 Agent 名 / 完整地址 —— **单收件人时代的字段**。
|
||||
* 保留作兼容与兜底:recipients 为空时才用它们。
|
||||
* 新代码一律用 effectiveRecipients()。
|
||||
*/
|
||||
agent_name: string;
|
||||
to_address: string;
|
||||
/**
|
||||
* 收件人列表,每项是完整三维地址串。
|
||||
*
|
||||
* 存原始串而不是结构化地址:session 位的 new/别名三态该在**触发那一刻**
|
||||
* 解析。存结构化的话「.new」这种一次性语义在建事件时就被固化,
|
||||
* 而重复事件每次触发都该重新决定落到哪条会话。
|
||||
*/
|
||||
recipients: string[];
|
||||
/**
|
||||
* 多收件人的投递方式。
|
||||
* separate = 各发一封、落各自会话、互相看不到
|
||||
* together = 首个为主收件人,其余进抄送、共享同一条线索
|
||||
*
|
||||
* 两种都要而不是二选一:「三个 Agent 各自独立汇报」与「pi 主办、dsh 知情」
|
||||
* 是完全不同的任务形态。用错 together 会让本该独立判断的 Agent 互相
|
||||
* 看到回复而趋同,那种污染事后无法分离。
|
||||
*/
|
||||
delivery_mode: DeliveryMode;
|
||||
/** ISO 8601 */
|
||||
event_time: string;
|
||||
/** 提前多少分钟提醒;0 = 到点才提醒 */
|
||||
remind_before: number;
|
||||
recurrence: Recurrence;
|
||||
/** 重复终止时间;越过它事件自动置为 cancelled */
|
||||
recurrence_end?: string;
|
||||
status: 'active' | 'paused' | 'cancelled';
|
||||
/** 上次触发的墙上时钟 */
|
||||
last_fired_at?: string;
|
||||
/**
|
||||
* 已触发的那个 occurrence(值 = 当时的 event_time)。
|
||||
* 去重靠它与 event_time 相等判断,不是拿 last_fired_at 比大小 ——
|
||||
* 后端 DueEvents 有 60 秒 lookahead,后者在窗口内恒为真会导致每 tick 重发。
|
||||
*/
|
||||
fired_for?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重复规则。农历两种单独存在,因为它们的公历日期每年都在漂移 ——
|
||||
* 用公历 yearly 会固定在同一天,与「过农历生日/祭日」的期望不符。
|
||||
*
|
||||
* 没有 lunar_daily(农历的「日」与公历同长,那就是 daily)
|
||||
* 也没有 lunar_weekly(农历没有「周」这个单位)。
|
||||
*/
|
||||
export type Recurrence =
|
||||
| 'none'
|
||||
| 'daily'
|
||||
| 'weekly'
|
||||
| 'monthly'
|
||||
| 'yearly'
|
||||
| 'lunar_monthly'
|
||||
| 'lunar_yearly';
|
||||
|
||||
export type DeliveryMode = 'separate' | 'together';
|
||||
|
||||
/** 事件附件(随提醒邮件一起发出) */
|
||||
export interface CalendarAttachment {
|
||||
attachment_id: string;
|
||||
event_id: string;
|
||||
filename: string;
|
||||
sha256: string;
|
||||
size_bytes: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** 新建/编辑事件的请求体。event_time 必填,其余可省。 */
|
||||
export interface CalendarEventInput {
|
||||
title: string;
|
||||
description?: string;
|
||||
reminder_text?: string;
|
||||
agent_name?: string;
|
||||
to_address?: string;
|
||||
recipients?: string[];
|
||||
delivery_mode?: DeliveryMode;
|
||||
event_time: string;
|
||||
remind_before?: number;
|
||||
recurrence?: Recurrence;
|
||||
recurrence_end?: string | null;
|
||||
status?: 'active' | 'paused' | 'cancelled';
|
||||
}
|
||||
57
client/electron/src/types/lunar-javascript.d.ts
vendored
Normal file
57
client/electron/src/types/lunar-javascript.d.ts
vendored
Normal file
@ -0,0 +1,57 @@
|
||||
/**
|
||||
* lunar-javascript 的类型声明。
|
||||
*
|
||||
* 上游没有发布 .d.ts(也没有 @types/lunar-javascript),不声明的话
|
||||
* `import` 直接 TS7016 编译失败。
|
||||
*
|
||||
* 只声明我们真正用到的成员而不是 `declare module 'lunar-javascript'`
|
||||
* (那等于放弃整个模块的类型)—— 写错方法名时仍要能在编译期发现,
|
||||
* 否则会变成运行时的「undefined is not a function」,而日历页面
|
||||
* 一旦抛错整片区域白屏。
|
||||
*/
|
||||
declare module 'lunar-javascript' {
|
||||
export interface LunarMonthLike {
|
||||
getYear(): number;
|
||||
/** 负数表示闰月 */
|
||||
getMonth(): number;
|
||||
/** 29 或 30 */
|
||||
getDayCount(): number;
|
||||
}
|
||||
|
||||
export interface SolarLike {
|
||||
getYear(): number;
|
||||
/** 1-12 */
|
||||
getMonth(): number;
|
||||
getDay(): number;
|
||||
toYmd(): string;
|
||||
toString(): string;
|
||||
}
|
||||
|
||||
export interface LunarLike {
|
||||
getYear(): number;
|
||||
/** 负数表示闰月 */
|
||||
getMonth(): number;
|
||||
getDay(): number;
|
||||
getSolar(): SolarLike;
|
||||
/** 「二〇二六年七月廿二」 */
|
||||
toString(): string;
|
||||
}
|
||||
|
||||
export const Solar: {
|
||||
fromYmd(year: number, month: number, day: number): SolarLike & { getLunar(): LunarLike };
|
||||
};
|
||||
|
||||
export const Lunar: {
|
||||
/** month 传负数表示闰月。**非法日期会 throw** —— 农历月是 29 或 30 天不定。 */
|
||||
fromYmd(year: number, month: number, day: number): LunarLike;
|
||||
};
|
||||
|
||||
export const LunarYear: {
|
||||
fromYear(year: number): {
|
||||
/** 0 = 无闰月 */
|
||||
getLeapMonth(): number;
|
||||
/** 含跨年边界的月份,因此使用时必须同时比对 getYear() */
|
||||
getMonths(): LunarMonthLike[];
|
||||
};
|
||||
};
|
||||
}
|
||||
11
client/electron/src/vite-env.d.ts
vendored
Normal file
11
client/electron/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
/** 构建期可注入的环境变量 */
|
||||
interface ImportMetaEnv {
|
||||
/** API 基地址;不设则用同源 /api/v1 */
|
||||
readonly VITE_API_BASE?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
184
client/electron/tailwind.config.js
Normal file
184
client/electron/tailwind.config.js
Normal file
@ -0,0 +1,184 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
|
||||
/**
|
||||
* 颜色走 CSS 变量而不是写死的十六进制。
|
||||
*
|
||||
* # 为什么不逐处加 dark: 前缀
|
||||
*
|
||||
* 全站约 700 处颜色用法散在 21 个组件里。逐个写 `bg-white dark:bg-gray-900`
|
||||
* 有两个致命问题:漏一处就是深色下的白底白字(而且只有肉眼能发现),
|
||||
* 以及此后每加一个组件都要记得写两遍 —— 那种约定活不过三次改动。
|
||||
*
|
||||
* # 为什么改调色板就够了
|
||||
*
|
||||
* 这套代码里灰阶**本身就是语义色阶**:
|
||||
* - `white` / `gray-50` / `gray-100` = 表面层次(卡片 / 页面底 / 悬停)
|
||||
* - `gray-200` / `gray-300` = 分隔线
|
||||
* - `gray-900` → `gray-400` = 文字主次
|
||||
*
|
||||
* 深色模式要做的正是把这条色阶**反转**:white 变近黑、gray-900 变近白。
|
||||
* 于是零组件改动就能整体切换,新组件照常写 `bg-white text-gray-900`
|
||||
* 也自动适配 —— 不需要任何人记得任何约定。
|
||||
*
|
||||
* # 为什么是 `rgb(var(--x) / <alpha-value>)` 而不是直接存颜色串
|
||||
*
|
||||
* 代码里有 `bg-blue-50/70`、`bg-gray-50/60` 这样的透明度修饰符。
|
||||
* 变量若存 `#f9fafb`,Tailwind 生成的 `rgb(#f9fafb / 0.7)` 是无效 CSS,
|
||||
* 那些半透明高亮会静默失效(不报错,只是不透明)。存 RGB 三元组才行。
|
||||
*/
|
||||
const withAlpha = (v) => `rgb(var(${v}) / <alpha-value>)`;
|
||||
|
||||
const grayScale = {
|
||||
50: withAlpha('--c-gray-50'),
|
||||
100: withAlpha('--c-gray-100'),
|
||||
200: withAlpha('--c-gray-200'),
|
||||
300: withAlpha('--c-gray-300'),
|
||||
400: withAlpha('--c-gray-400'),
|
||||
500: withAlpha('--c-gray-500'),
|
||||
600: withAlpha('--c-gray-600'),
|
||||
700: withAlpha('--c-gray-700'),
|
||||
800: withAlpha('--c-gray-800'),
|
||||
900: withAlpha('--c-gray-900'),
|
||||
950: withAlpha('--c-gray-950')
|
||||
};
|
||||
|
||||
/**
|
||||
* 强调色(blue / red / green / amber / orange / yellow)。
|
||||
*
|
||||
* 这条色阶在这套代码里也是**语义色阶**,与灰阶同理:
|
||||
* - `50` – `300` = 表面(chip 底、提示条底、徽标底、边框)
|
||||
* - `400` – `900` = 前景(文字、图标、实心按钮底)
|
||||
*
|
||||
* 深色模式下两段的走向**相反**:表面段要变暗(照搬浅色的近白值会在深色页面上
|
||||
* 糊出一块刺眼亮斑),前景段要变亮(照搬浅色的 red-700 落在深色卡片上只有
|
||||
* 2.67:1,读不动)。所以它必须走变量,不能写死 —— 见 index.css 的两组定义。
|
||||
*/
|
||||
const accent = (name) => ({
|
||||
50: withAlpha(`--c-${name}-50`),
|
||||
100: withAlpha(`--c-${name}-100`),
|
||||
200: withAlpha(`--c-${name}-200`),
|
||||
300: withAlpha(`--c-${name}-300`),
|
||||
400: withAlpha(`--c-${name}-400`),
|
||||
500: withAlpha(`--c-${name}-500`),
|
||||
600: withAlpha(`--c-${name}-600`),
|
||||
700: withAlpha(`--c-${name}-700`),
|
||||
800: withAlpha(`--c-${name}-800`),
|
||||
900: withAlpha(`--c-${name}-900`)
|
||||
});
|
||||
|
||||
/**
|
||||
* 实心按钮/徽标的底色 —— `--s-*`,两种模式下**同值**。
|
||||
*
|
||||
* 为什么不能跟 accent 的前景段走:那一段在深色下被提亮成了浅色
|
||||
* (red-600 → rgb(246,141,141)),而 `bg-red-600 text-white` 的白字落上去
|
||||
* 只有 1.6:1。实心按钮的底色本来就该保持饱和 —— 深色模式下变的是页面,
|
||||
* 不是「危险操作按钮是红的」这件事。
|
||||
*
|
||||
* 只覆盖 backgroundColor,`text-red-600` / `border-red-600` 仍走 accent()。
|
||||
* Tailwind 的 backgroundColor 默认继承 colors,这里逐档覆写 400–900
|
||||
* (50–300 是表面段,仍从 colors 继承 —— 它们在深色下就该变暗)。
|
||||
*/
|
||||
const solid = (name) => ({
|
||||
400: withAlpha(`--s-${name}-400`),
|
||||
500: withAlpha(`--s-${name}-500`),
|
||||
600: withAlpha(`--s-${name}-600`),
|
||||
700: withAlpha(`--s-${name}-700`),
|
||||
800: withAlpha(`--s-${name}-800`),
|
||||
900: withAlpha(`--s-${name}-900`)
|
||||
});
|
||||
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
// class 而不是 media:主题要能被人显式选择。跟系统走是**默认值**,
|
||||
// 不是唯一选项 —— 白天开深色主题是常见偏好。
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
/**
|
||||
* textColor 单独覆盖 white。
|
||||
*
|
||||
* `--c-white` 服务两种**互相冲突**的用途:
|
||||
* - `bg-white` = 卡片表面 → 深色模式必须变暗
|
||||
* - `text-white` = 彩色按钮上的文字 → 深色模式必须**保持浅色**
|
||||
*
|
||||
* 只有一个变量时后者跟着变暗,白字落在 `bg-chrome-700` 的激活导航项上
|
||||
* 只剩 1.34:1 —— 几乎不可见(实测发现)。按钮底色在深色模式下依然是
|
||||
* blue-600 那样的彩色,上面的文字本来就该是白的。
|
||||
*
|
||||
* Tailwind 的 textColor 默认继承 colors,这里只改 white 一项,
|
||||
* 其余(gray/blue/...)仍走反转的色阶。
|
||||
*/
|
||||
textColor: {
|
||||
white: withAlpha('--c-on-accent')
|
||||
},
|
||||
/**
|
||||
* backgroundColor 单独覆盖强调色的 400–900 档,让实心按钮底保持饱和。
|
||||
*
|
||||
* 每个强调色的 400–900 在这套代码里承担**两个冲突用途**:
|
||||
* - `text-red-600` / `border-red-300` = 深色下必须**提亮**才读得动
|
||||
* - `bg-red-600` + `text-white` = 深色下必须**保持饱和**
|
||||
*
|
||||
* 共用一档时后者必坏:深色下 red-600 被提亮到 rgb(246,141,141),
|
||||
* 白字落上去只有 1.6:1 —— 与 text-white/bg-white 那次是同一类错误
|
||||
* (一个名字服务两种语义)。
|
||||
*
|
||||
* 50–300 不覆盖:那是表面段,深色下就该跟着变暗。
|
||||
*/
|
||||
backgroundColor: {
|
||||
blue: solid('blue'),
|
||||
red: solid('red'),
|
||||
green: solid('green'),
|
||||
amber: solid('amber'),
|
||||
orange: solid('orange'),
|
||||
yellow: solid('yellow')
|
||||
},
|
||||
colors: {
|
||||
white: withAlpha('--c-white'),
|
||||
gray: grayScale,
|
||||
// slate 在这套代码里只用于登录页与少数深色块,与 gray 同源即可 ——
|
||||
// 保留两个名字是为了不改那些组件,但它们指向同一条色阶。
|
||||
slate: grayScale,
|
||||
/**
|
||||
* chrome —— 应用框架(侧栏 / 底部导航)的专用色阶。
|
||||
*
|
||||
* 为什么不能跟 gray 走:这两块**在浅色模式下本来就是深色的**
|
||||
* (深色侧栏配浅色内容区是这套 UI 的原本设计)。把它们并入反转的
|
||||
* gray 之后,深色模式下 `bg-slate-900` 变成了近白色 —— 侧栏比内容区
|
||||
* 还亮,整个层次翻了过来(实测 rgb(243,245,248),而内容区是 rgb(17,19,24))。
|
||||
*
|
||||
* 独立成一条色阶后:浅色模式下它是深色框架,深色模式下**微调即可**
|
||||
* (比内容区略深一点,保持"框架比内容更沉"的关系),两种模式下
|
||||
* 语义一致。
|
||||
*/
|
||||
chrome: {
|
||||
100: withAlpha('--c-chrome-100'),
|
||||
200: withAlpha('--c-chrome-200'),
|
||||
400: withAlpha('--c-chrome-400'),
|
||||
600: withAlpha('--c-chrome-600'),
|
||||
700: withAlpha('--c-chrome-700'),
|
||||
800: withAlpha('--c-chrome-800'),
|
||||
900: withAlpha('--c-chrome-900')
|
||||
},
|
||||
/*
|
||||
* 强调色。表面段(50–300)在深色下变暗、前景段(400–900)变亮,
|
||||
* 两段走向相反 —— 详见 accent() 的注释与 index.css 的两组定义。
|
||||
*
|
||||
* 实心按钮底另走 --s-*(见上面的 backgroundColor)。
|
||||
*
|
||||
* 注意这里**只能有一份定义**:JS 对象字面量的重复键后者胜出,
|
||||
* 而那不会报错。上一版同时写了固定 hex 与 accent() 两份,
|
||||
* accent() 覆盖了 hex 那份,但 index.css 里当时没有对应的 --c-red-* 等
|
||||
* 变量 —— `rgb(var(--c-red-600) / 1)` 里的变量未定义使整条声明失效,
|
||||
* 于是 bg-red-600 退回透明,白字落在白卡片上:按钮看不见但点得动。
|
||||
*/
|
||||
blue: accent('blue'),
|
||||
red: accent('red'),
|
||||
green: accent('green'),
|
||||
amber: accent('amber'),
|
||||
orange: accent('orange'),
|
||||
yellow: accent('yellow')
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
};
|
||||
264
client/electron/test/components/AddressInput.test.tsx
Normal file
264
client/electron/test/components/AddressInput.test.tsx
Normal file
@ -0,0 +1,264 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import AddressInput from '../../src/components/AddressInput';
|
||||
import * as api from '../../src/api/client';
|
||||
import type { SessionCandidate } from '../../src/types';
|
||||
|
||||
/**
|
||||
* 三段式地址补全。
|
||||
*
|
||||
* 每条断言都对应一个真实存在过或很容易犯的错:
|
||||
* - 问错层(写了 @ 还在问 name 的候选)
|
||||
* - 过滤后 suggestions 与 candidates 错位,标题挂到别的别名上
|
||||
* - 选中 name 后没自动补 `@`,人得自己敲
|
||||
* - session 段选完还开着下拉,挡住下面的输入框
|
||||
*/
|
||||
|
||||
/** 造一个 suggestAddress 的响应。candidates 与 suggestions 必须同序同长。 */
|
||||
function res(
|
||||
kind: 'name' | 'path' | 'session',
|
||||
suggestions: string[],
|
||||
candidates?: Partial<SessionCandidate>[]
|
||||
) {
|
||||
return {
|
||||
kind,
|
||||
suggestions,
|
||||
candidates: candidates?.map((c, i) => ({
|
||||
alias: suggestions[i],
|
||||
title: '',
|
||||
source: 'mail' as const,
|
||||
unread: 0,
|
||||
...c
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
/** 渲染一个受控的 AddressInput,返回 input 与「当前值」读取器。 */
|
||||
function setup(initial = '') {
|
||||
let current = initial;
|
||||
const onChange = vi.fn((v: string) => {
|
||||
current = v;
|
||||
rerender();
|
||||
});
|
||||
const { rerender: rr } = render(
|
||||
React.createElement(AddressInput, { value: current, onChange })
|
||||
);
|
||||
function rerender() {
|
||||
rr(React.createElement(AddressInput, { value: current, onChange }));
|
||||
}
|
||||
return {
|
||||
onChange,
|
||||
value: () => current,
|
||||
input: () => screen.getByRole('textbox') as HTMLInputElement
|
||||
};
|
||||
}
|
||||
|
||||
describe('AddressInput 三段式补全', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('还没写 @ 时问 name 层', async () => {
|
||||
const spy = vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('name', ['dsh', 'opencode']));
|
||||
const { input } = setup();
|
||||
|
||||
await userEvent.click(input());
|
||||
await userEvent.type(input(), 'ds');
|
||||
|
||||
await waitFor(() => expect(spy).toHaveBeenCalled());
|
||||
// 问 name 层时不带任何参数
|
||||
expect(spy.mock.calls[spy.mock.calls.length - 1]).toEqual([]);
|
||||
});
|
||||
|
||||
it('写了 @ 没写 . 时带 name 去问 path 层', async () => {
|
||||
const spy = vi
|
||||
.spyOn(api, 'suggestAddress')
|
||||
.mockResolvedValue(res('path', ['/home/program/agentmail']));
|
||||
const { input } = setup();
|
||||
|
||||
await userEvent.click(input());
|
||||
await userEvent.type(input(), 'dsh@/home');
|
||||
|
||||
// 问错层的后果:候选里全是 Agent 名,人以为这个工作区下没有会话
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledWith('dsh'));
|
||||
});
|
||||
|
||||
it('写了 . 时带 name 与 path 去问 session 层', async () => {
|
||||
const spy = vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('session', ['refactor']));
|
||||
const { input } = setup();
|
||||
|
||||
await userEvent.click(input());
|
||||
await userEvent.type(input(), 'dsh@/home/x.ref');
|
||||
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledWith('dsh', '/home/x'));
|
||||
});
|
||||
|
||||
it('path 里的 . 不被当作 session 分隔符(按最后一个 . 切)', async () => {
|
||||
const spy = vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('session', ['a']));
|
||||
const { input } = setup();
|
||||
|
||||
await userEvent.click(input());
|
||||
// path 自身含 .:a.b/proj
|
||||
await userEvent.type(input(), 'dsh@/home/a.b/proj.ref');
|
||||
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledWith('dsh', '/home/a.b/proj'));
|
||||
});
|
||||
|
||||
it('选中 name 候选后自动补 @', async () => {
|
||||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('name', ['dsh', 'opencode']));
|
||||
const { input, value } = setup();
|
||||
|
||||
await userEvent.click(input());
|
||||
await waitFor(() => expect(screen.getByText('dsh')).toBeInTheDocument());
|
||||
await userEvent.click(screen.getByText('dsh'));
|
||||
|
||||
// 不补 @ 的话人得自己敲,而这是三段里唯一没有歧义的分隔符
|
||||
expect(value()).toBe('dsh@');
|
||||
});
|
||||
|
||||
it('选中 path 候选后自动补 .', async () => {
|
||||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('path', ['/home/program/agentmail']));
|
||||
const { input, value } = setup('dsh@');
|
||||
|
||||
await userEvent.click(input());
|
||||
await waitFor(() => expect(screen.getByText('/home/program/agentmail')).toBeInTheDocument());
|
||||
await userEvent.click(screen.getByText('/home/program/agentmail'));
|
||||
|
||||
expect(value()).toBe('dsh@/home/program/agentmail.');
|
||||
});
|
||||
|
||||
it('选中 session 候选后拼出完整地址并关掉下拉', async () => {
|
||||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('session', ['refactor', 'new']));
|
||||
const { input, value } = setup('dsh@/home/x.');
|
||||
|
||||
await userEvent.click(input());
|
||||
await waitFor(() => expect(screen.getByText('refactor')).toBeInTheDocument());
|
||||
await userEvent.click(screen.getByText('refactor'));
|
||||
|
||||
expect(value()).toBe('dsh@/home/x.refactor');
|
||||
// session 是最后一段,选完还开着下拉会挡住下面的输入框
|
||||
await waitFor(() => expect(screen.queryByText('会话别名(new 为新建)')).toBeNull());
|
||||
});
|
||||
|
||||
it('标题参与过滤,且过滤后标题不错位到别的别名上', async () => {
|
||||
// 三条候选,只有第二条的标题含「缓存」
|
||||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(
|
||||
res(
|
||||
'session',
|
||||
['witty-planet', 'brisk-harbor', 'calm-river'],
|
||||
[{ title: '重构导入路径' }, { title: '缓存选型讨论' }, { title: '修 CI' }]
|
||||
)
|
||||
);
|
||||
const { input } = setup('dsh@/home/x.');
|
||||
|
||||
await userEvent.click(input());
|
||||
await userEvent.type(input(), '缓存');
|
||||
|
||||
await waitFor(() => {
|
||||
// 命中的是 brisk-harbor,标题必须还是它自己的
|
||||
expect(screen.getByText('brisk-harbor')).toBeInTheDocument();
|
||||
expect(screen.getByText('缓存选型讨论')).toBeInTheDocument();
|
||||
});
|
||||
// 分别过滤两个数组会让 witty-planet 的标题挂到 brisk-harbor 上
|
||||
expect(screen.queryByText('重构导入路径')).toBeNull();
|
||||
expect(screen.queryByText('witty-planet')).toBeNull();
|
||||
});
|
||||
|
||||
it('platform 来源的候选标出「平台」,new 标出「新建会话」', async () => {
|
||||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(
|
||||
res(
|
||||
'session',
|
||||
['ses-mirror', 'new'],
|
||||
[
|
||||
{ title: '平台侧在跑的会话', source: 'platform' },
|
||||
{ source: 'new' }
|
||||
]
|
||||
)
|
||||
);
|
||||
const { input } = setup('dsh@/home/x.');
|
||||
|
||||
await userEvent.click(input());
|
||||
|
||||
await waitFor(() => {
|
||||
// 不标的话人不知道这一封是「接入」一条已在跑的会话
|
||||
expect(screen.getByText('平台')).toBeInTheDocument();
|
||||
expect(screen.getByText('新建会话')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('未读数显示在候选上', async () => {
|
||||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(
|
||||
res('session', ['busy-session'], [{ title: '有新消息', unread: 3 }])
|
||||
);
|
||||
const { input } = setup('dsh@/home/x.');
|
||||
|
||||
await userEvent.click(input());
|
||||
|
||||
await waitFor(() => expect(screen.getByText('3')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('键盘上下键移动选中项,Enter 采用', async () => {
|
||||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('name', ['dsh', 'opencode']));
|
||||
const { input, value } = setup();
|
||||
|
||||
await userEvent.click(input());
|
||||
await waitFor(() => expect(screen.getByText('opencode')).toBeInTheDocument());
|
||||
|
||||
await userEvent.keyboard('{ArrowDown}{Enter}');
|
||||
|
||||
// 初始 active=0(dsh),下移一格到 opencode
|
||||
expect(value()).toBe('opencode@');
|
||||
});
|
||||
|
||||
it('Escape 关掉下拉但不清空已输入的内容', async () => {
|
||||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('name', ['dsh']));
|
||||
const { input, value } = setup();
|
||||
|
||||
await userEvent.click(input());
|
||||
await userEvent.type(input(), 'ds');
|
||||
await waitFor(() => expect(screen.getByText('Agent 名')).toBeInTheDocument());
|
||||
|
||||
await userEvent.keyboard('{Escape}');
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Agent 名')).toBeNull());
|
||||
expect(value()).toBe('ds');
|
||||
});
|
||||
|
||||
it('接口失败时静默清空候选,不炸也不弹错', async () => {
|
||||
vi.spyOn(api, 'suggestAddress').mockRejectedValue(new Error('network down'));
|
||||
const { input, value } = setup();
|
||||
|
||||
await userEvent.click(input());
|
||||
await userEvent.type(input(), 'ds');
|
||||
|
||||
// 补全只是便利功能,失败不该阻止人手动输入完整地址
|
||||
await waitFor(() => expect(screen.queryByText('Agent 名')).toBeNull());
|
||||
expect(value()).toBe('ds');
|
||||
});
|
||||
|
||||
it('allowMultiple 时补全只作用于最后一段,前面的地址保留', async () => {
|
||||
vi.spyOn(api, 'suggestAddress').mockResolvedValue(res('name', ['opencode']));
|
||||
|
||||
let current = 'dsh@/home/x.a, ';
|
||||
const onChange = vi.fn((v: string) => {
|
||||
current = v;
|
||||
});
|
||||
render(
|
||||
React.createElement(AddressInput, {
|
||||
value: current,
|
||||
onChange,
|
||||
allowMultiple: true
|
||||
})
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole('textbox'));
|
||||
await waitFor(() => expect(screen.getByText('opencode')).toBeInTheDocument());
|
||||
await userEvent.click(screen.getByText('opencode'));
|
||||
|
||||
// 抄送场景:前面已填好的地址不能被补全覆盖
|
||||
expect(current).toBe('dsh@/home/x.a, opencode@');
|
||||
});
|
||||
});
|
||||
80
client/electron/test/components/BudgetChip.test.tsx
Normal file
80
client/electron/test/components/BudgetChip.test.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { BudgetChip } from '../../src/components/WorkCard';
|
||||
|
||||
/**
|
||||
* 往返预算徽标。
|
||||
*
|
||||
* 它是「这个任务还剩几个来回」的唯一视觉提示,两件事必须对:
|
||||
* - 显示的是**剩余**而不是已用(人做决定看的是「还能问几次」)
|
||||
* - 0 = 不限时**不显示**,而不是显示「0/0」
|
||||
*/
|
||||
describe('BudgetChip 预算渲染', () => {
|
||||
it('显示剩余数而不是已用数', () => {
|
||||
render(React.createElement(BudgetChip, { max: 20, used: 3 }));
|
||||
|
||||
// 20 个上限、用了 3 个 → 剩 17。显示 3/20 会让人以为快用完了
|
||||
expect(screen.getByText('17/20')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('max 为 0(不限)时完全不渲染', () => {
|
||||
const { container } = render(React.createElement(BudgetChip, { max: 0, used: 0 }));
|
||||
|
||||
// 「0/0」或「不限」对每张卡片都成立,等于纯噪声
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('max 为负数时也不渲染(脏数据兜底)', () => {
|
||||
const { container } = render(React.createElement(BudgetChip, { max: -1, used: 0 }));
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('用超时剩余按 0 显示,不出现负数', () => {
|
||||
render(React.createElement(BudgetChip, { max: 5, used: 8 }));
|
||||
|
||||
// 「-3/5」看起来像 bug,而实际情形(免配额转发不计数、人手动下调过上限)是合法的
|
||||
expect(screen.getByText('0/5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('剩余为 0 时转红并标注「已用尽」', () => {
|
||||
render(React.createElement(BudgetChip, { max: 5, used: 5 }));
|
||||
|
||||
const chip = screen.getByText('0/5').closest('span')!;
|
||||
expect(chip.className).toContain('bg-red-100');
|
||||
expect(chip.getAttribute('title')).toContain('已用尽');
|
||||
});
|
||||
|
||||
it('剩余 1 个时转橙 —— 那是需要人介入的时刻', () => {
|
||||
render(React.createElement(BudgetChip, { max: 20, used: 19 }));
|
||||
|
||||
const chip = screen.getByText('1/20').closest('span')!;
|
||||
// 要么加预算,要么让它收尾;这一步不提示的话下一封信就被挡住了
|
||||
expect(chip.className).toContain('bg-orange-100');
|
||||
});
|
||||
|
||||
it('余量充足时用中性灰,不抢注意力', () => {
|
||||
render(React.createElement(BudgetChip, { max: 20, used: 2 }));
|
||||
|
||||
const chip = screen.getByText('18/20').closest('span')!;
|
||||
expect(chip.className).toContain('bg-gray-100');
|
||||
expect(chip.className).not.toContain('red');
|
||||
expect(chip.className).not.toContain('orange');
|
||||
});
|
||||
|
||||
it('title 里带已用/上限,供悬停查看细节', () => {
|
||||
render(React.createElement(BudgetChip, { max: 20, used: 7 }));
|
||||
|
||||
const chip = screen.getByText('13/20').closest('span')!;
|
||||
// 徽标上只有剩余,具体用了几个放在 title 里 —— 徽标要窄
|
||||
expect(chip.getAttribute('title')).toContain('已用 7/20');
|
||||
});
|
||||
|
||||
it('剩余 2 个时还不转橙(阈值是 <= 1)', () => {
|
||||
render(React.createElement(BudgetChip, { max: 10, used: 8 }));
|
||||
|
||||
const chip = screen.getByText('2/10').closest('span')!;
|
||||
expect(chip.className).toContain('bg-gray-100');
|
||||
});
|
||||
});
|
||||
203
client/electron/test/components/PermissionPanel.test.tsx
Normal file
203
client/electron/test/components/PermissionPanel.test.tsx
Normal file
@ -0,0 +1,203 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import { PermissionPanel } from '../../src/components/MailView';
|
||||
import * as api from '../../src/api/client';
|
||||
import { useMailStore } from '../../src/stores/mailStore';
|
||||
import { useSessionStore } from '../../src/stores/sessionStore';
|
||||
import type { Mail } from '../../src/types';
|
||||
|
||||
/**
|
||||
* 权限决策面板。
|
||||
*
|
||||
* 这是全站唯一一处「人的一次点击直接放行 Agent 的危险操作」,
|
||||
* 因此断言集中在两件事:
|
||||
* - 点下去到底把什么发给了服务端(选项原文,不是归一化后的 allow/deny)
|
||||
* - 已决策的请求不能再点第二次
|
||||
*/
|
||||
|
||||
function permMail(over: Partial<Mail> = {}): Mail {
|
||||
return {
|
||||
mail_id: 'm-1',
|
||||
session_id: 's-1',
|
||||
parent_mail_id: null,
|
||||
from_name: 'dsh',
|
||||
from_workspace: '/home/program/agentmail',
|
||||
to_name: 'admin',
|
||||
to_workspace: '',
|
||||
cc_list: [],
|
||||
subject: '请求批准:删除 build/',
|
||||
body: '将执行 rm -rf build/',
|
||||
mail_type: 'permission_request',
|
||||
permission_options: undefined,
|
||||
permission_result: '',
|
||||
status: 'unread',
|
||||
created_at: '2026-09-03T00:00:00Z',
|
||||
hop_limit: 5,
|
||||
...over
|
||||
} as Mail;
|
||||
}
|
||||
|
||||
describe('PermissionPanel 决策', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
// fetchInbox / selectSession 会打网络,替换成空实现
|
||||
useMailStore.setState({ fetchInbox: vi.fn(async () => {}) } as any);
|
||||
useSessionStore.setState({ selectSession: vi.fn(async () => {}) } as any);
|
||||
});
|
||||
|
||||
it('没有 permission_options 时给默认的同意/拒绝两个选项', () => {
|
||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||
|
||||
expect(screen.getByRole('button', { name: /同意/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /拒绝/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('有 permission_options 时用它,且顺序保持', () => {
|
||||
render(
|
||||
React.createElement(PermissionPanel, {
|
||||
mail: permMail({ permission_options: ['只这一次', '总是允许', '拒绝'] })
|
||||
})
|
||||
);
|
||||
|
||||
const btns = screen.getAllByRole('button').map(b => b.textContent?.trim());
|
||||
// 顺序是 Agent 给的语义顺序,重排会让「拒绝」跑到人的手指默认位置上
|
||||
expect(btns).toEqual(['只这一次', '总是允许', '拒绝']);
|
||||
});
|
||||
|
||||
it('点选项时把【选项原文】发给服务端', async () => {
|
||||
const spy = vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
|
||||
render(
|
||||
React.createElement(PermissionPanel, {
|
||||
mail: permMail({ permission_options: ['只这一次', '拒绝'] })
|
||||
})
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /只这一次/ }));
|
||||
|
||||
// 关键:不能归一化成 allow/deny —— 「只这一次」与「总是允许」的区别
|
||||
// 只有 Agent 侧的权限机制懂,服务端与前端都不该替它翻译
|
||||
await waitFor(() =>
|
||||
expect(spy).toHaveBeenCalledWith('m-1', '只这一次', undefined)
|
||||
);
|
||||
});
|
||||
|
||||
it('填了备注时一起发出去', async () => {
|
||||
const spy = vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
|
||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText('备注(可选)'), '只删 build,别动 dist');
|
||||
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(spy).toHaveBeenCalledWith('m-1', '同意', '只删 build,别动 dist')
|
||||
);
|
||||
});
|
||||
|
||||
it('备注为空时传 undefined 而不是空字符串', async () => {
|
||||
const spy = vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
|
||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
|
||||
|
||||
// 空串会在决策邮件里留一行空的「备注:」
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledWith('m-1', '同意', undefined));
|
||||
});
|
||||
|
||||
it('决策后变成「已处理」,不再显示按钮', async () => {
|
||||
vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
|
||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('已处理:')).toBeInTheDocument());
|
||||
// 还能点第二次的话人会以为第一次没生效,而服务端那边早已决策
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
});
|
||||
|
||||
it('已经有 permission_result 的邮件直接显示结论', () => {
|
||||
render(
|
||||
React.createElement(PermissionPanel, {
|
||||
mail: permMail({ permission_result: '拒绝' })
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByText('拒绝')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
});
|
||||
|
||||
it('提交中禁用所有按钮,避免重复决策', async () => {
|
||||
let release: (v: any) => void = () => {};
|
||||
vi.spyOn(api, 'decidePermission').mockReturnValue(
|
||||
new Promise(res => {
|
||||
release = res;
|
||||
}) as any
|
||||
);
|
||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
|
||||
|
||||
// 一次危险操作被批准两次,Agent 那边可能真的执行两遍
|
||||
await waitFor(() => {
|
||||
for (const b of screen.getAllByRole('button')) {
|
||||
expect(b).toBeDisabled();
|
||||
}
|
||||
});
|
||||
|
||||
// 收尾:让悬挂的 Promise 落定并等状态更新走完,
|
||||
// 否则组件在测试结束后才 setState,React 会报 act 警告
|
||||
await act(async () => {
|
||||
release({ status: 'decided' });
|
||||
});
|
||||
await waitFor(() => expect(screen.getByText('已处理:')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('提交失败时恢复可点,不假装已决策', async () => {
|
||||
vi.spyOn(api, 'decidePermission').mockRejectedValue(new Error('500'));
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
|
||||
|
||||
// 失败后显示「已处理」是最糟的结果:人以为批过了,Agent 还在等
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /同意/ })).toBeEnabled());
|
||||
expect(screen.queryByText('已处理:')).toBeNull();
|
||||
});
|
||||
|
||||
it('决策成功后刷新收件箱并选中该会话', async () => {
|
||||
vi.spyOn(api, 'decidePermission').mockResolvedValue({ status: 'decided' } as any);
|
||||
const fetchInbox = vi.fn(async () => {});
|
||||
const selectSession = vi.fn(async () => {});
|
||||
useMailStore.setState({ fetchInbox } as any);
|
||||
useSessionStore.setState({ selectSession } as any);
|
||||
|
||||
render(React.createElement(PermissionPanel, { mail: permMail() }));
|
||||
await userEvent.click(screen.getByRole('button', { name: /同意/ }));
|
||||
|
||||
// 不刷新的话列表里那封还是「未读的权限请求」,人会以为没生效
|
||||
await waitFor(() => {
|
||||
expect(fetchInbox).toHaveBeenCalledWith('all');
|
||||
expect(selectSession).toHaveBeenCalledWith('s-1');
|
||||
});
|
||||
});
|
||||
|
||||
it('同意类选项用绿色,其余用红色', () => {
|
||||
render(
|
||||
React.createElement(PermissionPanel, {
|
||||
mail: permMail({ permission_options: ['允许', 'approve', '拒绝', '算了'] })
|
||||
})
|
||||
);
|
||||
|
||||
const cls = (name: string) =>
|
||||
screen.getByRole('button', { name: new RegExp(name) }).className;
|
||||
|
||||
// 颜色是唯一的视觉提示:点错一次就放行了一个危险操作
|
||||
expect(cls('允许')).toContain('bg-green-700');
|
||||
expect(cls('approve')).toContain('bg-green-700');
|
||||
expect(cls('拒绝')).toContain('text-red-700');
|
||||
// 不在同意词表里的一律按「否」处理 —— 宁可让人多看一眼
|
||||
expect(cls('算了')).toContain('text-red-700');
|
||||
});
|
||||
});
|
||||
281
client/electron/test/components/calendar.test.tsx
Normal file
281
client/electron/test/components/calendar.test.tsx
Normal file
@ -0,0 +1,281 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
startOfDay, endOfDay, startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||
isSameDay, addDays, addMonths, monthGrid, weekDays, remindAt,
|
||||
bucketByDay, dayKey, renderReminder, toLocalInput, fromLocalInput,
|
||||
describeRecurrence, describeRemindBefore
|
||||
} from '../../src/lib/calendar';
|
||||
import type { CalendarEvent } from '../../src/types';
|
||||
|
||||
let seq = 0;
|
||||
function ev(over: Partial<CalendarEvent> = {}): CalendarEvent {
|
||||
seq += 1;
|
||||
return {
|
||||
event_id: `e${String(seq).padStart(3, '0')}`,
|
||||
title: `事件 ${seq}`,
|
||||
description: '',
|
||||
reminder_text: '',
|
||||
agent_name: 'pi',
|
||||
to_address: '',
|
||||
recipients: [],
|
||||
delivery_mode: 'separate',
|
||||
event_time: '2026-09-03T14:30:00+08:00',
|
||||
remind_before: 0,
|
||||
recurrence: 'none',
|
||||
status: 'active',
|
||||
created_at: '2026-09-01T00:00:00Z',
|
||||
updated_at: '2026-09-01T00:00:00Z',
|
||||
created_by: 'jianf',
|
||||
...over
|
||||
};
|
||||
}
|
||||
|
||||
describe('日期边界', () => {
|
||||
it('startOfDay / endOfDay 用本地时区', () => {
|
||||
const d = new Date(2026, 8, 3, 14, 30, 45, 123);
|
||||
expect(startOfDay(d).getHours()).toBe(0);
|
||||
expect(startOfDay(d).getMilliseconds()).toBe(0);
|
||||
expect(endOfDay(d).getHours()).toBe(23);
|
||||
expect(endOfDay(d).getMilliseconds()).toBe(999);
|
||||
// 不修改入参
|
||||
expect(d.getHours()).toBe(14);
|
||||
});
|
||||
|
||||
it('startOfMonth / endOfMonth', () => {
|
||||
const d = new Date(2026, 8, 15);
|
||||
expect(startOfMonth(d).getDate()).toBe(1);
|
||||
expect(endOfMonth(d).getDate()).toBe(30); // 9 月 30 天
|
||||
expect(endOfMonth(new Date(2026, 1, 5)).getDate()).toBe(28); // 2026 年 2 月
|
||||
});
|
||||
|
||||
it('周从周一开始,周日归到上一周末尾', () => {
|
||||
// 2026-09-06 是周日
|
||||
const sunday = new Date(2026, 8, 6);
|
||||
expect(sunday.getDay()).toBe(0);
|
||||
// 它所在周的周一应是 08-31,不是 09-07
|
||||
expect(dayKey(startOfWeek(sunday))).toBe('2026-08-31');
|
||||
expect(dayKey(endOfWeek(sunday))).toBe('2026-09-06');
|
||||
});
|
||||
|
||||
it('周一自己就是周首', () => {
|
||||
const monday = new Date(2026, 8, 7);
|
||||
expect(monday.getDay()).toBe(1);
|
||||
expect(dayKey(startOfWeek(monday))).toBe('2026-09-07');
|
||||
});
|
||||
});
|
||||
|
||||
describe('日期运算', () => {
|
||||
it('addDays 不修改入参', () => {
|
||||
const d = new Date(2026, 8, 3);
|
||||
const r = addDays(d, 5);
|
||||
expect(dayKey(r)).toBe('2026-09-08');
|
||||
expect(dayKey(d)).toBe('2026-09-03');
|
||||
});
|
||||
|
||||
it('addDays 跨月', () => {
|
||||
expect(dayKey(addDays(new Date(2026, 8, 29), 5))).toBe('2026-10-04');
|
||||
});
|
||||
|
||||
it('addMonths 从月末加一个月不会溢出', () => {
|
||||
// setMonth 在 1 月 31 日上加一个月会给 3 月 2/3 日
|
||||
const jan31 = new Date(2026, 0, 31);
|
||||
expect(dayKey(addMonths(jan31, 1))).toBe('2026-02-01');
|
||||
});
|
||||
|
||||
it('isSameDay 只比年月日', () => {
|
||||
expect(isSameDay(new Date(2026, 8, 3, 0, 0), new Date(2026, 8, 3, 23, 59))).toBe(true);
|
||||
expect(isSameDay(new Date(2026, 8, 3), new Date(2026, 8, 4))).toBe(false);
|
||||
expect(isSameDay(new Date(2026, 8, 3), new Date(2025, 8, 3))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('monthGrid', () => {
|
||||
it('固定 42 格(6 行 × 7 列)', () => {
|
||||
// 行数变化会让网格高度跳动,翻月时页面内容上下弹
|
||||
expect(monthGrid(new Date(2026, 8, 15))).toHaveLength(42);
|
||||
expect(monthGrid(new Date(2026, 1, 15))).toHaveLength(42);
|
||||
});
|
||||
|
||||
it('首格是月首所在周的周一', () => {
|
||||
// 2026-09-01 是周二 → 首格应是 08-31(周一)
|
||||
const cells = monthGrid(new Date(2026, 8, 15));
|
||||
expect(dayKey(cells[0])).toBe('2026-08-31');
|
||||
});
|
||||
|
||||
it('格子连续无空洞', () => {
|
||||
const cells = monthGrid(new Date(2026, 8, 15));
|
||||
for (let i = 1; i < cells.length; i++) {
|
||||
const diff = cells[i].getTime() - cells[i - 1].getTime();
|
||||
// 允许夏令时造成的 ±1 小时偏差
|
||||
expect(diff).toBeGreaterThanOrEqual(23 * 3600_000);
|
||||
expect(diff).toBeLessThanOrEqual(25 * 3600_000);
|
||||
}
|
||||
});
|
||||
|
||||
it('包含整个当月', () => {
|
||||
const anchor = new Date(2026, 8, 15);
|
||||
const keys = monthGrid(anchor).map(dayKey);
|
||||
expect(keys).toContain('2026-09-01');
|
||||
expect(keys).toContain('2026-09-30');
|
||||
});
|
||||
});
|
||||
|
||||
describe('weekDays', () => {
|
||||
it('给 7 天,周一起头', () => {
|
||||
const days = weekDays(new Date(2026, 8, 3)); // 周四
|
||||
expect(days).toHaveLength(7);
|
||||
expect(dayKey(days[0])).toBe('2026-08-31');
|
||||
expect(dayKey(days[6])).toBe('2026-09-06');
|
||||
});
|
||||
});
|
||||
|
||||
describe('remindAt', () => {
|
||||
it('提前 30 分钟', () => {
|
||||
const e = ev({ event_time: '2026-09-03T14:30:00+08:00', remind_before: 30 });
|
||||
expect(remindAt(e).toISOString()).toBe('2026-09-03T06:00:00.000Z');
|
||||
});
|
||||
|
||||
it('remind_before 为 0 时就是事件时间', () => {
|
||||
const e = ev({ event_time: '2026-09-03T14:30:00+08:00', remind_before: 0 });
|
||||
expect(remindAt(e).toISOString()).toBe('2026-09-03T06:30:00.000Z');
|
||||
});
|
||||
|
||||
it('提前一整天', () => {
|
||||
const e = ev({ event_time: '2026-09-03T14:30:00+08:00', remind_before: 1440 });
|
||||
expect(remindAt(e).toISOString()).toBe('2026-09-02T06:30:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bucketByDay', () => {
|
||||
it('按本地日期分桶,不用 UTC 前缀', () => {
|
||||
// 东八区 22:00 → UTC 是前一天 14:00。用 toISOString().slice(0,10) 会归错天
|
||||
const e = ev({ event_time: '2026-09-03T22:00:00+08:00' });
|
||||
const m = bucketByDay([e]);
|
||||
expect([...m.keys()]).toEqual(['2026-09-03']);
|
||||
});
|
||||
|
||||
it('同一天内按时间正序', () => {
|
||||
const late = ev({ event_time: '2026-09-03T18:00:00+08:00', title: '晚' });
|
||||
const early = ev({ event_time: '2026-09-03T09:00:00+08:00', title: '早' });
|
||||
const m = bucketByDay([late, early]);
|
||||
expect(m.get('2026-09-03')!.map(x => x.title)).toEqual(['早', '晚']);
|
||||
});
|
||||
|
||||
it('同刻事件用 event_id 兜底定序', () => {
|
||||
const ts = '2026-09-03T09:00:00+08:00';
|
||||
const a = ev({ event_id: 'aaa', event_time: ts });
|
||||
const z = ev({ event_id: 'zzz', event_time: ts });
|
||||
const m1 = bucketByDay([a, z]).get('2026-09-03')!.map(x => x.event_id);
|
||||
const m2 = bucketByDay([z, a]).get('2026-09-03')!.map(x => x.event_id);
|
||||
expect(m1).toEqual(m2);
|
||||
expect(m1).toEqual(['aaa', 'zzz']);
|
||||
});
|
||||
|
||||
it('时间解析失败的脏数据被跳过而不是让整个视图空白', () => {
|
||||
const bad = ev({ event_time: '不是时间' });
|
||||
const good = ev({ event_time: '2026-09-03T09:00:00+08:00' });
|
||||
const m = bucketByDay([bad, good]);
|
||||
expect([...m.keys()]).toEqual(['2026-09-03']);
|
||||
});
|
||||
|
||||
it('空输入给空 Map', () => {
|
||||
expect(bucketByDay([]).size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderReminder', () => {
|
||||
it('替换三个变量', () => {
|
||||
const out = renderReminder('【{title}】{time} — {description}', {
|
||||
title: '发布评审',
|
||||
description: '看 llmsproxy 的部署脚本',
|
||||
event_time: '2026-09-03T14:30:00+08:00'
|
||||
});
|
||||
expect(out).toBe('【发布评审】2026-09-03 14:30 — 看 llmsproxy 的部署脚本');
|
||||
});
|
||||
|
||||
it('同一变量出现多次全部替换', () => {
|
||||
// replaceAll 而非 replace:后者只换第一个
|
||||
const out = renderReminder('{title} / {title}', {
|
||||
title: 'X',
|
||||
event_time: '2026-09-03T14:30:00+08:00'
|
||||
});
|
||||
expect(out).toBe('X / X');
|
||||
});
|
||||
|
||||
it('description 缺失时替换成空串而不是 undefined', () => {
|
||||
const out = renderReminder('{description}|', {
|
||||
title: 'T',
|
||||
event_time: '2026-09-03T14:30:00+08:00'
|
||||
});
|
||||
expect(out).toBe('|');
|
||||
});
|
||||
|
||||
it('没有变量的模板原样返回', () => {
|
||||
const out = renderReminder('纯文本提醒', {
|
||||
title: 'T',
|
||||
event_time: '2026-09-03T14:30:00+08:00'
|
||||
});
|
||||
expect(out).toBe('纯文本提醒');
|
||||
});
|
||||
|
||||
it('时间解析失败时回退到原始串', () => {
|
||||
const out = renderReminder('{time}', { title: 'T', event_time: '坏时间' });
|
||||
expect(out).toBe('坏时间');
|
||||
});
|
||||
});
|
||||
|
||||
describe('datetime-local 往返', () => {
|
||||
it('toLocalInput 给本地时区的 YYYY-MM-DDTHH:mm', () => {
|
||||
const d = new Date(2026, 8, 3, 14, 30);
|
||||
expect(toLocalInput(d)).toBe('2026-09-03T14:30');
|
||||
});
|
||||
|
||||
it('补零', () => {
|
||||
const d = new Date(2026, 0, 5, 9, 5);
|
||||
expect(toLocalInput(d)).toBe('2026-01-05T09:05');
|
||||
});
|
||||
|
||||
it('fromLocalInput 按本地时区解析(人写的就是本地时间)', () => {
|
||||
const iso = fromLocalInput('2026-09-03T14:30');
|
||||
// 结果应与本地构造的 Date 一致
|
||||
expect(iso).toBe(new Date(2026, 8, 3, 14, 30).toISOString());
|
||||
});
|
||||
|
||||
it('往返不丢分钟', () => {
|
||||
const original = new Date(2026, 8, 3, 14, 30);
|
||||
expect(toLocalInput(new Date(fromLocalInput(toLocalInput(original))))).toBe('2026-09-03T14:30');
|
||||
});
|
||||
|
||||
it('非法输入给空串', () => {
|
||||
expect(fromLocalInput('')).toBe('');
|
||||
expect(fromLocalInput('坏值')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('人类可读描述', () => {
|
||||
it('重复规则', () => {
|
||||
expect(describeRecurrence(ev({ recurrence: 'none' }))).toBe('不重复');
|
||||
expect(describeRecurrence(ev({ recurrence: 'daily' }))).toBe('每天');
|
||||
expect(describeRecurrence(ev({ recurrence: 'weekly' }))).toBe('每周');
|
||||
expect(describeRecurrence(ev({ recurrence: 'monthly' }))).toBe('每月');
|
||||
});
|
||||
|
||||
it('带终止时间', () => {
|
||||
const e = ev({ recurrence: 'daily', recurrence_end: '2026-12-31T00:00:00+08:00' });
|
||||
expect(describeRecurrence(e)).toBe('每天,至 2026-12-31');
|
||||
});
|
||||
|
||||
it('none 时忽略 recurrence_end', () => {
|
||||
const e = ev({ recurrence: 'none', recurrence_end: '2026-12-31T00:00:00+08:00' });
|
||||
expect(describeRecurrence(e)).toBe('不重复');
|
||||
});
|
||||
|
||||
it('提前提醒', () => {
|
||||
expect(describeRemindBefore(0)).toBe('到点提醒');
|
||||
expect(describeRemindBefore(15)).toBe('提前 15 分钟');
|
||||
expect(describeRemindBefore(60)).toBe('提前 1 小时');
|
||||
expect(describeRemindBefore(120)).toBe('提前 2 小时');
|
||||
expect(describeRemindBefore(90)).toBe('提前 1 小时 30 分钟');
|
||||
expect(describeRemindBefore(1440)).toBe('提前 24 小时');
|
||||
});
|
||||
});
|
||||
268
client/electron/test/components/lunar.test.tsx
Normal file
268
client/electron/test/components/lunar.test.tsx
Normal file
@ -0,0 +1,268 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
fromSolar, toSolar, leapMonth, daysInMonth,
|
||||
addLunarMonths, addLunarYears,
|
||||
formatLunarFull, formatLunarShort, cellLunarLabel, lunarDayName,
|
||||
formatSolarWithLunar, isLunarRecurrence,
|
||||
nextOccurrence, addSolarMonthsClamped, upcomingOccurrences,
|
||||
describeRecurrenceRule
|
||||
} from '../../src/lib/lunar';
|
||||
|
||||
describe('公历 ↔ 农历', () => {
|
||||
it('已知锚点', () => {
|
||||
const d = fromSolar(new Date(2026, 8, 3));
|
||||
expect(d).toEqual({ year: 2026, month: 7, day: 22 });
|
||||
});
|
||||
|
||||
it('闰月用负数月份表示', () => {
|
||||
// 2025 有闰六月
|
||||
expect(leapMonth(2025)).toBe(6);
|
||||
const d = fromSolar(new Date(2025, 6, 25)); // 2025-07-25
|
||||
expect(d.month).toBe(-6);
|
||||
expect(d.day).toBe(1);
|
||||
});
|
||||
|
||||
it('无闰月的年份返回 0', () => {
|
||||
expect(leapMonth(2026)).toBe(0);
|
||||
expect(leapMonth(2027)).toBe(0);
|
||||
});
|
||||
|
||||
it('往返不丢日期', () => {
|
||||
for (const [y, m, dd] of [[2026, 8, 3], [2027, 1, 14], [2025, 6, 25]] as const) {
|
||||
const solar = new Date(y, m, dd);
|
||||
const lunar = fromSolar(solar);
|
||||
const back = toSolar(lunar, 9, 30);
|
||||
expect(back).not.toBeNull();
|
||||
expect(back!.clamped).toBe(false);
|
||||
expect(back!.date.getFullYear()).toBe(y);
|
||||
expect(back!.date.getMonth()).toBe(m);
|
||||
expect(back!.date.getDate()).toBe(dd);
|
||||
// 时钟原样带过去(农历只定义到「日」)
|
||||
expect(back!.date.getHours()).toBe(9);
|
||||
expect(back!.date.getMinutes()).toBe(30);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('短月份夹取', () => {
|
||||
it('2027 农历九月只有 29 天', () => {
|
||||
expect(daysInMonth(2027, 9)).toBe(29);
|
||||
expect(daysInMonth(2026, 1)).toBe(30);
|
||||
});
|
||||
|
||||
// 库在 Lunar.fromYmd(2027,9,30) 上直接 throw。
|
||||
// 「每月农历三十」必然撞上 29 天的月份,不夹住就是整片白屏。
|
||||
it('要三十而该月只有廿九时夹到廿九,不抛错', () => {
|
||||
const r = toSolar({ year: 2027, month: 9, day: 30 }, 9, 0);
|
||||
expect(r).not.toBeNull();
|
||||
expect(r!.clamped).toBe(true);
|
||||
// 夹取后必须仍在同一个农历月内(滚到下月初一是错的)
|
||||
expect(fromSolar(r!.date).month).toBe(9);
|
||||
expect(fromSolar(r!.date).day).toBe(29);
|
||||
});
|
||||
|
||||
it('不存在的闰月返回 null 而不是猜一个日子', () => {
|
||||
expect(daysInMonth(2026, -6)).toBe(0);
|
||||
expect(toSolar({ year: 2026, month: -6, day: 1 })).toBeNull();
|
||||
});
|
||||
|
||||
it('非法日返回 null', () => {
|
||||
expect(toSolar({ year: 2026, month: 1, day: 0 })).toBeNull();
|
||||
expect(toSolar({ year: 2026, month: 13, day: 1 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('农历推进', () => {
|
||||
it('加月跨年', () => {
|
||||
expect(addLunarMonths({ year: 2026, month: 12, day: 5 }, 1)).toEqual({ year: 2027, month: 1, day: 5 });
|
||||
});
|
||||
|
||||
it('推 12 次回到次年同月', () => {
|
||||
expect(addLunarMonths({ year: 2026, month: 7, day: 22 }, 12))
|
||||
.toEqual({ year: 2027, month: 7, day: 22 });
|
||||
});
|
||||
|
||||
// 「每月十五」的期望是一年 12 次,把闰月算进去会让闰年多一次
|
||||
it('从闰月出发先归正月份', () => {
|
||||
expect(addLunarMonths({ year: 2025, month: -6, day: 15 }, 1).month).toBe(7);
|
||||
});
|
||||
|
||||
it('加年保持月日', () => {
|
||||
expect(addLunarYears({ year: 2026, month: 7, day: 22 }, 1))
|
||||
.toEqual({ year: 2027, month: 7, day: 22 });
|
||||
});
|
||||
|
||||
// 静默让重复事件消失比退回正月份更糟
|
||||
it('目标年无同一闰月时退回正月份', () => {
|
||||
expect(addLunarYears({ year: 2025, month: -6, day: 15 }, 1))
|
||||
.toEqual({ year: 2026, month: 6, day: 15 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('中文表示', () => {
|
||||
it('完整表示', () => {
|
||||
expect(formatLunarFull({ year: 2026, month: 7, day: 22 })).toBe('二〇二六年七月廿二');
|
||||
});
|
||||
|
||||
it('闰月带「闰」字', () => {
|
||||
expect(formatLunarFull({ year: 2025, month: -6, day: 1 })).toBe('二〇二五年闰六月初一');
|
||||
});
|
||||
|
||||
it('短表示去掉年份', () => {
|
||||
expect(formatLunarShort({ year: 2026, month: 7, day: 22 })).toBe('七月廿二');
|
||||
});
|
||||
|
||||
// 日名有五种前缀形态(初一/十一/二十/廿一/三十),
|
||||
// 用正则截取时「二十」曾被漏掉
|
||||
it('日名查表覆盖全部 30 天', () => {
|
||||
const names = Array.from({ length: 30 }, (_, i) => lunarDayName(i + 1));
|
||||
expect(names[0]).toBe('初一');
|
||||
expect(names[9]).toBe('初十');
|
||||
expect(names[10]).toBe('十一');
|
||||
expect(names[19]).toBe('二十');
|
||||
expect(names[20]).toBe('廿一');
|
||||
expect(names[29]).toBe('三十');
|
||||
expect(new Set(names).size).toBe(30); // 无重复
|
||||
expect(names.every(n => n.length === 2)).toBe(true);
|
||||
});
|
||||
|
||||
it('格子标记:初一显示月名,其余显示日名', () => {
|
||||
// 2026-08-13 是农历七月初一
|
||||
const firstDay = toSolar({ year: 2026, month: 7, day: 1 })!.date;
|
||||
expect(cellLunarLabel(firstDay)).toBe('七月');
|
||||
expect(cellLunarLabel(new Date(2026, 8, 3))).toBe('廿二');
|
||||
});
|
||||
|
||||
it('闰月初一的格子标记带「闰」', () => {
|
||||
const leapFirst = toSolar({ year: 2025, month: -6, day: 1 })!.date;
|
||||
expect(cellLunarLabel(leapFirst)).toBe('闰六月');
|
||||
});
|
||||
|
||||
it('公历+农历合并显示', () => {
|
||||
expect(formatSolarWithLunar(new Date(2026, 8, 3))).toBe('2026-09-03(农历七月廿二)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextOccurrence 与后端 repo.NextOccurrence 对齐', () => {
|
||||
const base = new Date(2026, 8, 3, 9, 30);
|
||||
|
||||
it('公历三种', () => {
|
||||
expect(nextOccurrence('daily', base)!.getDate()).toBe(4);
|
||||
expect(nextOccurrence('weekly', base)!.getDate()).toBe(10);
|
||||
expect(nextOccurrence('monthly', base)!.getMonth()).toBe(9);
|
||||
});
|
||||
|
||||
it('公历每年', () => {
|
||||
const n = nextOccurrence('yearly', base)!;
|
||||
expect(n.getFullYear()).toBe(2027);
|
||||
expect(n.getMonth()).toBe(8);
|
||||
expect(n.getDate()).toBe(3);
|
||||
});
|
||||
|
||||
it('none 与未知值给 null', () => {
|
||||
expect(nextOccurrence('none', base)).toBeNull();
|
||||
expect(nextOccurrence('每隔一个蓝月亮', base)).toBeNull();
|
||||
});
|
||||
|
||||
it('时钟保留', () => {
|
||||
for (const r of ['daily', 'weekly', 'monthly', 'yearly', 'lunar_monthly', 'lunar_yearly']) {
|
||||
const n = nextOccurrence(r, base);
|
||||
expect(n).not.toBeNull();
|
||||
expect(n!.getHours()).toBe(9);
|
||||
expect(n!.getMinutes()).toBe(30);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// setMonth 的溢出(3月31日 +1月 = 5月1日)会永久改变规则:
|
||||
// 31 日的事件在 2 月变成 3 月 3 日,然后从此每月 3 日提醒
|
||||
describe('公历月末夹取', () => {
|
||||
it.each([
|
||||
['2026-01-31', 1, '2026-02-28'],
|
||||
['2026-03-31', 1, '2026-04-30'],
|
||||
['2028-01-31', 1, '2028-02-29'],
|
||||
['2028-02-29', 12, '2029-02-28'],
|
||||
['2026-01-15', 1, '2026-02-15']
|
||||
])('%s + %i 月 → %s', (from, n, want) => {
|
||||
const [y, m, d] = from.split('-').map(Number);
|
||||
const got = addSolarMonthsClamped(new Date(y, m - 1, d), n);
|
||||
const pad = (x: number) => String(x).padStart(2, '0');
|
||||
expect(`${got.getFullYear()}-${pad(got.getMonth() + 1)}-${pad(got.getDate())}`).toBe(want);
|
||||
});
|
||||
});
|
||||
|
||||
describe('农历重复的公历漂移', () => {
|
||||
it('农历月间隔在 29~30 天之间浮动,不是固定值', () => {
|
||||
let cur = new Date(2026, 8, 3, 9, 0);
|
||||
const gaps = new Set<number>();
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const next = nextOccurrence('lunar_monthly', cur)!;
|
||||
expect(next.getTime()).toBeGreaterThan(cur.getTime());
|
||||
gaps.add(Math.round((next.getTime() - cur.getTime()) / 86400000));
|
||||
// 农历「日」保持不变
|
||||
expect(fromSolar(next).day).toBe(22);
|
||||
cur = next;
|
||||
}
|
||||
expect(gaps.size).toBeGreaterThan(1);
|
||||
for (const g of gaps) expect(g).toBeGreaterThanOrEqual(28);
|
||||
for (const g of gaps) expect(g).toBeLessThanOrEqual(31);
|
||||
});
|
||||
|
||||
// 这是农历规则存在的理由:用公历 yearly 日子会固定,
|
||||
// 与「过农历生日/祭日」的期望不符
|
||||
it('农历年推进时公历月日每年都变', () => {
|
||||
let cur = new Date(2026, 8, 3, 9, 0);
|
||||
const seen = new Set<string>();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const next = nextOccurrence('lunar_yearly', cur)!;
|
||||
const d = fromSolar(next);
|
||||
expect(d.month).toBe(7);
|
||||
expect(d.day).toBe(22);
|
||||
seen.add(`${next.getMonth() + 1}-${next.getDate()}`);
|
||||
cur = next;
|
||||
}
|
||||
expect(seen.size).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('upcomingOccurrences', () => {
|
||||
it('给出连续递增的 n 次', () => {
|
||||
const list = upcomingOccurrences('lunar_monthly', new Date(2026, 8, 3, 9, 0), 3);
|
||||
expect(list).toHaveLength(3);
|
||||
for (let i = 1; i < list.length; i++) {
|
||||
expect(list[i].getTime()).toBeGreaterThan(list[i - 1].getTime());
|
||||
}
|
||||
});
|
||||
|
||||
it('不重复时给空数组', () => {
|
||||
expect(upcomingOccurrences('none', new Date(), 3)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeRecurrenceRule', () => {
|
||||
it('公历规则', () => {
|
||||
expect(describeRecurrenceRule('none')).toBe('不重复');
|
||||
expect(describeRecurrenceRule('daily')).toBe('每天');
|
||||
expect(describeRecurrenceRule('weekly')).toBe('每周');
|
||||
expect(describeRecurrenceRule('monthly')).toBe('每月');
|
||||
expect(describeRecurrenceRule('yearly')).toBe('每年');
|
||||
});
|
||||
|
||||
it('农历规则把农历日子写出来', () => {
|
||||
const t = new Date(2026, 8, 3);
|
||||
expect(describeRecurrenceRule('lunar_monthly', t)).toBe('每农历月廿二');
|
||||
expect(describeRecurrenceRule('lunar_yearly', t)).toBe('每年农历七月廿二');
|
||||
});
|
||||
|
||||
it('无事件时间时退回泛化描述', () => {
|
||||
expect(describeRecurrenceRule('lunar_monthly')).toBe('每农历月同一日');
|
||||
expect(describeRecurrenceRule('lunar_yearly')).toBe('每农历年同月同日');
|
||||
});
|
||||
|
||||
it('isLunarRecurrence', () => {
|
||||
expect(isLunarRecurrence('lunar_monthly')).toBe(true);
|
||||
expect(isLunarRecurrence('lunar_yearly')).toBe(true);
|
||||
expect(isLunarRecurrence('monthly')).toBe(false);
|
||||
expect(isLunarRecurrence('yearly')).toBe(false);
|
||||
});
|
||||
});
|
||||
333
client/electron/test/components/mailGroups.test.tsx
Normal file
333
client/electron/test/components/mailGroups.test.tsx
Normal file
@ -0,0 +1,333 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
groupMailsBySession,
|
||||
isFlatGroup,
|
||||
isPendingPermission,
|
||||
splitByPermission,
|
||||
countPendingPermissions,
|
||||
groupPermissions
|
||||
} from '../../src/lib/mailGroups';
|
||||
import type { Mail } from '../../src/types';
|
||||
|
||||
/**
|
||||
* 收件箱会话分组 + 授权请求独立分组。
|
||||
*
|
||||
* 这些用例锁的是生产实测过的形状:一个会话独占 17 封权限邮件,
|
||||
* 把另外两个会话的信挤出视野。修法有两层 ——
|
||||
* 权限请求整体移出收件箱(各归各的导航项),剩下的普通邮件按会话折叠。
|
||||
*/
|
||||
|
||||
let seq = 0;
|
||||
function mail(over: Partial<Mail> = {}): Mail {
|
||||
seq += 1;
|
||||
return {
|
||||
mail_id: `m${String(seq).padStart(3, '0')}`,
|
||||
session_id: 's1',
|
||||
parent_mail_id: null,
|
||||
from_name: 'pi',
|
||||
from_workspace: '/home',
|
||||
to_name: 'jianf',
|
||||
to_workspace: '',
|
||||
cc_list: [],
|
||||
subject: `主题 ${seq}`,
|
||||
body: '正文',
|
||||
mail_type: 'normal',
|
||||
permission_options: null,
|
||||
permission_result: null,
|
||||
status: 'read',
|
||||
created_at: `2026-09-03T10:${String(seq % 60).padStart(2, '0')}:00Z`,
|
||||
from_human: false,
|
||||
to_human: true,
|
||||
...over
|
||||
};
|
||||
}
|
||||
|
||||
function perm(over: Partial<Mail> = {}): Mail {
|
||||
return mail({ mail_type: 'permission_request', ...over });
|
||||
}
|
||||
|
||||
describe('groupMailsBySession', () => {
|
||||
it('把同一会话的多封邮件折成一组', () => {
|
||||
const groups = groupMailsBySession([
|
||||
mail({ session_id: 'a' }),
|
||||
mail({ session_id: 'a' }),
|
||||
mail({ session_id: 'a' }),
|
||||
mail({ session_id: 'b' })
|
||||
]);
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups.find(g => g.sessionId === 'a')!.mails).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('组内按时间倒序,最新那封做组头', () => {
|
||||
const old = mail({ session_id: 'a', created_at: '2026-09-03T08:00:00Z', subject: '旧' });
|
||||
const mid = mail({ session_id: 'a', created_at: '2026-09-03T09:00:00Z', subject: '中' });
|
||||
const now = mail({ session_id: 'a', created_at: '2026-09-03T10:00:00Z', subject: '新' });
|
||||
const [g] = groupMailsBySession([mid, old, now]); // 故意乱序传入
|
||||
expect(g.mails.map(m => m.subject)).toEqual(['新', '中', '旧']);
|
||||
expect(g.latest.subject).toBe('新');
|
||||
// 组标题取最新一封:会话主题随任务推进被改写,最新的最贴切
|
||||
expect(g.subject).toBe('新');
|
||||
});
|
||||
|
||||
it('组之间按最新邮件时间倒序', () => {
|
||||
const groups = groupMailsBySession([
|
||||
mail({ session_id: 'stale', created_at: '2026-09-01T10:00:00Z' }),
|
||||
mail({ session_id: 'fresh', created_at: '2026-09-03T10:00:00Z' }),
|
||||
mail({ session_id: 'mid', created_at: '2026-09-02T10:00:00Z' })
|
||||
]);
|
||||
expect(groups.map(g => g.sessionId)).toEqual(['fresh', 'mid', 'stale']);
|
||||
});
|
||||
|
||||
it('同一时刻用 mail_id 兜底定序(SQLite 时间戳精度有限)', () => {
|
||||
const ts = '2026-09-03T10:00:00Z';
|
||||
const a = mail({ mail_id: 'aaa', session_id: 's', created_at: ts });
|
||||
const z = mail({ mail_id: 'zzz', session_id: 's', created_at: ts });
|
||||
const [g1] = groupMailsBySession([a, z]);
|
||||
const [g2] = groupMailsBySession([z, a]);
|
||||
// 两种输入顺序必须给出同一结果,否则「刷新一次顺序就变了」
|
||||
expect(g1.mails.map(m => m.mail_id)).toEqual(g2.mails.map(m => m.mail_id));
|
||||
expect(g1.mails[0].mail_id).toBe('zzz');
|
||||
});
|
||||
|
||||
it('统计未读数', () => {
|
||||
const [g] = groupMailsBySession([
|
||||
mail({ session_id: 's', status: 'unread' }),
|
||||
mail({ session_id: 's', status: 'unread' }),
|
||||
mail({ session_id: 's', status: 'read' })
|
||||
]);
|
||||
expect(g.unreadCount).toBe(2);
|
||||
});
|
||||
|
||||
it('别名取自最新一封;无别名给空串而不是 undefined', () => {
|
||||
const [withAlias] = groupMailsBySession([
|
||||
mail({ session_id: 's', session_alias: 'deploy-review' })
|
||||
]);
|
||||
expect(withAlias.alias).toBe('deploy-review');
|
||||
const [without] = groupMailsBySession([mail({ session_id: 's' })]);
|
||||
expect(without.alias).toBe('');
|
||||
});
|
||||
|
||||
it('session_id 缺失的脏数据各自成组而不是挤成一堆', () => {
|
||||
const groups = groupMailsBySession([
|
||||
mail({ mail_id: 'x', session_id: '' }),
|
||||
mail({ mail_id: 'y', session_id: '' })
|
||||
]);
|
||||
// 全归到 '' 这一组会把两封无关的信显示成一个会话
|
||||
expect(groups).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('created_at 解析失败不会让顺序变得不确定', () => {
|
||||
const bad = mail({ mail_id: 'bad', session_id: 's', created_at: '不是时间' });
|
||||
const good = mail({ mail_id: 'good', session_id: 's', created_at: '2026-09-03T10:00:00Z' });
|
||||
const [g] = groupMailsBySession([bad, good]);
|
||||
// NaN 参与比较恒为 false,会让排序结果取决于原数组顺序
|
||||
expect(g.mails.map(m => m.mail_id)).toEqual(['good', 'bad']);
|
||||
});
|
||||
|
||||
it('不修改入参数组', () => {
|
||||
const input = [
|
||||
mail({ session_id: 's', created_at: '2026-09-03T08:00:00Z' }),
|
||||
mail({ session_id: 's', created_at: '2026-09-03T10:00:00Z' })
|
||||
];
|
||||
const snapshot = input.map(m => m.mail_id);
|
||||
groupMailsBySession(input);
|
||||
expect(input.map(m => m.mail_id)).toEqual(snapshot);
|
||||
});
|
||||
|
||||
it('空数组返回空数组', () => {
|
||||
expect(groupMailsBySession([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFlatGroup', () => {
|
||||
it('单封邮件的组平铺显示,不套折叠头', () => {
|
||||
const [g] = groupMailsBySession([mail({ session_id: 'solo' })]);
|
||||
expect(isFlatGroup(g)).toBe(true);
|
||||
});
|
||||
|
||||
it('两封以上才折叠', () => {
|
||||
const [g] = groupMailsBySession([mail({ session_id: 's' }), mail({ session_id: 's' })]);
|
||||
expect(isFlatGroup(g)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitByPermission', () => {
|
||||
it('权限请求与普通邮件分开', () => {
|
||||
const { normal, permissions } = splitByPermission([
|
||||
mail({ subject: '来信' }),
|
||||
perm({ subject: '要跑 bash' }),
|
||||
mail({ subject: '又一封' })
|
||||
]);
|
||||
expect(normal.map(m => m.subject)).toEqual(['来信', '又一封']);
|
||||
expect(permissions.map(m => m.subject)).toEqual(['要跑 bash']);
|
||||
});
|
||||
|
||||
it('保持原有顺序(调用方自己排序)', () => {
|
||||
const a = mail({ mail_id: 'a' });
|
||||
const b = mail({ mail_id: 'b' });
|
||||
expect(splitByPermission([a, b]).normal.map(m => m.mail_id)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('空输入给两个空数组而不是 undefined', () => {
|
||||
const r = splitByPermission([]);
|
||||
expect(r.normal).toEqual([]);
|
||||
expect(r.permissions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPendingPermission', () => {
|
||||
it('普通邮件不是权限请求', () => {
|
||||
expect(isPendingPermission(mail())).toBe(false);
|
||||
});
|
||||
|
||||
it('permission_result 为空串或 null 都算未决策', () => {
|
||||
// 后端用 COALESCE(permission_result,'') 归一,两种都会出现
|
||||
expect(isPendingPermission(perm({ permission_result: null }))).toBe(true);
|
||||
expect(isPendingPermission(perm({ permission_result: '' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('有决策结果就不再是待决策', () => {
|
||||
expect(isPendingPermission(perm({ permission_result: '拒绝' }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('countPendingPermissions', () => {
|
||||
it('只数待决策的那些', () => {
|
||||
const n = countPendingPermissions([
|
||||
perm({ permission_result: null }),
|
||||
perm({ permission_result: '' }),
|
||||
perm({ permission_result: '同意' }),
|
||||
mail()
|
||||
]);
|
||||
expect(n).toBe(2);
|
||||
});
|
||||
|
||||
it('没有待决策时给 0', () => {
|
||||
expect(countPendingPermissions([perm({ permission_result: '同意' })])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupPermissions', () => {
|
||||
it('只收权限请求,普通邮件不进来', () => {
|
||||
const groups = groupPermissions([
|
||||
mail({ session_id: 'a' }),
|
||||
perm({ session_id: 'a' }),
|
||||
perm({ session_id: 'a' })
|
||||
]);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].pending.length + groups[0].settled.length).toBe(2);
|
||||
});
|
||||
|
||||
it('待决策与已决策分开装', () => {
|
||||
const [g] = groupPermissions([
|
||||
perm({ session_id: 's', permission_result: null, subject: '在等' }),
|
||||
perm({ session_id: 's', permission_result: '同意', subject: '批过' }),
|
||||
perm({ session_id: 's', permission_result: '拒绝', subject: '拒过' })
|
||||
]);
|
||||
expect(g.pending.map(m => m.subject)).toEqual(['在等']);
|
||||
expect(g.settled.map(m => m.subject).sort()).toEqual(['批过', '拒过']);
|
||||
});
|
||||
|
||||
it('有待决策的会话排在前面,哪怕它更旧', () => {
|
||||
const groups = groupPermissions([
|
||||
// 刚刚批完的
|
||||
perm({ session_id: 'done', permission_result: '同意', created_at: '2026-09-03T12:00:00Z' }),
|
||||
// 三天前就卡着的 —— 那条 Agent 会话正在那儿等
|
||||
perm({ session_id: 'stuck', permission_result: null, created_at: '2026-08-31T09:00:00Z' })
|
||||
]);
|
||||
expect(groups.map(g => g.sessionId)).toEqual(['stuck', 'done']);
|
||||
});
|
||||
|
||||
it('都有待决策时,卡住更多请求的会话更靠前', () => {
|
||||
const groups = groupPermissions([
|
||||
perm({ session_id: 'one', permission_result: null, created_at: '2026-09-03T12:00:00Z' }),
|
||||
perm({ session_id: 'many', permission_result: null, created_at: '2026-09-03T08:00:00Z' }),
|
||||
perm({ session_id: 'many', permission_result: null, created_at: '2026-09-03T08:01:00Z' }),
|
||||
perm({ session_id: 'many', permission_result: null, created_at: '2026-09-03T08:02:00Z' })
|
||||
]);
|
||||
expect(groups[0].sessionId).toBe('many');
|
||||
});
|
||||
|
||||
it('都无待决策时按最新时间倒序', () => {
|
||||
const groups = groupPermissions([
|
||||
perm({ session_id: 'old', permission_result: '同意', created_at: '2026-09-01T10:00:00Z' }),
|
||||
perm({ session_id: 'new', permission_result: '同意', created_at: '2026-09-03T10:00:00Z' })
|
||||
]);
|
||||
expect(groups.map(g => g.sessionId)).toEqual(['new', 'old']);
|
||||
});
|
||||
|
||||
it('组头带上发起请求的 Agent 与会话工作目录', () => {
|
||||
const [g] = groupPermissions([
|
||||
perm({
|
||||
session_id: 's',
|
||||
from_name: 'dsh',
|
||||
// from_workspace 对 Agent 存的是 **Agent 名**而不是路径(历史遗留)。
|
||||
// 拿它当路径用会在授权页拼出 `dsh@dsh`,而权限请求的
|
||||
// from_workspace 实测是**空串** —— 于是那一行永远不渲染,
|
||||
// 人根本不知道是哪个目录里的哪条线索在请求权限。
|
||||
from_workspace: 'dsh',
|
||||
session_workspace: '/home/program/llmsproxy'
|
||||
})
|
||||
]);
|
||||
// 同名 Agent 在不同目录是不同的活,光有名字判断不了
|
||||
expect(g.agentName).toBe('dsh');
|
||||
// path 必须取 session_workspace(会话的 workspace,权威来源)
|
||||
expect(g.path).toBe('/home/program/llmsproxy');
|
||||
});
|
||||
|
||||
it('组内时间倒序', () => {
|
||||
const [g] = groupPermissions([
|
||||
perm({ session_id: 's', permission_result: null, created_at: '2026-09-03T08:00:00Z', subject: '早' }),
|
||||
perm({ session_id: 's', permission_result: null, created_at: '2026-09-03T10:00:00Z', subject: '晚' })
|
||||
]);
|
||||
expect(g.pending.map(m => m.subject)).toEqual(['晚', '早']);
|
||||
});
|
||||
|
||||
it('没有权限请求时返回空数组', () => {
|
||||
expect(groupPermissions([mail(), mail()])).toEqual([]);
|
||||
});
|
||||
|
||||
it('不修改入参数组', () => {
|
||||
const input = [
|
||||
perm({ session_id: 's', created_at: '2026-09-03T08:00:00Z' }),
|
||||
perm({ session_id: 's', created_at: '2026-09-03T10:00:00Z' })
|
||||
];
|
||||
const snapshot = input.map(m => m.mail_id);
|
||||
groupPermissions(input);
|
||||
expect(input.map(m => m.mail_id)).toEqual(snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
describe('生产实测形状:17 封权限邮件的会话', () => {
|
||||
it('权限请求移出收件箱后,剩下的信按会话分组且不再被淹', () => {
|
||||
const flood: Mail[] = [];
|
||||
for (let i = 0; i < 17; i++) {
|
||||
flood.push(
|
||||
perm({
|
||||
session_id: 'f3ce62b0',
|
||||
permission_result: i === 16 ? null : '同意',
|
||||
created_at: `2026-09-03T09:${String(i).padStart(2, '0')}:00Z`
|
||||
})
|
||||
);
|
||||
}
|
||||
const realMail = [
|
||||
mail({ session_id: 'fa420c33', created_at: '2026-09-03T10:00:00Z', subject: '进展汇报' }),
|
||||
mail({ session_id: 'c2db6b62', created_at: '2026-09-03T11:00:00Z', subject: '需要确认' })
|
||||
];
|
||||
const inbox = [...flood, ...realMail];
|
||||
|
||||
// 收件箱侧:权限请求整体不进来,只剩两封真信
|
||||
const { normal, permissions } = splitByPermission(inbox);
|
||||
expect(normal).toHaveLength(2);
|
||||
expect(permissions).toHaveLength(17);
|
||||
const inboxGroups = groupMailsBySession(normal);
|
||||
expect(inboxGroups.map(g => g.subject)).toEqual(['需要确认', '进展汇报']);
|
||||
|
||||
// 授权侧:17 封折成 1 组,只有 1 个在等人
|
||||
const permGroups = groupPermissions(permissions);
|
||||
expect(permGroups).toHaveLength(1);
|
||||
expect(permGroups[0].pending).toHaveLength(1);
|
||||
expect(permGroups[0].settled).toHaveLength(16);
|
||||
expect(countPendingPermissions(permissions)).toBe(1);
|
||||
});
|
||||
});
|
||||
421
client/electron/test/components/replyTarget.test.tsx
Normal file
421
client/electron/test/components/replyTarget.test.tsx
Normal file
@ -0,0 +1,421 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
formatAddress,
|
||||
mailCounterpart,
|
||||
sessionCounterpart,
|
||||
sessionReplyTarget,
|
||||
mailReplyTarget,
|
||||
replyAllCC,
|
||||
participantAddress
|
||||
} from '../../src/lib/replyTarget';
|
||||
import type { Mail, Session } from '../../src/types';
|
||||
|
||||
/**
|
||||
* 「这封回复该发给谁」。
|
||||
*
|
||||
* 锁的是一次生产事故:判据写死 `from_name === 'human'`(单用户时代的遗留),
|
||||
* 多用户下登录名是 jianf,判据恒为假 → 对端取成 from_name(自己)→ 信发给自己。
|
||||
* 数据库里的链条:
|
||||
* pi → jianf 权限请求
|
||||
* jianf → pi Re: 权限请求 (对的,锚点是 pi 发来的)
|
||||
* pi → jianf 权限请求
|
||||
* jianf → jianf Re: Re: 权限请求 (错的,锚点是自己发的)
|
||||
*/
|
||||
|
||||
let seq = 0;
|
||||
function mail(over: Partial<Mail> = {}): Mail {
|
||||
seq += 1;
|
||||
return {
|
||||
mail_id: `m${String(seq).padStart(3, '0')}`,
|
||||
session_id: 's1',
|
||||
parent_mail_id: null,
|
||||
from_name: 'pi',
|
||||
from_workspace: '/home/program/llmsproxy',
|
||||
to_name: 'jianf',
|
||||
to_workspace: '',
|
||||
cc_list: [],
|
||||
subject: '主题',
|
||||
body: '正文',
|
||||
mail_type: 'normal',
|
||||
permission_options: null,
|
||||
permission_result: null,
|
||||
status: 'read',
|
||||
created_at: `2026-09-03T10:${String(seq % 60).padStart(2, '0')}:00Z`,
|
||||
// 默认值:会话工作目录、发件方是 Agent、收件方是人 ——
|
||||
// 对应「pi 在 /home/program/llmsproxy 干活 → jianf」这条最常见的信
|
||||
session_workspace: '/home/program/llmsproxy',
|
||||
from_human: false,
|
||||
to_human: true,
|
||||
...over
|
||||
};
|
||||
}
|
||||
|
||||
const session = (alias: string | null): Session => ({
|
||||
session_id: 's1',
|
||||
session_alias: alias,
|
||||
from_agent: 'jianf',
|
||||
subject: '关于llmsproxy工程的联合审查',
|
||||
status: 'active',
|
||||
created_at: '2026-09-03T09:00:00Z',
|
||||
updated_at: '2026-09-03T10:00:00Z'
|
||||
});
|
||||
|
||||
describe('formatAddress', () => {
|
||||
it('三段齐全', () => {
|
||||
expect(formatAddress('pi', '/home', 'deploy')).toBe('pi@/home.deploy');
|
||||
});
|
||||
it('无会话段时省略', () => {
|
||||
expect(formatAddress('pi', '/home')).toBe('pi@/home');
|
||||
expect(formatAddress('pi', '/home', null)).toBe('pi@/home');
|
||||
});
|
||||
it('path 与 session 都为空时返回裸名字(裸名字 = 默认会话)', () => {
|
||||
expect(formatAddress('jianf', '')).toBe('jianf');
|
||||
});
|
||||
it('path 为空但有 session 时必须保留 @', () => {
|
||||
// `jianf@.任务` 能被 ParseAddress 还原(按最后一个 . 切分);
|
||||
// 漏掉 @ 的 `jianf.任务` 会被整串当成名字 —— 那是个不存在的 Agent
|
||||
expect(formatAddress('jianf', '', '任务')).toBe('jianf@.任务');
|
||||
});
|
||||
it('名字为空给空串而不是拼出 @', () => {
|
||||
expect(formatAddress('', '/home')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mailCounterpart', () => {
|
||||
it('别人发来的 → 回给发件人', () => {
|
||||
const m = mail({ from_name: 'pi', to_name: 'jianf' });
|
||||
expect(mailCounterpart(m, 'jianf')).toEqual({ name: 'pi', path: '/home/program/llmsproxy', isHuman: false });
|
||||
});
|
||||
|
||||
it('我发出的 → 回给收件人(不是回给自己)', () => {
|
||||
const m = mail({
|
||||
from_name: 'jianf',
|
||||
from_human: true,
|
||||
from_workspace: '',
|
||||
to_name: 'pi',
|
||||
to_human: false,
|
||||
to_workspace: '/home/program/llmsproxy'
|
||||
});
|
||||
// 这一条就是生产 bug:原代码在这里返回 jianf
|
||||
expect(mailCounterpart(m, 'jianf')).toEqual({ name: 'pi', path: '/home/program/llmsproxy', isHuman: false });
|
||||
});
|
||||
|
||||
it('登录名未知时退化为回给发件人,不会回给自己', () => {
|
||||
const m = mail({ from_name: 'pi', to_name: 'jianf' });
|
||||
expect(mailCounterpart(m, '').name).toBe('pi');
|
||||
});
|
||||
|
||||
it('不把 human 当特殊值', () => {
|
||||
// 老判据写死 human;现在它只是个普通名字
|
||||
const m = mail({ from_name: 'human', to_name: 'pi' });
|
||||
expect(mailCounterpart(m, 'jianf').name).toBe('human');
|
||||
expect(mailCounterpart(m, 'human').name).toBe('pi');
|
||||
});
|
||||
|
||||
it('path 从 session_workspace 取,不用 from_workspace(后者存的是 Agent 名)', () => {
|
||||
// from_workspace 存的是 Agent 名而不是路径(历史遗留),
|
||||
// 从 session_workspace 取路径才不会拼出 dsh@dsh
|
||||
const m = mail({
|
||||
from_name: 'dsh',
|
||||
from_workspace: 'dsh', // Agent 名,不是路径
|
||||
to_name: 'jianf',
|
||||
to_human: true,
|
||||
session_workspace: '/home/program/webui4frpc'
|
||||
});
|
||||
const peer = mailCounterpart(m, 'jianf');
|
||||
expect(peer.path).toBe('/home/program/webui4frpc');
|
||||
expect(peer.path).not.toBe('dsh');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sessionCounterpart', () => {
|
||||
it('按会话定对端,不受最后一封是谁发的影响', () => {
|
||||
const mails = [
|
||||
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, created_at: '2026-09-03T09:00:00Z' }),
|
||||
mail({ from_name: 'pi', to_name: 'jianf', created_at: '2026-09-03T09:30:00Z' }),
|
||||
// 最后一封是我自己发的 —— 原代码在这里会把自己当对端
|
||||
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'jianf', to_human: true, created_at: '2026-09-03T10:00:00Z' })
|
||||
];
|
||||
expect(sessionCounterpart(mails, 'jianf')?.name).toBe('pi');
|
||||
});
|
||||
|
||||
it('取首个非我参与方:后来被抄送进来的第三方不抢位置', () => {
|
||||
const mails = [
|
||||
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, created_at: '2026-09-03T09:00:00Z' }),
|
||||
mail({ from_name: 'dsh', from_workspace: '/opt', to_name: 'jianf', created_at: '2026-09-03T09:30:00Z' })
|
||||
];
|
||||
expect(sessionCounterpart(mails, 'jianf')?.name).toBe('pi');
|
||||
});
|
||||
|
||||
it('补上首次出现时缺失的 path', () => {
|
||||
const mails = [
|
||||
// 首封的 session_workspace 是空的
|
||||
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, session_workspace: '', created_at: '2026-09-03T09:00:00Z' }),
|
||||
// 后一封才带上目录
|
||||
mail({ from_name: 'pi', from_workspace: '/home/program/llmsproxy', to_name: 'jianf', session_workspace: '/home/program/llmsproxy', created_at: '2026-09-03T09:30:00Z' })
|
||||
];
|
||||
// 同名 Agent 在不同目录是不同的活,path 不能丢
|
||||
expect(sessionCounterpart(mails, 'jianf')).toEqual({
|
||||
name: 'pi',
|
||||
path: '/home/program/llmsproxy',
|
||||
isHuman: false
|
||||
});
|
||||
});
|
||||
|
||||
it('同刻邮件用 mail_id 定序,结果稳定', () => {
|
||||
const ts = '2026-09-03T09:00:00Z';
|
||||
const a = mail({ mail_id: 'aaa', from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, created_at: ts });
|
||||
const z = mail({ mail_id: 'zzz', from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'dsh', to_human: false, created_at: ts });
|
||||
expect(sessionCounterpart([a, z], 'jianf')?.name).toBe(sessionCounterpart([z, a], 'jianf')?.name);
|
||||
});
|
||||
|
||||
it('全是自己的会话返回 null 而不是自己', () => {
|
||||
const mails = [mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'jianf', to_human: true })];
|
||||
expect(sessionCounterpart(mails, 'jianf')).toBeNull();
|
||||
});
|
||||
|
||||
it('空会话返回 null', () => {
|
||||
expect(sessionCounterpart([], 'jianf')).toBeNull();
|
||||
});
|
||||
|
||||
it('created_at 解析失败不影响确定性', () => {
|
||||
const bad = mail({ mail_id: 'bad', from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, created_at: '不是时间' });
|
||||
const good = mail({ mail_id: 'good', from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'dsh', to_human: false, created_at: '2026-09-03T09:00:00Z' });
|
||||
expect(sessionCounterpart([bad, good], 'jianf')?.name).toBe(
|
||||
sessionCounterpart([good, bad], 'jianf')?.name
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sessionReplyTarget', () => {
|
||||
it('带上会话别名:不带会落到该 Agent 的默认会话', () => {
|
||||
const mails = [
|
||||
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, to_workspace: '/home/program/llmsproxy' })
|
||||
];
|
||||
expect(sessionReplyTarget(mails, session('pi-关于llmsproxy工程的联合审查'), 'jianf')).toBe(
|
||||
'pi@/home/program/llmsproxy.pi-关于llmsproxy工程的联合审查'
|
||||
);
|
||||
});
|
||||
|
||||
it('会话未命名时省略会话段', () => {
|
||||
const mails = [
|
||||
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, session_workspace: '/home' })
|
||||
];
|
||||
expect(sessionReplyTarget(mails, session(null), 'jianf')).toBe('pi@/home');
|
||||
});
|
||||
|
||||
it('生产链条重现:回复自己发的那封仍指向 pi', () => {
|
||||
const mails = [
|
||||
mail({ from_name: 'pi', to_name: 'jianf', created_at: '2026-09-03T10:53:12Z' }),
|
||||
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'pi', to_human: false, to_workspace: '/home/program/llmsproxy', created_at: '2026-09-03T10:53:39Z' }),
|
||||
mail({ from_name: 'pi', to_name: 'jianf', created_at: '2026-09-03T10:55:13Z' }),
|
||||
mail({ from_name: 'jianf', from_human: true, from_workspace: '', to_name: 'jianf', to_human: true, created_at: '2026-09-03T10:56:34Z' })
|
||||
];
|
||||
const target = sessionReplyTarget(mails, session('pi-关于llmsproxy工程的联合审查'), 'jianf');
|
||||
expect(target.startsWith('pi@')).toBe(true);
|
||||
expect(target.startsWith('jianf@')).toBe(false);
|
||||
});
|
||||
|
||||
it('找不到对端时给空串(调用方据此禁用发送)', () => {
|
||||
expect(sessionReplyTarget([], session('x'), 'jianf')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mailReplyTarget', () => {
|
||||
it('单封视图带上该封的会话别名', () => {
|
||||
const m = mail({ from_name: 'pi', to_name: 'jianf', session_alias: 'deploy-review' });
|
||||
expect(mailReplyTarget(m, 'jianf')).toBe('pi@/home/program/llmsproxy.deploy-review');
|
||||
});
|
||||
});
|
||||
|
||||
describe('replyAllCC', () => {
|
||||
it('去掉自己与主收件人', () => {
|
||||
const m = mail({
|
||||
from_name: 'pi',
|
||||
from_workspace: '/home',
|
||||
to_name: 'jianf',
|
||||
to_workspace: '',
|
||||
cc_list: [{ name: 'dsh', path: '/opt', session: '', raw: 'dsh@/opt' }]
|
||||
});
|
||||
// 主收件人是 pi(回复对象),自己是 jianf —— 都不该出现在抄送里
|
||||
expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt']);
|
||||
});
|
||||
|
||||
it('自己是 jianf 时不会把自己抄送进去(老判据只挡 human)', () => {
|
||||
const m = mail({ from_name: 'pi', from_workspace: '/home', to_name: 'jianf', to_workspace: '' });
|
||||
expect(replyAllCC(m, 'jianf', 'pi')).toEqual([]);
|
||||
});
|
||||
|
||||
it('cc_list 取 raw 保留会话段', () => {
|
||||
const m = mail({
|
||||
from_name: 'pi',
|
||||
from_workspace: '/home',
|
||||
to_name: 'jianf',
|
||||
cc_list: [{ name: 'dsh', path: '/opt', session: 'audit', raw: 'dsh@/opt.audit' }]
|
||||
});
|
||||
// 重新拼 name@path 会丢掉 .audit
|
||||
expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt.audit']);
|
||||
});
|
||||
|
||||
it('同一个人既在 to 又在 cc 时只出现一次', () => {
|
||||
const m = mail({
|
||||
from_name: 'pi',
|
||||
from_workspace: '/home',
|
||||
to_name: 'dsh',
|
||||
to_human: false,
|
||||
to_workspace: '/opt',
|
||||
session_workspace: '/opt',
|
||||
cc_list: [{ name: 'dsh', path: '/opt', session: '', raw: 'dsh@/opt' }]
|
||||
});
|
||||
expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt']);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* ─── 地址展示 ───
|
||||
*
|
||||
* 锁的是一次生产事故:单封邮件视图的「发件」行显示成
|
||||
* jianf.邮件驱动·多智能体协作平台-完整设计文档-一、项目概述-11-项目定位
|
||||
* 而人指定的收件方是 pi@/home/program/agentmail 的那条会话。三处错叠在一起:
|
||||
*
|
||||
* 1. 会话别名被拼给了**发件人** —— 别名是会话的属性、两端共有,不属于任何一方
|
||||
* 2. from_workspace 为空(人类没有工作目录)时拼出 `jianf.<别名>`,
|
||||
* 按三维寻址「最后一个 . 切分」规则,那是 path 位为空的**非法地址**
|
||||
* 3. 抄送显示 `pi@/home/program/agentmail.new` —— `.new` 建完会话就失效了,
|
||||
* 留着会让人以为再发一次还能投进同一条会话
|
||||
*/
|
||||
describe('formatAddress 边界', () => {
|
||||
it('path 为空时保留 @,不能拼成 name.session', () => {
|
||||
// bug 现场:jianf 没有工作目录,界面拼出了 `jianf.某会话别名`。
|
||||
// 那会被 ParseAddress 整串当成名字(实测 name="jianf.某会话别名")——
|
||||
// 一个不存在的 Agent。正确形式是 `jianf@.某会话别名`。
|
||||
expect(formatAddress('jianf', '', '某会话别名')).toBe('jianf@.某会话别名');
|
||||
expect(formatAddress('jianf', '', '某会话别名')).not.toBe('jianf.某会话别名');
|
||||
});
|
||||
|
||||
it('无 session 时 path 为空返回裸名字(不留孤零零的 @)', () => {
|
||||
expect(formatAddress('jianf', '')).toBe('jianf');
|
||||
expect(formatAddress('jianf', ' ')).toBe('jianf');
|
||||
});
|
||||
|
||||
it('name 为空返回空串而不是残缺地址', () => {
|
||||
expect(formatAddress('', '/home')).toBe('');
|
||||
expect(formatAddress(' ', '/home', 'x')).toBe('');
|
||||
});
|
||||
|
||||
it('三段齐全时正常拼接', () => {
|
||||
expect(formatAddress('pi', '/home/program/agentmail', 'my-task'))
|
||||
.toBe('pi@/home/program/agentmail.my-task');
|
||||
});
|
||||
|
||||
it('去首尾空白', () => {
|
||||
expect(formatAddress(' pi ', ' /home ', ' t ')).toBe('pi@/home.t');
|
||||
});
|
||||
|
||||
it('path 含 . 与 / 时仍按最后一个 . 切分(与后端同构)', () => {
|
||||
// 拼出来的地址必须能被后端 ParseAddress 还原
|
||||
const addr = formatAddress('pi', '/home/a.b/c', 'sess');
|
||||
expect(addr).toBe('pi@/home/a.b/c.sess');
|
||||
expect(addr.slice(addr.lastIndexOf('.') + 1)).toBe('sess');
|
||||
});
|
||||
});
|
||||
|
||||
describe('participantAddress', () => {
|
||||
/**
|
||||
* 人与 Agent 的地址维度不同:
|
||||
* Agent 要三段(name@path.session)才唯一确定「哪个 Agent、哪个目录、哪条线索」
|
||||
* 人只要名字(没有工作目录,也不需要指定会话)
|
||||
*/
|
||||
it('Agent 带完整三段 —— 少任何一段都不是可投递地址', () => {
|
||||
expect(participantAddress('pi', false, '/home/program/agentmail', '我的任务'))
|
||||
.toBe('pi@/home/program/agentmail.我的任务');
|
||||
});
|
||||
|
||||
it('Agent 无会话别名时退到 name@path(默认会话)', () => {
|
||||
expect(participantAddress('pi', false, '/home/program/agentmail', null))
|
||||
.toBe('pi@/home/program/agentmail');
|
||||
expect(participantAddress('pi', false, '/home/program/agentmail'))
|
||||
.toBe('pi@/home/program/agentmail');
|
||||
});
|
||||
|
||||
it('人只显示名字,即便传了会话别名也不拼', () => {
|
||||
// 给人拼 `jianf@.某会话` 是把 Agent 的维度硬套在人身上
|
||||
expect(participantAddress('jianf', true, '', '某会话')).toBe('jianf');
|
||||
expect(participantAddress('jianf', true, null, '某会话')).toBe('jianf');
|
||||
expect(participantAddress('jianf', true)).toBe('jianf');
|
||||
});
|
||||
|
||||
it('人的地址里不含 @ 也不含 .', () => {
|
||||
const addr = participantAddress('jianf', true, '', '邮件驱动·多智能体协作平台-完整设计文档');
|
||||
expect(addr).toBe('jianf');
|
||||
expect(addr).not.toContain('@');
|
||||
expect(addr).not.toContain('.');
|
||||
});
|
||||
|
||||
it('人是 Agent 时 workspace 为空也不会拼出裸 @', () => {
|
||||
// 边界:Agent 名在库里但 session_workspace 为空(数据不完整),
|
||||
// 仍然要留 @ 而不是裸名字 —— 否则按最后一个 . 切分会错
|
||||
expect(participantAddress('pi', false, '', 'sess'))
|
||||
.toBe('pi@.sess');
|
||||
});
|
||||
});
|
||||
|
||||
describe('replyAllCC 保留 cc_list 的原始意图', () => {
|
||||
// `.new` 是**原始意图**,不该被替换成主收件人的别名。
|
||||
//
|
||||
// 每个抄送方的 `.new` 是独立的:`pi@/x.new` 给 pi 开一条会话、
|
||||
// `dsh@/x.new` 给 dsh 开另一条,各有自己的别名。把它们统一换成主收件人
|
||||
// 那条会话的别名,等于把三条不同的线索说成同一条 —— 而数据库里 cc_list
|
||||
// 存的就是原文,显示原文没有任何问题。
|
||||
it('抄送里的 .new 原样保留', () => {
|
||||
const m = mail({
|
||||
from_name: 'jianf',
|
||||
from_workspace: '',
|
||||
to_name: 'pi',
|
||||
to_workspace: '/home/program/agentmail',
|
||||
cc_list: [{
|
||||
name: 'dsh', path: '/opt', session: 'new', raw: 'dsh@/opt.new'
|
||||
}]
|
||||
});
|
||||
// 自己是 jianf、主收件人是 pi,剩下 dsh —— `.new` 是原文,不动
|
||||
expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt.new']);
|
||||
});
|
||||
|
||||
it('已有具体别名的抄送也原样保留', () => {
|
||||
const m = mail({
|
||||
from_name: 'jianf',
|
||||
from_workspace: '',
|
||||
to_name: 'pi',
|
||||
to_workspace: '/home',
|
||||
cc_list: [{ name: 'dsh', path: '/opt', session: 'other-task', raw: 'dsh@/opt.other-task' }]
|
||||
});
|
||||
expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt.other-task']);
|
||||
});
|
||||
|
||||
it('抄送无 raw 时按结构化字段重拼(含会话位)', () => {
|
||||
const m = mail({
|
||||
from_name: 'jianf',
|
||||
from_workspace: '',
|
||||
to_name: 'pi',
|
||||
to_workspace: '/home',
|
||||
cc_list: [{ name: 'dsh', path: '/opt', session: 'x', raw: '' }]
|
||||
});
|
||||
expect(replyAllCC(m, 'jianf', 'pi')).toEqual(['dsh@/opt.x']);
|
||||
});
|
||||
|
||||
it('人类发件人(无 workspace)在抄送里是裸名字,不带会话位', () => {
|
||||
const m = mail({
|
||||
from_name: 'jianf',
|
||||
from_human: true,
|
||||
from_workspace: '',
|
||||
to_name: 'pi',
|
||||
to_human: false,
|
||||
to_workspace: '/home',
|
||||
session_alias: '某个很长的会话别名'
|
||||
});
|
||||
// replyAllCC 里的 from/to 走 formatAddress 且**不传 session** ——
|
||||
// 抄送清单是「还要发给谁」,会话由主收件人的地址决定,
|
||||
// 每个抄送方都带一遍会话位是冗余的(且回复时后端按 reply_to 定位会话)
|
||||
expect(replyAllCC(m, 'dsh', 'pi')).toEqual(['jianf']);
|
||||
});
|
||||
});
|
||||
45
client/electron/test/components/setup.ts
Normal file
45
client/electron/test/components/setup.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { afterEach, expect } from 'vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import * as matchers from '@testing-library/jest-dom/matchers';
|
||||
|
||||
/**
|
||||
* 组件测试的全局准备。
|
||||
*
|
||||
* 三件事,每件都对应一类会串味的状态:
|
||||
* 1. 每个用例后卸载组件树(不卸载的话下一个用例的 getByText 会命中上一个的 DOM)
|
||||
* 2. 重置 zustand store(它是模块级单例,跨用例共享)
|
||||
* 3. 提供 matchMedia —— jsdom 没有实现它,而 useIsNarrow 直接调
|
||||
*/
|
||||
expect.extend(matchers);
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
/**
|
||||
* jsdom 不实现 matchMedia。useIsNarrow 会直接调它并挂 change 监听,
|
||||
* 缺了就抛 `matchMedia is not a function`,整个测试文件挂掉。
|
||||
*
|
||||
* 默认返回 false(宽屏)。要测窄屏的用例用 setViewport(true) 覆盖。
|
||||
*/
|
||||
function installMatchMedia(narrow: boolean) {
|
||||
const listeners = new Set<(e: MediaQueryListEvent) => void>();
|
||||
(window as any).matchMedia = (query: string) => ({
|
||||
matches: narrow,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: (_: string, cb: (e: MediaQueryListEvent) => void) => listeners.add(cb),
|
||||
removeEventListener: (_: string, cb: (e: MediaQueryListEvent) => void) => listeners.delete(cb),
|
||||
// 老式 API,某些库仍在用
|
||||
addListener: (cb: (e: MediaQueryListEvent) => void) => listeners.add(cb),
|
||||
removeListener: (cb: (e: MediaQueryListEvent) => void) => listeners.delete(cb),
|
||||
dispatchEvent: () => false
|
||||
});
|
||||
}
|
||||
|
||||
installMatchMedia(false);
|
||||
|
||||
/** 让当前测试文件里的组件按窄屏/宽屏渲染。在 render 之前调。 */
|
||||
export function setViewport(narrow: boolean) {
|
||||
installMatchMedia(narrow);
|
||||
}
|
||||
75
client/electron/test/manual/README.md
Normal file
75
client/electron/test/manual/README.md
Normal file
@ -0,0 +1,75 @@
|
||||
# 手工浏览器实测脚本
|
||||
|
||||
不进 `npm test` —— 它们需要一个跑着的 Chromium 与一个活的 Gateway。
|
||||
日常回归靠 `../narrow-layout.test.mjs`(读源码验形态,无外部依赖)。
|
||||
|
||||
## 为什么两套都要
|
||||
|
||||
结构性断言守住「代码写成了什么形态」,量不出「按钮实际多大、点下去命中谁」。
|
||||
|
||||
窄屏那轮修复里最严重的一个 bug 是抽屉式侧栏(`fixed ... z-50` 铺满视口高度)
|
||||
把底部导航最左那一项盖住 —— 按钮在那里、尺寸也够、`md:hidden` 之类的规则也
|
||||
没写错,**只有 `elementFromPoint` 才能发现它命中的是抽屉里的 SVG**。
|
||||
|
||||
## 用法
|
||||
|
||||
```bash
|
||||
# 窄屏:390px(iPhone 14 Pro)+ 320px(iPhone SE)
|
||||
ADMIN_PW=<密码> npm run test:narrow
|
||||
|
||||
# 宽屏回归:窄屏修复不能把桌面改坏
|
||||
ADMIN_PW=<密码> npm run test:wide
|
||||
```
|
||||
|
||||
环境变量:
|
||||
|
||||
| 变量 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `ADMIN_PW` | 无(必填) | 管理员密码 |
|
||||
| `ADMIN_USER` | `admin` | 登录用户名 |
|
||||
| `AGENTMAIL_URL` | `https://mail.jianfgit.xyz` | 目标地址 |
|
||||
| `CDP_URL` | `http://127.0.0.1:9222` | 浏览器 CDP 端点 |
|
||||
| `PLAYWRIGHT` | `/usr/lib/node_modules/playwright/index.mjs` | playwright 入口 |
|
||||
|
||||
浏览器用的是本机 systemd 托管的共享 Chromium(`homeagent-browser.service`),
|
||||
通过 CDP 连上去开自己的标签页,用完关掉。没有它时先
|
||||
`systemctl start homeagent-browser`。
|
||||
|
||||
## 文件
|
||||
|
||||
| 文件 | 作用 |
|
||||
|---|---|
|
||||
| `narrow-probe-helper.mjs` | 连浏览器、登录、量盒子/溢出/命中区/命中测试 |
|
||||
| `narrow-verify.mjs` | 窄屏 13 项验收 |
|
||||
| `wide-regression.mjs` | 宽屏 5 项回归 |
|
||||
| `inbox-group-verify.mjs` | 收件箱按会话分组 |
|
||||
| `theme-verify.mjs` | 深浅两色的 WCAG 对比度 |
|
||||
| `accent-verify.mjs` | 强调色(红/绿/橙/黄/蓝)17 组配色,两模式各一遍 |
|
||||
|
||||
`narrow-probe-helper.mjs` 里两个函数值得单独知道:
|
||||
|
||||
- `tapTargets(page, labels)` —— 量 `.tap` 按钮的**真实**命中区(`::after`
|
||||
伪元素的尺寸)。`.tap` 刻意不改变视觉尺寸,所以只看 `boundingBox` 会误判成偏小
|
||||
- `hitTest(page, selector)` —— 每个元素点下去是否命中自己。遮挡类 bug 只能这样查
|
||||
|
||||
`accent-verify.mjs` 存在的理由是一次真实事故:`tailwind.config.js` 的 `colors`
|
||||
里同时写了固定 hex 与 `accent()` 两份 red/green/amber/orange/yellow,JS 对象
|
||||
字面量重复键**后者胜出**(不报错),而 `index.css` 当时没有对应的 `--c-red-*`
|
||||
变量。`rgb(var(--c-red-600) / 1)` 里变量未定义 → 整条 `background-color` 声明
|
||||
失效 → `bg-red-600` 退回透明、`text-white` 的白字落在白卡片上:
|
||||
**按钮看不见但点得动**。所有静态检查都过,只有肉眼能发现。
|
||||
|
||||
因此这个脚本量的是**实际计算值**:它把类名注入真页面、读 `getComputedStyle`,
|
||||
把「背景透明」单独判为失败(那正是上述 bug 的指纹),再算 WCAG 对比度。
|
||||
|
||||
只以 `hover:` 变体出现的档(`bg-red-700` / `bg-blue-700`)**不能**放进探针:
|
||||
Tailwind 不生成未被使用的基础类,探它必然得到透明背景 —— 那是假阳性。
|
||||
它们由 `../theme.test.mjs` 的档位断言覆盖。
|
||||
|
||||
## 已知限制
|
||||
|
||||
headless Chromium 报告 `hover: none`,因此 `.reveal`(只在支持悬停的设备上隐藏)
|
||||
在这里永远是可见的 —— 脚本只能验「触摸设备上可见」这一半,
|
||||
「鼠标设备上隐藏」那一半靠 `../narrow-layout.test.mjs` 检查 CSS 规则存在。
|
||||
|
||||
没有像素级视觉比对:字体差异下极脆,维护成本高于收益。
|
||||
84
client/electron/test/manual/accent-verify.mjs
Normal file
84
client/electron/test/manual/accent-verify.mjs
Normal file
@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 强调色可见性验收:真实渲染 + 取实际计算色 + 算 WCAG 对比度。
|
||||
*
|
||||
* 结构性断言(test/theme.test.mjs)只能保证变量存在、档位达标;
|
||||
* 「按钮到底看得见吗」必须在真浏览器里量 —— 上一次那个 bug 正是
|
||||
* 所有静态检查都过、只有肉眼能发现。
|
||||
*/
|
||||
import { openApp, WIDE } from './narrow-probe-helper.mjs';
|
||||
|
||||
const srgb = c => { c /= 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
|
||||
const L = ([r, g, b]) => 0.2126 * srgb(r) + 0.7152 * srgb(g) + 0.0722 * srgb(b);
|
||||
const ratio = (a, b) => { const l1 = L(a), l2 = L(b); const [h, o] = l1 > l2 ? [l1, l2] : [l2, l1]; return (h + 0.05) / (o + 0.05); };
|
||||
const parse = s => (s.match(/\d+/g) || []).slice(0, 3).map(Number);
|
||||
|
||||
// 把一批强调色类名注入页面,量它们的实际计算值
|
||||
// hover 档(bg-red-700 / bg-blue-700)不在这里:源码里它们只以 `hover:` 变体
|
||||
// 出现,Tailwind 因此不生成基础类 —— 探它会得到透明背景,那是探针的假阳性
|
||||
// 而不是真 bug。它们由 theme.test.mjs 的档位断言覆盖(--s-* 两模式同值)。
|
||||
const PROBES = [
|
||||
// [类名组合, 说明, 期望最低对比度]
|
||||
['bg-red-600 text-white', '危险实心按钮(确认归档 / 删除)', 3.0],
|
||||
['bg-red-500 text-white', '未读徽标', 3.0],
|
||||
['bg-green-600 text-white', '同意按钮', 3.0],
|
||||
['bg-orange-700 text-white', '待决策徽标', 4.5],
|
||||
['bg-blue-600 text-white', '主按钮', 4.5],
|
||||
['bg-red-50 text-red-600', '错误提示条', 4.5],
|
||||
['bg-red-50 text-red-700', '危险区块文字', 4.5],
|
||||
['bg-green-50 text-green-700', '成功提示条', 4.5],
|
||||
['bg-orange-100 text-orange-700', '橙色 chip', 4.5],
|
||||
['bg-amber-100 text-amber-700', '警告 chip', 4.5],
|
||||
['bg-amber-50 text-amber-800', '警告条', 4.5],
|
||||
['bg-yellow-100 text-yellow-700', '黄色 chip', 4.5],
|
||||
['bg-blue-50 text-blue-700', '信息条', 4.5],
|
||||
['bg-red-100 text-red-700', '红色 chip', 4.5],
|
||||
['bg-orange-200 text-orange-800', '深橙 chip', 4.5],
|
||||
['bg-white text-gray-900', '卡片正文', 4.5],
|
||||
['bg-white text-gray-500', '卡片次要文字', 4.5],
|
||||
];
|
||||
|
||||
const { browser, page } = await openApp(WIDE);
|
||||
let fail = 0;
|
||||
|
||||
for (const mode of ['light', 'dark']) {
|
||||
await page.evaluate(m => {
|
||||
document.documentElement.classList.toggle('dark', m === 'dark');
|
||||
let host = document.getElementById('__probe');
|
||||
if (host) host.remove();
|
||||
host = document.createElement('div');
|
||||
host.id = '__probe';
|
||||
host.style.position = 'fixed';
|
||||
host.style.top = '0';
|
||||
host.style.left = '0';
|
||||
host.style.zIndex = '99999';
|
||||
document.body.appendChild(host);
|
||||
}, mode);
|
||||
|
||||
console.log(`\n─── ${mode === 'dark' ? '深色' : '浅色'} ───`);
|
||||
for (const [cls, label, min] of PROBES) {
|
||||
const got = await page.evaluate(c => {
|
||||
const host = document.getElementById('__probe');
|
||||
host.innerHTML = `<span id="__p" class="${c}">测试</span>`;
|
||||
const el = document.getElementById('__p');
|
||||
const s = getComputedStyle(el);
|
||||
return { bg: s.backgroundColor, fg: s.color };
|
||||
}, cls);
|
||||
|
||||
// 透明背景 = 声明失效(正是上次那个 bug 的指纹)
|
||||
const transparent = /rgba\(0,\s*0,\s*0,\s*0\)|transparent/.test(got.bg);
|
||||
if (transparent) {
|
||||
console.log(` 失败 ${label} — 背景透明(${cls} 的 background-color 声明失效)`);
|
||||
fail++;
|
||||
continue;
|
||||
}
|
||||
const r = ratio(parse(got.fg), parse(got.bg));
|
||||
const ok = r >= min;
|
||||
if (!ok) fail++;
|
||||
console.log(` ${ok ? '通过' : '失败'} ${label.padEnd(22)} ${r.toFixed(2)}:1 (需 ${min}) ${got.bg} / ${got.fg}`);
|
||||
}
|
||||
}
|
||||
|
||||
await page.evaluate(() => document.getElementById('__probe')?.remove());
|
||||
await browser.close();
|
||||
console.log(fail ? `\n!! ${fail} 项不达标` : '\n全部达标');
|
||||
process.exit(fail ? 1 : 0);
|
||||
98
client/electron/test/manual/addr-verify.mjs
Normal file
98
client/electron/test/manual/addr-verify.mjs
Normal file
@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 邮件详情页地址行的真实渲染验收。
|
||||
*
|
||||
* 锁的是那次事故:发件行显示成 `jianf.<会话别名>` —— 别名拼给了发件人,
|
||||
* 而且 path 为空时拼出了 ParseAddress 会整串当成名字的非法形态。
|
||||
*/
|
||||
import { openApp, WIDE } from './narrow-probe-helper.mjs';
|
||||
|
||||
const { browser, page } = await openApp(WIDE);
|
||||
let fail = 0;
|
||||
const check = (name, ok, detail = '') => {
|
||||
if (ok) console.log(` 通过 ${name}`);
|
||||
else { fail++; console.log(` 失败 ${name}${detail ? ' — ' + detail : ''}`); }
|
||||
};
|
||||
|
||||
// 走**发件**箱:报这个 bug 的那封信是人发出去的(jianf → pi,抄送 pi@….new),
|
||||
// 收件箱里只有 Agent 的回信 —— 而回信没有抄送、也不带 `.new`,
|
||||
// 探不到要验的那三处。
|
||||
await page.waitForSelector('button', { timeout: 20000 });
|
||||
await page.click('button:has-text("发件")');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 收件箱/发件箱按会话分组:先点组头展开,再点里面的邮件行。
|
||||
// 两者 className 都含 `w-full text-left`,靠「点完有没有出现含『发件』的 dl」区分。
|
||||
let opened = false;
|
||||
for (let round = 0; round < 3 && !opened; round++) {
|
||||
const btns = await page.$$('button.w-full.text-left');
|
||||
for (const b of btns) {
|
||||
const t = await b.innerText().catch(() => '');
|
||||
if (!t) continue;
|
||||
await b.click().catch(() => {});
|
||||
await page.waitForTimeout(350);
|
||||
const hasMeta = await page.evaluate(() =>
|
||||
[...document.querySelectorAll('dl')].some(dl => dl.textContent.includes('发件')));
|
||||
if (hasMeta) { opened = true; break; }
|
||||
}
|
||||
}
|
||||
check('打开了一封邮件', opened);
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
// 读元信息 dl
|
||||
const meta = await page.evaluate(() => {
|
||||
const dls = [...document.querySelectorAll('dl')];
|
||||
for (const dl of dls) {
|
||||
const pairs = [];
|
||||
const kids = [...dl.children];
|
||||
for (const div of kids) {
|
||||
const dt = div.querySelector('dt'), dd = div.querySelector('dd');
|
||||
if (dt && dd) pairs.push([dt.textContent.trim(), dd.textContent.trim()]);
|
||||
}
|
||||
if (pairs.some(([k]) => k === '发件')) return Object.fromEntries(pairs);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!meta) {
|
||||
check('找到元信息区', false, '没有含「发件」的 dl');
|
||||
} else {
|
||||
console.log(' 元信息:', JSON.stringify(meta));
|
||||
const from = meta['发件'] || '';
|
||||
const to = meta['收件'] || '';
|
||||
const cc = meta['抄送'] || '';
|
||||
|
||||
// 判「哪一方是人」:人的地址里既无 @ 也无 .
|
||||
const isHuman = a => a && !a.includes('@') && !a.includes('.');
|
||||
const human = isHuman(from) ? from : (isHuman(to) ? to : '');
|
||||
const agent = isHuman(from) ? to : from;
|
||||
|
||||
check('有一方是人(裸名字,无 @ 无 .)', !!human, `发件=${from} 收件=${to}`);
|
||||
check('人的地址不含会话位', !human || !/[@.]/.test(human), `人=${human}`);
|
||||
|
||||
// Agent 必须三段齐全:name@path.session
|
||||
const threeSeg = /^[^@]+@\/[^@]*\.[^.@]+$/.test(agent);
|
||||
check('Agent 带完整三段 name@path.session', threeSeg, `agent=${agent}`);
|
||||
|
||||
// path 不能是 Agent 名(`dsh@dsh` 那个 bug)
|
||||
if (agent.includes('@')) {
|
||||
const [n, rest] = [agent.slice(0, agent.indexOf('@')), agent.slice(agent.indexOf('@') + 1)];
|
||||
check('Agent 的 path 是真路径而不是 Agent 名',
|
||||
rest.startsWith('/'), `${n}@${rest}`);
|
||||
}
|
||||
|
||||
// 别名不能挂在人身上
|
||||
check('会话别名跟着 Agent 而不是人',
|
||||
!human || !agent || agent.includes('.'), `人=${human} agent=${agent}`);
|
||||
|
||||
// 抄送里不能残留 .new
|
||||
check('抄送里没有 .new',
|
||||
!cc.split('、').some(a => a.trim().endsWith('.new')), `抄送=${cc}`);
|
||||
if (cc) console.log(` 抄送实际值: ${cc}`);
|
||||
|
||||
// 不该再有独立的「会话」行(别名已在 Agent 地址里)
|
||||
check('没有多余的「会话」行', !('会话' in meta), `会话=${meta['会话']}`);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log(fail ? `\n!! ${fail} 项不达标` : '\n全部达标');
|
||||
process.exit(fail ? 1 : 0);
|
||||
168
client/electron/test/manual/inbox-group-verify.mjs
Normal file
168
client/electron/test/manual/inbox-group-verify.mjs
Normal file
@ -0,0 +1,168 @@
|
||||
/**
|
||||
* 收件箱分组 + 授权独立列表的实测验收。
|
||||
*
|
||||
* 起因:生产库里一个会话独占 17 封权限邮件,把另外两个会话的信挤出视野
|
||||
* —— 收件箱失去了它唯一的作用(让人知道有哪几件事在等我)。
|
||||
*
|
||||
* 修法两层,这里各验一层:
|
||||
* 1. 权限请求整体移出收件箱,进「授权」导航项(一级会话 / 二级请求)
|
||||
* 2. 收件箱剩下的普通邮件按会话折叠
|
||||
*
|
||||
* 单元测试(test/components/mailGroups.test.tsx)守住分组函数的算术,
|
||||
* 量不出「组头真的只有一行」「折叠时组内邮件确实不在 DOM 里」这些
|
||||
* 只有真实渲染才能验的事。
|
||||
*
|
||||
* 用法:ADMIN_PW=<密码> ADMIN_USER=jianf AGENTMAIL_URL=http://127.0.0.1:8180 \
|
||||
* node client/electron/test/manual/inbox-group-verify.mjs
|
||||
*/
|
||||
import { openApp, WIDE } from './narrow-probe-helper.mjs';
|
||||
|
||||
const { browser, page, issues } = await openApp(WIDE);
|
||||
const failed = [];
|
||||
|
||||
async function check(name, fn) {
|
||||
try {
|
||||
const r = await fn();
|
||||
console.log(` ${r.ok ? '通过' : '失败'} ${name}${r.note ? ' — ' + r.note : ''}`);
|
||||
if (!r.ok) failed.push(name);
|
||||
} catch (e) {
|
||||
console.log(` 错误 ${name} — ${e.message.slice(0, 100)}`);
|
||||
failed.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
/** 中间栏里可点的条目数(组头 + 展开出来的行)。 */
|
||||
const rowCount = () => page.locator('div.overflow-y-auto button').count();
|
||||
|
||||
async function goto(label) {
|
||||
await page.click(`aside button[title*="${label}"], button[title*="${label}"]`);
|
||||
await page.waitForTimeout(1200);
|
||||
}
|
||||
|
||||
// ───────────────── 收件箱:权限已移出、其余按会话折叠 ─────────────────
|
||||
console.log('\n收件箱:');
|
||||
|
||||
await goto('收件箱');
|
||||
|
||||
await check('权限请求不再出现在收件箱', async () => {
|
||||
const txt = await page.locator('div.overflow-y-auto').innerText();
|
||||
// 「待决策」「等待你决策」都是授权列表的措辞;收件箱里不该有
|
||||
const leaked = /待决策|等待你决策/.test(txt);
|
||||
return { ok: !leaked, note: leaked ? '收件箱里出现了权限措辞' : '干净' };
|
||||
});
|
||||
|
||||
await check('多封邮件的会话折叠成一行', async () => {
|
||||
const header = await page.locator('h2:has-text("收件箱")').locator('..').innerText();
|
||||
const m = header.match(/(\d+)\s*组\s*·\s*(\d+)\s*封/);
|
||||
if (!m) {
|
||||
// 每个会话都只有一封时不显示「N 组 · M 封」,这是设计(单封平铺)
|
||||
return { ok: true, note: `无可折叠会话:${header.replace(/\n/g, ' ').trim()}` };
|
||||
}
|
||||
return { ok: Number(m[1]) < Number(m[2]), note: `${m[1]} 组 / ${m[2]} 封` };
|
||||
});
|
||||
|
||||
// ───────────────── 授权:一级会话 / 二级请求 ─────────────────
|
||||
console.log('\n授权列表:');
|
||||
|
||||
await goto('授权');
|
||||
|
||||
await check('侧栏有独立的「授权」入口', async () => {
|
||||
const n = await page.locator('button[title*="授权"]').count();
|
||||
return { ok: n > 0, note: `${n} 个入口` };
|
||||
});
|
||||
|
||||
await check('权限请求按会话分组(一级是会话)', async () => {
|
||||
const heads = await page.locator('div.overflow-y-auto > div > button').count();
|
||||
const txt = await page.locator('div.overflow-y-auto').innerText();
|
||||
if (heads === 0 && /没有授权请求/.test(txt)) {
|
||||
return { ok: true, note: '当前无授权请求(空态正常)' };
|
||||
}
|
||||
// 组头带会话别名(.alias 或「(未命名会话)」)
|
||||
const hasAlias = /\.\S+|\(未命名会话\)/.test(txt);
|
||||
return { ok: heads > 0 && hasAlias, note: `${heads} 个会话组头` };
|
||||
});
|
||||
|
||||
await check('待决策的会话默认展开(在等人的不能藏)', async () => {
|
||||
const txt = await page.locator('div.overflow-y-auto').innerText();
|
||||
if (/没有授权请求/.test(txt)) return { ok: true, note: '无授权请求,跳过' };
|
||||
const pendingBadge = await page.locator('span:has-text("待决策")').count();
|
||||
if (pendingBadge === 0) {
|
||||
return { ok: /已全部处理/.test(txt), note: '全部已处理,组头显示「已全部处理」' };
|
||||
}
|
||||
// 有待决策 → 组内应已展开,能看到「等待你决策」的行
|
||||
const rows = await page.locator('span:has-text("等待你决策")').count();
|
||||
return { ok: rows > 0, note: `${pendingBadge} 个待决策徽标,${rows} 行展开可见` };
|
||||
});
|
||||
|
||||
await check('已决策的历史折进二级,不默认铺开', async () => {
|
||||
const toggle = page.locator('button:has-text("已决策")');
|
||||
const n = await toggle.count();
|
||||
if (n === 0) return { ok: true, note: '无已决策历史,跳过' };
|
||||
const label = await toggle.first().innerText();
|
||||
return { ok: label.includes('展开'), note: label.trim() };
|
||||
});
|
||||
|
||||
await check('二级折叠可展开', async () => {
|
||||
const toggle = page.locator('button:has-text("已决策")');
|
||||
if ((await toggle.count()) === 0) return { ok: true, note: '无二级折叠,跳过' };
|
||||
const before = await rowCount();
|
||||
await toggle.first().click();
|
||||
await page.waitForTimeout(600);
|
||||
const after = await rowCount();
|
||||
return { ok: after > before, note: `${before} → ${after} 个条目` };
|
||||
});
|
||||
|
||||
await check('点一条授权请求能打开决策面板', async () => {
|
||||
// 授权行带「等待你决策」或决策结果;组头带「待决策 N」或「已全部处理」
|
||||
const row = page
|
||||
.locator('div.overflow-y-auto button')
|
||||
.filter({ hasText: /等待你决策|同意|拒绝/ })
|
||||
.filter({ hasNotText: /待决策 \d|已全部处理|展开已决策|收起已决策/ });
|
||||
if ((await row.count()) === 0) return { ok: true, note: '无授权请求,跳过' };
|
||||
await row.first().click();
|
||||
await page.waitForTimeout(1500);
|
||||
// 右栏出现权限决策按钮或已决策的结果说明
|
||||
const panel =
|
||||
(await page.locator('button:has-text("同意")').count()) > 0 ||
|
||||
(await page.locator('text=/已(同意|拒绝|决策)/').count()) > 0;
|
||||
return { ok: panel, note: panel ? '决策面板已渲染' : '右栏没有内容' };
|
||||
});
|
||||
|
||||
await check('组头可收起', async () => {
|
||||
const head = page.locator('div.overflow-y-auto > div > button').first();
|
||||
if ((await head.count()) === 0) return { ok: true, note: '无组头,跳过' };
|
||||
const before = await rowCount();
|
||||
await head.click();
|
||||
await page.waitForTimeout(600);
|
||||
const after = await rowCount();
|
||||
// 原本折叠的组点一下会展开;原本展开的会收起。两种都算「可切换」
|
||||
return { ok: after !== before, note: `${before} → ${after}` };
|
||||
});
|
||||
|
||||
// ───────────────── 徽标语义 ─────────────────
|
||||
console.log('\n徽标:');
|
||||
|
||||
await check('收件箱未读数不把权限请求算进来', async () => {
|
||||
const badge = page.locator('button[title*="收件箱"] span').filter({ hasText: /^\d+$/ });
|
||||
const inboxBadge = (await badge.count()) > 0 ? await badge.first().innerText() : '0';
|
||||
const permBadge = page.locator('button[title*="授权"] span').filter({ hasText: /^\d+$/ });
|
||||
const pBadge = (await permBadge.count()) > 0 ? await permBadge.first().innerText() : '0';
|
||||
// 两个数字各自独立;一个待批的 bash 不该在两处都计数
|
||||
return { ok: true, note: `收件箱未读 ${inboxBadge},授权待决策 ${pBadge}` };
|
||||
});
|
||||
|
||||
await check('无 JS 运行时错误', async () => {
|
||||
// 404 资源(favicon 之类)不算 JS 错误
|
||||
const real = issues.filter(i => !/404|Failed to load resource/.test(i));
|
||||
return { ok: real.length === 0, note: real.length ? real.slice(0, 2).join(' | ') : '无' };
|
||||
});
|
||||
|
||||
console.log(
|
||||
failed.length === 0
|
||||
? '\n收件箱分组 + 授权列表:全部通过'
|
||||
: `\n收件箱分组 + 授权列表:${failed.length} 项失败 — ${failed.join('、')}`
|
||||
);
|
||||
|
||||
await page.close();
|
||||
await browser.close();
|
||||
process.exit(failed.length === 0 ? 0 : 1);
|
||||
262
client/electron/test/manual/narrow-probe-helper.mjs
Normal file
262
client/electron/test/manual/narrow-probe-helper.mjs
Normal file
@ -0,0 +1,262 @@
|
||||
/**
|
||||
* 窄屏实测辅助:连本机共享 Chromium(CDP 127.0.0.1:9222)量真实盒子。
|
||||
*
|
||||
* 这是**手工脚本**,不进 `npm test` —— 它需要一个跑着的浏览器与一个活的
|
||||
* Gateway。日常回归靠 `../narrow-layout.test.mjs` 的结构性断言。
|
||||
*
|
||||
* 两者分工:结构性断言守住「代码写成了什么形态」,量不出「按钮实际多大、
|
||||
* 点下去命中谁」。抽屉遮挡底部导航那个 bug(`elementFromPoint` 命中抽屉里的
|
||||
* SVG 而不是导航按钮)只有这样才能发现。
|
||||
*
|
||||
* 用法:
|
||||
* ADMIN_PW=<密码> node client/electron/test/manual/narrow-verify.mjs
|
||||
* ADMIN_PW=<密码> node client/electron/test/manual/wide-regression.mjs
|
||||
*
|
||||
* 环境变量:
|
||||
* ADMIN_PW 必填,管理员密码
|
||||
* AGENTMAIL_URL 目标地址,默认 https://mail.jianfgit.xyz
|
||||
* CDP_URL 浏览器 CDP 端点,默认 http://127.0.0.1:9222
|
||||
* PLAYWRIGHT playwright 入口,默认 /usr/lib/node_modules/playwright/index.mjs
|
||||
*/
|
||||
const PLAYWRIGHT = process.env.PLAYWRIGHT || '/usr/lib/node_modules/playwright/index.mjs';
|
||||
const { chromium } = await import(PLAYWRIGHT);
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
const { extname, join } = await import('node:path');
|
||||
|
||||
const CDP = process.env.CDP_URL || 'http://127.0.0.1:9222';
|
||||
const APP = (process.env.AGENTMAIL_URL || 'https://mail.jianfgit.xyz').replace(/\/$/, '');
|
||||
const DIST = process.env.AGENTMAIL_DIST?.replace(/\/$/, '');
|
||||
const contentTypes = {
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.webp': 'image/webp',
|
||||
'.woff2': 'font/woff2'
|
||||
};
|
||||
|
||||
export const PHONE = { width: 390, height: 844 }; // iPhone 14 Pro
|
||||
export const SMALL = { width: 320, height: 568 }; // iPhone SE 1 代
|
||||
export const WIDE = { width: 1280, height: 800 };
|
||||
|
||||
export async function openApp(viewport = PHONE) {
|
||||
const browser = await chromium.connectOverCDP(CDP);
|
||||
const ctx = browser.contexts()[0] ?? (await browser.newContext());
|
||||
const page = await ctx.newPage();
|
||||
await page.setViewportSize(viewport);
|
||||
|
||||
const issues = [];
|
||||
page.on('pageerror', e => issues.push('pageerror: ' + String(e).slice(0, 220)));
|
||||
page.on('console', m => {
|
||||
if (m.type() === 'error') {
|
||||
const t = m.text();
|
||||
// 401 是未登录时的正常探测,不算问题
|
||||
if (!t.includes('401')) issues.push('console: ' + t.slice(0, 200));
|
||||
}
|
||||
});
|
||||
|
||||
// 在不替换、不重启现有 Gateway 的前提下验证本次构建:仅把页面壳和静态资源
|
||||
// 从 dist 注入当前标签页,API/SSE 仍由 APP 指向的真实 Gateway 提供。
|
||||
if (DIST) {
|
||||
const appOrigin = new URL(APP).origin;
|
||||
await page.route(`${appOrigin}/**`, async route => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
const relative = url.pathname === '/' ? 'index.html' : url.pathname.replace(/^\/+/, '');
|
||||
const file = join(DIST, relative);
|
||||
if (!file.startsWith(DIST + '/') && file !== join(DIST, 'index.html')) {
|
||||
await route.abort('blockedbyclient');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body = await readFile(file);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body,
|
||||
contentType: contentTypes[extname(file)] || 'application/octet-stream'
|
||||
});
|
||||
} catch {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 不能用 networkidle:SSE 是一条永不结束的长连接,networkidle 永远不触发
|
||||
await page.goto(APP + '/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
// 需要登录时登录(同一 context 共享 cookie,通常只登一次)
|
||||
if ((await page.locator('input[autocomplete=username]').count()) > 0) {
|
||||
if (!process.env.ADMIN_PW) throw new Error('需要 ADMIN_PW 环境变量');
|
||||
await page.fill('input[autocomplete=username]', process.env.ADMIN_USER || 'admin');
|
||||
await page.fill('input[type=password]', process.env.ADMIN_PW);
|
||||
await page.click('button:has-text("登录")');
|
||||
await page.waitForTimeout(3500);
|
||||
}
|
||||
return { browser, page, issues };
|
||||
}
|
||||
|
||||
/** 量一个元素的盒子;不存在返回 null。 */
|
||||
export async function box(page, sel) {
|
||||
const el = page.locator(sel).first();
|
||||
if ((await el.count()) === 0) return null;
|
||||
return await el.boundingBox();
|
||||
}
|
||||
|
||||
/**
|
||||
* 有没有横向溢出 —— 窄屏最常见的毛病。
|
||||
*
|
||||
* 只报 `right` 超过文档宽度的元素:溢出到左边通常是有意的负 margin。
|
||||
*/
|
||||
export async function overflowX(page) {
|
||||
return await page.evaluate(() => {
|
||||
const de = document.documentElement;
|
||||
const over = [];
|
||||
for (const el of document.querySelectorAll('*')) {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width > 0 && r.right > de.clientWidth + 1) {
|
||||
over.push({
|
||||
tag: el.tagName.toLowerCase(),
|
||||
cls: (el.className || '').toString().slice(0, 70),
|
||||
right: Math.round(r.right),
|
||||
text: (el.textContent || '').trim().slice(0, 40)
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
docWidth: de.clientWidth,
|
||||
scrollWidth: de.scrollWidth,
|
||||
bodyScrollWidth: document.body.scrollWidth,
|
||||
offenders: over.slice(0, 8)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击目标够不够大。
|
||||
*
|
||||
* 量的是**视觉尺寸**;有 `.tap` 的按钮视觉上仍然很小,命中区在
|
||||
* `::after` 伪元素上 —— 用 tapTargets() 才能看到真实命中区。
|
||||
*/
|
||||
export async function smallTargets(page, min = 40) {
|
||||
return await page.evaluate(min => {
|
||||
const bad = [];
|
||||
for (const el of document.querySelectorAll(
|
||||
'button, a, [role=button], input[type=checkbox]'
|
||||
)) {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width === 0 || r.height === 0) continue; // 隐藏的不算
|
||||
if (r.height < min || r.width < min) {
|
||||
bad.push({
|
||||
tag: el.tagName.toLowerCase(),
|
||||
w: Math.round(r.width),
|
||||
h: Math.round(r.height),
|
||||
text: (el.textContent || el.getAttribute('aria-label') || '').trim().slice(0, 28)
|
||||
});
|
||||
}
|
||||
}
|
||||
return bad;
|
||||
}, min);
|
||||
}
|
||||
|
||||
/**
|
||||
* 量 `.tap` 按钮的真实命中区(`::after` 伪元素的尺寸)。
|
||||
*
|
||||
* @param labels 只看这些文字的按钮
|
||||
*/
|
||||
export async function tapTargets(page, labels) {
|
||||
return await page.evaluate(labels => {
|
||||
const out = [];
|
||||
for (const b of document.querySelectorAll('button')) {
|
||||
const t = (b.textContent || '').trim();
|
||||
if (labels.length && !labels.includes(t)) continue;
|
||||
const bb = b.getBoundingClientRect();
|
||||
if (bb.width === 0) continue;
|
||||
const cs = getComputedStyle(b, '::after');
|
||||
out.push({
|
||||
t,
|
||||
visual: `${Math.round(bb.width)}x${Math.round(bb.height)}`,
|
||||
hitW: Math.round(parseFloat(cs.width) || 0),
|
||||
hitH: Math.round(parseFloat(cs.height) || 0)
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}, labels);
|
||||
}
|
||||
|
||||
/**
|
||||
* 每个元素点下去是否命中自己。
|
||||
*
|
||||
* 这是抽屉遮挡 bug 的检测手段:按钮明明在那里、尺寸也够,
|
||||
* 但上面盖了一层 `fixed z-50`,`elementFromPoint` 命中的是别人。
|
||||
*/
|
||||
export async function hitTest(page, selector) {
|
||||
return await page.evaluate(selector => {
|
||||
const out = [];
|
||||
for (const el of document.querySelectorAll(selector)) {
|
||||
const bb = el.getBoundingClientRect();
|
||||
if (bb.width === 0) continue;
|
||||
const top = document.elementFromPoint(
|
||||
Math.round(bb.x + bb.width / 2),
|
||||
Math.round(bb.y + bb.height / 2)
|
||||
);
|
||||
out.push({
|
||||
text: (el.textContent || '').replace(/\s+/g, '').slice(0, 8),
|
||||
hit: el.contains(top) || el === top
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}, selector);
|
||||
}
|
||||
|
||||
/**
|
||||
* 每个页面是否**有**纵向滚动容器。
|
||||
*
|
||||
* 判据是「存在 overflow-y:auto|scroll 的容器」,不是「当前正在滚动」——
|
||||
* 内容暂时不够高时后者为假,但页面是健康的。真正的 bug 是**根本没有**滚动
|
||||
* 容器:内容一旦超过视口就被 `overflow-hidden` 的父级裁掉,没有任何办法看到。
|
||||
*
|
||||
* 「我的」页就是这样坏的:内容(资料+权限+改密码+密钥+退出)在 390px 下需要
|
||||
* 860px,容器只有 795px,超出那 65px 连同「退出登录」按钮一起消失。
|
||||
*
|
||||
* @returns {{ hasScroller: boolean, scrollers: object[], clipped: object[] }}
|
||||
*/
|
||||
export async function scrollHealth(page) {
|
||||
return await page.evaluate(() => {
|
||||
const root = document.querySelector('#root');
|
||||
const scrollers = [];
|
||||
for (const el of root.querySelectorAll('*')) {
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.overflowY === 'auto' || cs.overflowY === 'scroll') {
|
||||
scrollers.push({
|
||||
cls: (el.className || '').toString().slice(0, 45),
|
||||
scrollH: el.scrollHeight,
|
||||
clientH: el.clientHeight,
|
||||
needsScroll: el.scrollHeight > el.clientHeight + 4
|
||||
});
|
||||
}
|
||||
}
|
||||
// 内容超出但被 overflow:hidden 的父级裁掉 —— 这才是真正的问题
|
||||
const clipped = [];
|
||||
for (const el of root.querySelectorAll('*')) {
|
||||
const cs = getComputedStyle(el);
|
||||
if (el.scrollHeight > el.clientHeight + 20 && cs.overflowY === 'visible') {
|
||||
const p = el.parentElement;
|
||||
const pcs = p ? getComputedStyle(p) : null;
|
||||
if (pcs && (pcs.overflow === 'hidden' || pcs.overflowY === 'hidden')) {
|
||||
clipped.push({
|
||||
cls: (el.className || '').toString().slice(0, 50),
|
||||
have: el.clientHeight,
|
||||
need: el.scrollHeight
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return { hasScroller: scrollers.length > 0, scrollers, clipped: clipped.slice(0, 4) };
|
||||
});
|
||||
}
|
||||
174
client/electron/test/manual/narrow-verify.mjs
Normal file
174
client/electron/test/manual/narrow-verify.mjs
Normal file
@ -0,0 +1,174 @@
|
||||
/**
|
||||
* 窄屏实测验收:390px 与 320px 下量真实盒子、真实命中。
|
||||
*
|
||||
* 每一条都对应一个曾经真实存在的问题(见 docs/PLAN.md §7.10.1):
|
||||
* 抽屉盖住底部导航、工具按钮只有 16px 高、看不见却按得动的「归档」、
|
||||
* 对话树缩进把卡片压成竖条、对话树没有返回出口。
|
||||
*
|
||||
* 用法:ADMIN_PW=<密码> node client/electron/test/manual/narrow-verify.mjs
|
||||
*/
|
||||
import {
|
||||
openApp,
|
||||
overflowX,
|
||||
tapTargets,
|
||||
hitTest,
|
||||
scrollHealth,
|
||||
SMALL
|
||||
} from './narrow-probe-helper.mjs';
|
||||
|
||||
const { browser, page, issues } = await openApp();
|
||||
const failed = [];
|
||||
|
||||
async function check(name, fn) {
|
||||
try {
|
||||
const r = await fn();
|
||||
console.log(` ${r.ok ? '通过' : '失败'} ${name}${r.note ? ' — ' + r.note : ''}`);
|
||||
if (!r.ok) failed.push(name);
|
||||
} catch (e) {
|
||||
console.log(` 错误 ${name} — ${e.message.slice(0, 90)}`);
|
||||
failed.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开收件箱里的第一封邮件。
|
||||
*
|
||||
* 邮件行是 `<button class="w-full text-left ...">`,不是带 cursor-pointer 的 div
|
||||
* —— 按后者找会一直等到超时。
|
||||
*/
|
||||
async function openFirstMail(page) {
|
||||
await page.click('nav button:has-text("收件")');
|
||||
await page.waitForTimeout(1000);
|
||||
const rows = page.locator('button.w-full.text-left');
|
||||
const n = await rows.count();
|
||||
if (n === 0) throw new Error('收件箱是空的,没有邮件可点');
|
||||
await rows.first().click();
|
||||
await page.waitForTimeout(1200);
|
||||
}
|
||||
|
||||
console.log('窄屏实测(390px):');
|
||||
|
||||
// 抽屉已删。它是 fixed z-50 铺满视口高度,把底部导航最左那项盖住点不到。
|
||||
await check('抽屉入口已移除', async () => {
|
||||
const n = await page.locator('button[aria-label="打开导航"]').count();
|
||||
return { ok: n === 0, note: n ? `仍有 ${n} 个` : '' };
|
||||
});
|
||||
|
||||
// 删抽屉时退出登录是它唯一的独有入口,必须有新去处
|
||||
await check('「我的」页有退出登录', async () => {
|
||||
await page.click('nav button:has-text("我的")');
|
||||
await page.waitForTimeout(1200);
|
||||
const btn = page.locator('button:has-text("退出登录")');
|
||||
const n = await btn.count();
|
||||
const b = n ? await btn.first().boundingBox() : null;
|
||||
return {
|
||||
ok: n === 1 && b.height >= 36,
|
||||
note: b ? `${Math.round(b.width)}x${Math.round(b.height)}` : '找不到'
|
||||
};
|
||||
});
|
||||
|
||||
// 核心回归:底部导航每一项都要命中自己
|
||||
await check('底部导航每项都命中自己', async () => {
|
||||
const r = await hitTest(page, 'nav button');
|
||||
const miss = r.filter(x => !x.hit);
|
||||
return {
|
||||
ok: r.length > 0 && miss.length === 0,
|
||||
note: miss.length ? '未命中: ' + miss.map(m => m.text).join(',') : `${r.length} 项全部命中`
|
||||
};
|
||||
});
|
||||
|
||||
// 详情页工具按钮:视觉 15-16px,命中区必须补到 44
|
||||
await check('详情页工具按钮命中区 >= 44px', async () => {
|
||||
await openFirstMail(page);
|
||||
|
||||
const r = await tapTargets(page, ['标记已读', '对话树', '转发', '抄送', '发送', '清空']);
|
||||
for (const x of r) console.log(' ', JSON.stringify(x));
|
||||
const small = r.filter(x => x.hitH < 44 || x.hitW < 44);
|
||||
return {
|
||||
ok: r.length > 0 && small.length === 0,
|
||||
note: small.length ? '仍偏小: ' + small.map(s => s.t).join(',') : `${r.length} 个都达标`
|
||||
};
|
||||
});
|
||||
|
||||
// 次要动作在触摸设备上必须可见(没有 hover 时曾经永远透明却接收点击)
|
||||
await check('联系人页次要动作默认可见', async () => {
|
||||
await page.click('nav button:has-text("联系人")');
|
||||
await page.waitForTimeout(1200);
|
||||
const r = await page.evaluate(() =>
|
||||
[...document.querySelectorAll('.reveal')].slice(0, 3).map(d => getComputedStyle(d).opacity)
|
||||
);
|
||||
return { ok: r.length > 0 && r.every(o => o === '1'), note: `opacity=${r.join(',')}` };
|
||||
});
|
||||
|
||||
// 对话树:窄屏要有返回出口
|
||||
await check('对话树有返回出口', async () => {
|
||||
await openFirstMail(page);
|
||||
const t = page.locator('button:has-text("对话树")');
|
||||
if ((await t.count()) === 0) return { ok: false, note: '找不到对话树入口' };
|
||||
await t.first().click();
|
||||
await page.waitForTimeout(1600);
|
||||
const n = await page.locator('button[aria-label="返回"]').count();
|
||||
return { ok: n >= 1, note: `${n} 个` };
|
||||
});
|
||||
|
||||
// 各页无横向溢出
|
||||
for (const label of ['收件', '联系人', '管理', '我的']) {
|
||||
await check(`${label}页无横向溢出`, async () => {
|
||||
await page.click(`nav button:has-text("${label}")`);
|
||||
await page.waitForTimeout(1100);
|
||||
const of = await overflowX(page);
|
||||
for (const o of of.offenders) console.log(' 超出:', JSON.stringify(o));
|
||||
return { ok: of.scrollWidth <= of.docWidth, note: `doc=${of.docWidth} scroll=${of.scrollWidth}` };
|
||||
});
|
||||
}
|
||||
|
||||
// 每个页面都必须有纵向滚动容器 —— 否则内容一超过视口就被裁掉看不到。
|
||||
// 「我的」页曾经缺这个:390px 下内容需 860px、容器 795px,
|
||||
// 「退出登录」按钮连同下面 65px 一起消失,滚也滚不到。
|
||||
for (const label of ['收件', '发件', '联系人', '管理', '我的']) {
|
||||
await check(`${label}页有纵向滚动容器`, async () => {
|
||||
await page.click(`nav button:has-text("${label}")`);
|
||||
await page.waitForTimeout(1200);
|
||||
const h = await scrollHealth(page);
|
||||
for (const c of h.clipped) console.log(' 被裁:', JSON.stringify(c));
|
||||
return {
|
||||
ok: h.hasScroller && h.clipped.length === 0,
|
||||
note: h.hasScroller
|
||||
? `${h.scrollers.length} 个容器${h.clipped.length ? ',但有内容被裁' : ''}`
|
||||
: '没有滚动容器'
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n最窄(320px):');
|
||||
await page.setViewportSize(SMALL);
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
await check('320px 收件箱无横向溢出', async () => {
|
||||
await page.click('nav button:has-text("收件")');
|
||||
await page.waitForTimeout(1000);
|
||||
const of = await overflowX(page);
|
||||
return { ok: of.scrollWidth <= of.docWidth, note: `doc=${of.docWidth} scroll=${of.scrollWidth}` };
|
||||
});
|
||||
|
||||
await check('320px 模型范围排序按钮命中区达标', async () => {
|
||||
await page.click('nav button:has-text("管理")');
|
||||
await page.waitForTimeout(900);
|
||||
await page.click('button:has-text("模型范围")');
|
||||
await page.waitForTimeout(1000);
|
||||
const first = page.locator('button:has-text("dsh")').first();
|
||||
if ((await first.count()) === 0) return { ok: false, note: '没有 Agent 可展开' };
|
||||
await first.click();
|
||||
await page.waitForTimeout(1600);
|
||||
const r = await tapTargets(page, ['↑', '↓', '×']);
|
||||
if (r.length === 0) return { ok: true, note: '当前没有已选模型,跳过' };
|
||||
const small = r.filter(x => x.hitH < 44 || x.hitW < 44);
|
||||
return { ok: small.length === 0, note: `${r.length} 个,最小 ${Math.min(...r.map(x => x.hitH))}px 高` };
|
||||
});
|
||||
|
||||
console.log('\nissues:', issues.length ? issues : '无');
|
||||
console.log(failed.length === 0 ? '\n窄屏实测:全部通过' : `\n窄屏实测:${failed.length} 项失败 — ${failed.join(', ')}`);
|
||||
|
||||
await page.close();
|
||||
await browser.close();
|
||||
process.exit(failed.length === 0 ? 0 : 1);
|
||||
221
client/electron/test/manual/responsive-verify.mjs
Normal file
221
client/electron/test/manual/responsive-verify.mjs
Normal file
@ -0,0 +1,221 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { extname, join } from 'node:path';
|
||||
|
||||
const PLAYWRIGHT = process.env.PLAYWRIGHT || '/usr/lib/node_modules/playwright/index.mjs';
|
||||
const { chromium } = await import(PLAYWRIGHT);
|
||||
const CDP = process.env.CDP_URL || 'http://127.0.0.1:9222';
|
||||
const APP = 'http://127.0.0.1:8180';
|
||||
const DIST = process.env.AGENTMAIL_DIST || '/home/program/agentmail/client/electron/dist';
|
||||
const types = {
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.woff2': 'font/woff2'
|
||||
};
|
||||
|
||||
const user = {
|
||||
user_id: 'u-test', username: 'tester', display_name: '测试用户', role: 'admin',
|
||||
status: 'active', allowed_agents: [], allowed_paths: [], created_at: '2025-01-01T00:00:00Z'
|
||||
};
|
||||
const mail = {
|
||||
mail_id: 'm-test', session_id: 's-test', parent_mail_id: null,
|
||||
from_name: 'deepseekharness', from_workspace: 'deepseekharness', to_name: 'tester', to_workspace: '',
|
||||
cc_list: [], subject: '响应式布局测试邮件', body: '## 正文\n\n用于浏览器布局验证。', body_preview: '用于浏览器布局验证。',
|
||||
mail_type: 'normal', permission_options: null, permission_result: null, status: 'unread',
|
||||
created_at: '2025-01-02T03:04:00Z', session_alias: 'layout-test', session_workspace: '/program/test',
|
||||
attachments: [], from_human: false, to_human: true
|
||||
};
|
||||
const contact = {
|
||||
session_id: 's-test', agent_name: 'deepseekharness', path: '/program/test', session_alias: 'layout-test',
|
||||
address: 'deepseekharness@/program/test.layout-test', status: 'active', mail_count: 1, unread_count: 1,
|
||||
last_activity: '2025-01-02T03:04:00Z', subject: '响应式布局测试邮件', max_rounds: 0, used_rounds: 0,
|
||||
last_from: 'deepseekharness', last_preview: '用于浏览器布局验证。'
|
||||
};
|
||||
|
||||
function json(route, body, status = 200) {
|
||||
return route.fulfill({ status, contentType: 'application/json; charset=utf-8', body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
async function mockApi(route, url) {
|
||||
const p = url.pathname.replace(/^\/api\/v1/, '');
|
||||
if (p === '/auth/me') return json(route, { user });
|
||||
if (p === '/setup/status') return json(route, { needs_setup: false });
|
||||
if (p === '/me/mail/inbox') return json(route, { mails: [mail], total: 1 });
|
||||
if (p === '/me/mail/sent') return json(route, { mails: [] });
|
||||
if (p === '/me/sessions') return json(route, { sessions: [] });
|
||||
if (p === '/contacts') return json(route, { contacts: [contact] });
|
||||
if (p === '/contacts/suggest') {
|
||||
return json(route, {
|
||||
kind: 'name', suggestions: ['deepseekharness', 'pi', 'opencode'],
|
||||
candidates: [
|
||||
{ alias: 'deepseekharness', source: 'mail', title: '布局测试', unread: 1 },
|
||||
{ alias: 'pi', source: 'platform', title: 'Pi' },
|
||||
{ alias: 'opencode', source: 'platform', title: 'OpenCode' }
|
||||
]
|
||||
});
|
||||
}
|
||||
if (p === '/agents') return json(route, { agents: [] });
|
||||
if (p === '/me/keys') return json(route, { keys: [] });
|
||||
if (p === '/admin/users') return json(route, { users: [user] });
|
||||
if (p === '/admin/scopes') return json(route, { agents: [], paths: [] });
|
||||
if (p === '/admin/quotas') return json(route, { quotas: [] });
|
||||
if (p === '/calendar/events') return json(route, { events: [] });
|
||||
if (p === '/events/stream') {
|
||||
return route.fulfill({ status: 200, contentType: 'text/event-stream', body: 'event: connected\ndata: {}\n\n' });
|
||||
}
|
||||
if (p === '/mail/m-test') return json(route, mail);
|
||||
if (p === '/mail/m-test/read') return json(route, { status: 'ok' });
|
||||
if (p === '/mail/m-test/thread') {
|
||||
return json(route, { anchor_mail_id: 'm-test', root_mail_id: 'm-test', anchor_depth: 0, nodes: [], total: 0, hidden: 0, has_more: false, next_offset: 0 });
|
||||
}
|
||||
return json(route, { error: `unmocked ${p}` }, 404);
|
||||
}
|
||||
|
||||
async function installRoutes(page) {
|
||||
await page.route(`${APP}/**`, async route => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.pathname.startsWith('/api/v1/')) return mockApi(route, url);
|
||||
const rel = url.pathname === '/' ? 'index.html' : url.pathname.replace(/^\/+/, '');
|
||||
const file = join(DIST, rel);
|
||||
try {
|
||||
const body = await readFile(file);
|
||||
return route.fulfill({ status: 200, body, contentType: types[extname(file)] || 'application/octet-stream' });
|
||||
} catch {
|
||||
return route.fulfill({ status: 404, body: 'not found' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const browser = await chromium.connectOverCDP(CDP);
|
||||
const context = browser.contexts()[0];
|
||||
const failures = [];
|
||||
let passed = 0;
|
||||
function check(name, ok, detail = '') {
|
||||
console.log(` ${ok ? '通过' : '失败'} ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
if (ok) passed++; else failures.push(name);
|
||||
}
|
||||
|
||||
async function open(viewport) {
|
||||
const page = await context.newPage();
|
||||
await page.setViewportSize(viewport);
|
||||
await installRoutes(page);
|
||||
await page.goto(`${APP}/`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('text=收件箱');
|
||||
await page.waitForTimeout(250);
|
||||
return page;
|
||||
}
|
||||
|
||||
async function openCompose(page, narrow) {
|
||||
if (narrow) await page.locator('nav button').filter({ hasText: '新建' }).click();
|
||||
else await page.locator('button[title="新建邮件"]').click();
|
||||
await page.waitForSelector('textarea');
|
||||
}
|
||||
|
||||
async function measureCompose(page, name, narrow) {
|
||||
await openCompose(page, narrow);
|
||||
const m = await page.evaluate(() => {
|
||||
const ta = document.querySelector('textarea');
|
||||
const attach = [...document.querySelectorAll('button')].find(b => b.textContent?.includes('添加附件'));
|
||||
const send = [...document.querySelectorAll('button')].find(b => b.textContent?.trim() === '发送');
|
||||
const root = ta?.closest('.overflow-y-auto, .lg\\:overflow-hidden');
|
||||
attach?.scrollIntoView({ block: 'center' });
|
||||
const tr = ta?.getBoundingClientRect();
|
||||
const ar = attach?.getBoundingClientRect();
|
||||
const sr = send?.parentElement?.getBoundingClientRect();
|
||||
return {
|
||||
textareaH: tr?.height || 0,
|
||||
textareaBottom: tr?.bottom || 0,
|
||||
attachmentTop: ar?.top || 0,
|
||||
attachmentBottom: ar?.bottom || 0,
|
||||
footerTop: sr?.top || 0,
|
||||
separated: !!tr && !!ar && ar.top >= tr.bottom - 1,
|
||||
footerSeparated: !!ar && !!sr && sr.top >= ar.bottom - 1,
|
||||
docW: document.documentElement.clientWidth,
|
||||
scrollW: document.documentElement.scrollWidth,
|
||||
composeScrollH: root?.scrollHeight || 0,
|
||||
composeClientH: root?.clientHeight || 0
|
||||
};
|
||||
});
|
||||
check(
|
||||
narrow ? `${name} 正文不少于 12rem` : `${name} 桌面正文占据剩余高度`,
|
||||
m.textareaH >= (narrow ? 191 : 120),
|
||||
`${Math.round(m.textareaH)}px`
|
||||
);
|
||||
check(
|
||||
`${name} 附件不遮挡正文`,
|
||||
m.separated,
|
||||
`正文底=${Math.round(m.textareaBottom)} 附件顶=${Math.round(m.attachmentTop)}`
|
||||
);
|
||||
check(
|
||||
`${name} 操作栏不遮挡附件`,
|
||||
m.footerSeparated,
|
||||
`附件底=${Math.round(m.attachmentBottom)} 操作栏顶=${Math.round(m.footerTop)}`
|
||||
);
|
||||
check(`${name} 无横向溢出`, m.scrollW <= m.docW, `${m.docW}/${m.scrollW}`);
|
||||
return m;
|
||||
}
|
||||
|
||||
for (const viewport of [
|
||||
{ width: 320, height: 568 }, { width: 390, height: 844 },
|
||||
{ width: 768, height: 900 }, { width: 820, height: 980 }
|
||||
]) {
|
||||
const page = await open(viewport);
|
||||
const label = `${viewport.width}x${viewport.height}`;
|
||||
check(`${label} 使用单栏底部导航`, await page.locator('nav.narrow-nav').isVisible());
|
||||
await measureCompose(page, label, true);
|
||||
await page.close();
|
||||
}
|
||||
|
||||
{
|
||||
const page = await open({ width: 390, height: 420 });
|
||||
const m = await measureCompose(page, '390x420 键盘态', true);
|
||||
check('键盘态写信页可纵向滚动', m.composeScrollH > m.composeClientH, `${m.composeClientH}/${m.composeScrollH}`);
|
||||
await page.locator('textarea').focus();
|
||||
await page.waitForTimeout(50);
|
||||
const navHidden = await page.locator('nav.narrow-nav').evaluate(el => getComputedStyle(el).display === 'none');
|
||||
check('输入时隐藏底部导航', navHidden);
|
||||
const to = page.locator('input[placeholder*="deepseekharness"]').first();
|
||||
await to.fill('d');
|
||||
await page.waitForTimeout(250);
|
||||
const menu = page.locator('div.absolute.z-20').first();
|
||||
const bounds = await menu.evaluate(el => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { top: r.top, bottom: r.bottom, vh: window.visualViewport?.height || innerHeight };
|
||||
});
|
||||
check('地址补全不越出可视视口', bounds.top >= 0 && bounds.bottom <= bounds.vh + 1, JSON.stringify(bounds));
|
||||
await page.close();
|
||||
}
|
||||
|
||||
for (const viewport of [{ width: 1024, height: 768 }, { width: 1280, height: 800 }]) {
|
||||
const page = await open(viewport);
|
||||
const label = `${viewport.width}x${viewport.height}`;
|
||||
check(`${label} 使用桌面侧栏`, await page.locator('button[title="新建邮件"]').isVisible());
|
||||
await measureCompose(page, label, false);
|
||||
await page.close();
|
||||
}
|
||||
|
||||
{
|
||||
const page = await open({ width: 1280, height: 800 });
|
||||
for (const mode of ['light', 'dark']) {
|
||||
await page.evaluate(mode => {
|
||||
localStorage.setItem('agentmail.theme', mode);
|
||||
document.documentElement.classList.toggle('dark', mode === 'dark');
|
||||
document.documentElement.style.colorScheme = mode;
|
||||
}, mode);
|
||||
const colors = await page.evaluate(() => ({
|
||||
body: getComputedStyle(document.body).backgroundColor,
|
||||
card: getComputedStyle(document.querySelector('.bg-white')).backgroundColor,
|
||||
text: getComputedStyle(document.querySelector('.text-gray-900') || document.body).color
|
||||
}));
|
||||
check(`${mode} 主题颜色均已解析`, !Object.values(colors).some(c => c === 'rgba(0, 0, 0, 0)'), JSON.stringify(colors));
|
||||
}
|
||||
await page.close();
|
||||
}
|
||||
|
||||
console.log(`\n响应式浏览器验收:${passed} 通过,${failures.length} 失败`);
|
||||
if (failures.length) console.log(failures.join('\n'));
|
||||
await browser.close();
|
||||
process.exit(failures.length ? 1 : 0);
|
||||
148
client/electron/test/manual/theme-verify.mjs
Normal file
148
client/electron/test/manual/theme-verify.mjs
Normal file
@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 深色主题手工验收。
|
||||
*
|
||||
* 需要共享 Chromium(CDP 9222)。结构性检查已在 test/theme.test.mjs 里,
|
||||
* 这里验的是**真实渲染出来的对比度** —— 那是唯一能发现白底白字的判据。
|
||||
*
|
||||
* 用法:
|
||||
* ADMIN_USER=jianf ADMIN_PW=... AGENTMAIL_URL=http://127.0.0.1:8180 \
|
||||
* node client/electron/test/manual/theme-verify.mjs
|
||||
*/
|
||||
import { openApp, WIDE } from './narrow-probe-helper.mjs';
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const check = (name, ok, detail = '') => {
|
||||
if (ok) { pass++; console.log(` 通过 ${name}`); }
|
||||
else { fail++; console.log(` 失败 ${name}${detail ? ' — ' + detail : ''}`); }
|
||||
};
|
||||
|
||||
/** 相对亮度(WCAG)。用于判断 body 底色是否真的变暗。 */
|
||||
function luminance([r, g, b]) {
|
||||
const f = c => {
|
||||
c /= 255;
|
||||
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
|
||||
}
|
||||
|
||||
const parseRgb = s => {
|
||||
const m = String(s).match(/(\d+),\s*(\d+),\s*(\d+)/);
|
||||
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
||||
};
|
||||
|
||||
const { browser, page, issues } = await openApp(WIDE);
|
||||
|
||||
try {
|
||||
|
||||
for (const mode of ['light', 'dark']) {
|
||||
console.log(`\n── ${mode} ──`);
|
||||
await page.evaluate(m => {
|
||||
localStorage.setItem('agentmail.theme', m);
|
||||
document.documentElement.classList.toggle('dark', m === 'dark');
|
||||
document.documentElement.style.colorScheme = m;
|
||||
}, mode);
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
const body = parseRgb(await page.evaluate(() =>
|
||||
getComputedStyle(document.body).backgroundColor));
|
||||
check(`${mode}: body 底色可读取`, body !== null, String(body));
|
||||
|
||||
if (mode === 'dark') {
|
||||
// 深色下 body 必须是暗的。这一条挂掉说明变量没生效
|
||||
check('dark: body 底色确实是暗的', luminance(body) < 0.2,
|
||||
`亮度 ${luminance(body).toFixed(3)}`);
|
||||
}
|
||||
|
||||
// 遍历可见文本节点,算每个的前景/背景对比度。
|
||||
// 4.5:1 是 WCAG AA 的正文标准;大字放宽到 3:1。
|
||||
const bad = await page.evaluate(() => {
|
||||
const out = [];
|
||||
const els = document.querySelectorAll('button, a, h1, h2, h3, p, span, div, label, li');
|
||||
const lum = ([r, g, b]) => {
|
||||
const f = c => { c /= 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
|
||||
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
|
||||
};
|
||||
const parse = s => {
|
||||
const m = String(s).match(/(\d+),\s*(\d+),\s*(\d+)/);
|
||||
return m ? [+m[1], +m[2], +m[3]] : null;
|
||||
};
|
||||
/** 往上找第一个不透明的背景。 */
|
||||
const bgOf = el => {
|
||||
let cur = el;
|
||||
while (cur && cur !== document.documentElement) {
|
||||
const cs = getComputedStyle(cur);
|
||||
const c = parse(cs.backgroundColor);
|
||||
const alpha = String(cs.backgroundColor).match(/rgba?\([^)]*,\s*([\d.]+)\)/);
|
||||
if (c && (!alpha || Number(alpha[1]) > 0.85)) return c;
|
||||
cur = cur.parentElement;
|
||||
}
|
||||
return parse(getComputedStyle(document.body).backgroundColor);
|
||||
};
|
||||
for (const el of els) {
|
||||
// 只看直接含文本的元素
|
||||
const text = [...el.childNodes]
|
||||
.filter(n => n.nodeType === 3)
|
||||
.map(n => n.textContent.trim())
|
||||
.join('');
|
||||
if (!text) continue;
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width < 4 || r.height < 4) continue;
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.visibility === 'hidden' || Number(cs.opacity) < 0.75) continue;
|
||||
if (el.matches(':disabled, [aria-disabled="true"]')) continue;
|
||||
// 方向箭头、分隔点与关闭符号是装饰/图标,不按正文文字审计。
|
||||
if (/^[→·×↑↓]+$/.test(text)) continue;
|
||||
const fg = parse(cs.color);
|
||||
const bg = bgOf(el);
|
||||
if (!fg || !bg) continue;
|
||||
const la = lum(fg), lb = lum(bg);
|
||||
const [hi, lo] = la > lb ? [la, lb] : [lb, la];
|
||||
const ratio = (hi + 0.05) / (lo + 0.05);
|
||||
const size = parseFloat(cs.fontSize);
|
||||
const bold = Number(cs.fontWeight) >= 700;
|
||||
const large = size >= 24 || (size >= 18.66 && bold);
|
||||
const need = large ? 3 : 4.5;
|
||||
if (ratio < need) {
|
||||
out.push({
|
||||
text: text.slice(0, 24),
|
||||
ratio: Number(ratio.toFixed(2)),
|
||||
need,
|
||||
fg: cs.color,
|
||||
bg: `rgb(${bg.join(',')})`
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
// 禁用态与纯图标已在页面端过滤,其余可见文字都应达到自身 AA 阈值。
|
||||
check(`${mode}: 可见文字全部达到 WCAG AA`, bad.length === 0,
|
||||
bad.slice(0, 4).map(x => `"${x.text}" ${x.ratio}/${x.need}`).join(' | '));
|
||||
if (bad.length) {
|
||||
console.log(` (${bad.length} 处低于 AA 阈值,最差 ${Math.min(...bad.map(x => x.ratio))}:1)`);
|
||||
// 逐条列出来而不只报个数:不知道是哪一处就没法修
|
||||
for (const x of bad.slice(0, 8)) {
|
||||
console.log(` ${x.ratio}:1 (需 ${x.need}) "${x.text}" ${x.fg} on ${x.bg}`);
|
||||
}
|
||||
}
|
||||
|
||||
await page.screenshot({ path: `/tmp/theme-${mode}.png`, fullPage: false });
|
||||
}
|
||||
|
||||
// 刷新后主题必须保持(localStorage + 内联脚本)
|
||||
await page.evaluate(() => localStorage.setItem('agentmail.theme', 'dark'));
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(600);
|
||||
const stillDark = await page.evaluate(() =>
|
||||
document.documentElement.classList.contains('dark'));
|
||||
check('刷新后深色保持(内联脚本生效)', stillDark);
|
||||
|
||||
check('无 JS 运行时错误', issues.length === 0, issues.slice(0, 3).join(' | '));
|
||||
} finally {
|
||||
await page.close();
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
console.log(`\n主题验收:${pass} 通过${fail ? `,${fail} 失败` : ''}`);
|
||||
console.log('截图:/tmp/theme-light.png /tmp/theme-dark.png');
|
||||
process.exit(fail ? 1 : 0);
|
||||
583
client/electron/test/manual/ui-sweep.mjs
Normal file
583
client/electron/test/manual/ui-sweep.mjs
Normal file
@ -0,0 +1,583 @@
|
||||
/**
|
||||
* WebUI 能力巡检 —— 覆盖没有专项脚本守着的界面。
|
||||
*
|
||||
* 已有六个专项脚本各守一件事(窄屏覆盖式布局、宽屏三栏回归、深色主题、
|
||||
* 强调色对比度、收件箱分组、地址维度)。它们加起来仍有一大半界面没人看:
|
||||
* 日历三视图与事件编辑器、新建邮件的三段式补全、卡片/列表双视图、对话树、
|
||||
* 转发页、管理页四个 tab、账号页与密钥面板。这个脚本补的就是那部分。
|
||||
*
|
||||
* # 只读原则(它跑在生产库上)
|
||||
*
|
||||
* 一律不做不可逆动作:不发信、不建日程、不停用/删除 Agent、不吊销密钥、
|
||||
* 不改密码。表单只打开与填写,靠「取消」退出。
|
||||
*
|
||||
* 归档是唯一需要解释的例外:点每行的归档图标只调 `requestArchive`,那是个
|
||||
* **纯前端状态**(把该地址记进 `pendingArchive` 以渲染确认框),真正发请求的是
|
||||
* 确认框里的「确认归档」。所以这里点图标 → 验确认框 → 点取消,全程零副作用。
|
||||
*
|
||||
* # 三条定位纪律(每条都是被一次假失败教出来的)
|
||||
*
|
||||
* **一、走 title / placeholder,不走可见文字。** 侧栏图标按钮的可见文字是缩写
|
||||
* (`收件`/`联系`/`用户`),头像按钮的可见文字是用户名前两字 —— 按全名去
|
||||
* `hasText` 一个都点不到。而「新建」在侧栏(新建邮件)与日历头部(新建日程)
|
||||
* 各有一个,靠文字选会点错页面:侧栏那个有 `title`,日历那个没有。
|
||||
*
|
||||
* **二、单字按钮必须精确匹配。** 日历的视图切换是 `月`/`周`/`日` 三个单字按钮,
|
||||
* 而 `hasText` 是子串匹配 —— `日` 会先命中侧栏的 `日历`,于是「切到日视图」
|
||||
* 实际上点了导航,scale 一直停在上一个视图,然后「日视图没有小时刻度」
|
||||
* 报一个假失败。
|
||||
*
|
||||
* **三、判「某控件在不在」不搜 body 全文。** `AddressInput` 补全下拉的 hint
|
||||
* 里就写着「会话别名(new 为新建)」,全文搜索会把补全提示误当成那个只在
|
||||
* 新建会话时出现的输入框。判据要落在 placeholder / inputmode 上。
|
||||
*
|
||||
* 用法:
|
||||
* ADMIN_PW=<密码> ADMIN_USER=jianf AGENTMAIL_URL=http://127.0.0.1:8180 \
|
||||
* node client/electron/test/manual/ui-sweep.mjs
|
||||
*/
|
||||
import { openApp, WIDE } from './narrow-probe-helper.mjs';
|
||||
|
||||
const { browser, page, issues } = await openApp(WIDE);
|
||||
|
||||
let pass = 0;
|
||||
const fails = [];
|
||||
const skips = [];
|
||||
|
||||
const ok = (n, d = '') => { pass++; console.log(` 通过 ${n}${d ? ' — ' + d : ''}`); };
|
||||
const bad = (n, d = '') => { fails.push(n); console.log(` 失败 ${n}${d ? ' — ' + d : ''}`); };
|
||||
const skip = (n, why) => { skips.push(n); console.log(` 跳过 ${n} — ${why}`); };
|
||||
const check = (n, cond, d = '') => (cond ? ok(n, d) : bad(n, d));
|
||||
|
||||
async function section(title, fn) {
|
||||
console.log(`\n─── ${title} ───`);
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
bad(`${title} 整段异常`, String(e?.message || e).slice(0, 150));
|
||||
}
|
||||
}
|
||||
|
||||
/** 点一个 CSS 选择器命中的元素;找不到返回 false 而不是抛。 */
|
||||
async function click(sel, wait = 0) {
|
||||
const loc = page.locator(sel).first();
|
||||
if ((await loc.count()) === 0) return false;
|
||||
await loc.click({ timeout: 5000 }).catch(() => {});
|
||||
if (wait) await page.waitForTimeout(wait);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 侧栏导航:可见文字是缩写,title 才是全名。 */
|
||||
const nav = (title, wait = 1200) => click(`button[title="${title}"]`, wait);
|
||||
|
||||
/** 按可见文字点按钮(子串匹配,用于 tab、取消这类长标签)。 */
|
||||
async function clickText(text, wait = 0) {
|
||||
const loc = page.locator('button').filter({ hasText: text }).first();
|
||||
if ((await loc.count()) === 0) return false;
|
||||
await loc.click({ timeout: 5000 }).catch(() => {});
|
||||
if (wait) await page.waitForTimeout(wait);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 按可见文字**精确**点按钮。单字标签(月/周/日)必须走这个。 */
|
||||
async function clickExact(text, wait = 0) {
|
||||
const loc = page.locator('button').filter({ hasText: new RegExp(`^${text}$`) }).first();
|
||||
if ((await loc.count()) === 0) return false;
|
||||
await loc.click({ timeout: 5000 }).catch(() => {});
|
||||
if (wait) await page.waitForTimeout(wait);
|
||||
return true;
|
||||
}
|
||||
|
||||
const has = (sel) => page.locator(sel).count().then((n) => n > 0);
|
||||
const bodyText = () => page.evaluate(() => document.body.innerText);
|
||||
const titles = () =>
|
||||
page.evaluate(() =>
|
||||
[...document.querySelectorAll('button[title]')].map((b) => b.title).slice(0, 24));
|
||||
|
||||
await page.waitForSelector('button', { timeout: 20000 });
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
await section('实时推送与连接状态', async () => {
|
||||
check('EventSource 可用', await page.evaluate(() => typeof window.EventSource === 'function'));
|
||||
// 指示器只在异常时出声是合理设计 —— 这里要的是「不会在正常时误报断线」
|
||||
const shown = await page.evaluate(() =>
|
||||
[...document.querySelectorAll('*')]
|
||||
.filter((e) => e.children.length === 0)
|
||||
.map((e) => (e.textContent || '').trim())
|
||||
.find((t) => /已断开|连接中|重连中/.test(t)) || null);
|
||||
check('正常状态下不误报断线', !shown, shown ? `显示了「${shown}」` : '');
|
||||
check('侧栏头像上挂着连接状态点', await has('button[title*="点击管理账号"] span'));
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
await section('日历:月 / 周 / 日三视图 + 农历', async () => {
|
||||
if (!(await nav('日历', 3200))) return bad('找不到日历入口', (await titles()).join(' / '));
|
||||
|
||||
// 月视图与周视图都是 grid-cols-7,靠子元素数区分:月 = 7 表头 + 42 格,周 = 7 列
|
||||
const cells = () => page.locator('[class*="grid-cols-7"] > *').count();
|
||||
const hourMarks = async () => ((await bodyText()).match(/\b([01]\d|2[0-3]):00\b/g) || []).length;
|
||||
|
||||
const month = await page.evaluate(() => {
|
||||
const txt = document.body.innerText;
|
||||
return {
|
||||
weekHeader: /日[\s\S]{0,8}一[\s\S]{0,8}二[\s\S]{0,8}三/.test(txt),
|
||||
lunar: (txt.match(/初[一二三四五六七八九十]|十[一二三四五]|廿[一二三四五六七八九]|正月|腊月|冬月/g) || []).length,
|
||||
scaleBtns: [...document.querySelectorAll('button')]
|
||||
.map((b) => (b.innerText || '').trim())
|
||||
.filter((t) => ['月', '周', '日'].includes(t)),
|
||||
};
|
||||
});
|
||||
check('月视图有星期表头', month.weekHeader);
|
||||
check('月视图是整月网格', (await cells()) >= 42, `${await cells()} 个格子`);
|
||||
check('显示农历', month.lunar > 0, `命中 ${month.lunar} 处`);
|
||||
check('有三档视图切换', month.scaleBtns.length === 3, month.scaleBtns.join(','));
|
||||
check('月视图不按小时排', (await hourMarks()) === 0);
|
||||
|
||||
// 周视图:7 列按天,每列头显示 M.D。它刻意**不**按小时排 ——
|
||||
// 一周 × 24 小时的格子在任何屏宽下都读不了,按小时是日视图的事
|
||||
if (await clickExact('周', 1500)) {
|
||||
const dates = ((await bodyText()).match(/\b\d{1,2}\.\d{1,2}\b/g) || []).length;
|
||||
check('周视图是 7 列', (await cells()) === 7, `${await cells()} 列`);
|
||||
check('周视图每列有日期', dates >= 7, `${dates} 个 M.D`);
|
||||
check('周视图不按小时排', (await hourMarks()) === 0);
|
||||
} else skip('周视图', '按钮不存在');
|
||||
|
||||
// 日视图:唯一按小时排的视图,HOURS 是完整 24 行
|
||||
if (await clickExact('日', 1500)) {
|
||||
const marks = await hourMarks();
|
||||
check('日视图有 24 行小时刻度', marks >= 24, `${marks} 个 HH:00`);
|
||||
} else skip('日视图', '按钮不存在');
|
||||
|
||||
await clickExact('月', 1200);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
await section('日历:新建事件(打开后取消,不保存)', async () => {
|
||||
// 侧栏的「新建」有 title="新建邮件",日历头部那个没有 title —— 用它区分
|
||||
const calNew = page.locator('button:not([title])').filter({ hasText: /^新建$/ }).first();
|
||||
if ((await calNew.count()) === 0) return skip('新建事件', '找不到日历的新建按钮');
|
||||
await calNew.click().catch(() => {});
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const ed = await page.evaluate(() => {
|
||||
const txt = document.body.innerText;
|
||||
const inputs = [...document.querySelectorAll('input, select, textarea')];
|
||||
return {
|
||||
count: inputs.length,
|
||||
kinds: [...new Set(inputs.map((i) => i.type || i.tagName.toLowerCase()))].join(','),
|
||||
title: /标题/.test(txt),
|
||||
time: !!document.querySelector('input[type=datetime-local]'),
|
||||
remind: /提前|提醒/.test(txt),
|
||||
recur: /重复|不重复|每天|每周|每月|每年/.test(txt),
|
||||
lunar: /农历/.test(txt),
|
||||
preview: /Agent 会收到/.test(txt),
|
||||
// 附件要挂在某个 event_id 上才能上传,所以新建态**刻意**没有附件段
|
||||
// (`{editing && ...}`)—— 存盘后再打开才有
|
||||
attach: /附件(随提醒邮件一起发出)/.test(txt),
|
||||
// 单收件人时不该出现投递模式:那是多收件人才有的选择
|
||||
deliveryModeEarly: /分别发送|一起发送/.test(txt),
|
||||
};
|
||||
});
|
||||
check('有输入控件', ed.count >= 3, `${ed.count} 个(${ed.kinds})`);
|
||||
check('有标题字段', ed.title);
|
||||
check('有 datetime-local 时间选择', ed.time);
|
||||
check('有提醒提前量', ed.remind);
|
||||
check('有重复规则', ed.recur);
|
||||
check('重复规则含农历选项', ed.lunar);
|
||||
check('有「Agent 会收到」正文预览', ed.preview);
|
||||
check('新建态不显示附件段(附件需先有 event_id)', !ed.attach);
|
||||
check('收件人不足两个时不显示投递模式', !ed.deliveryModeEarly);
|
||||
|
||||
// 加两个收件人:`unusedAgents` 快捷按钮的文字是 `+ <agent名>`
|
||||
const added = await page.evaluate(() => {
|
||||
const btns = [...document.querySelectorAll('button')]
|
||||
.filter((b) => /^\+\s+\S+$/.test((b.innerText || '').trim()));
|
||||
btns.slice(0, 2).forEach((b) => b.click());
|
||||
return btns.slice(0, 2).map((b) => (b.innerText || '').trim());
|
||||
});
|
||||
await page.waitForTimeout(900);
|
||||
if (added.length < 2) {
|
||||
skip('多收件人投递模式', `只找到 ${added.length} 个快捷收件人按钮`);
|
||||
} else {
|
||||
const dm = await page.evaluate(() => {
|
||||
const txt = document.body.innerText;
|
||||
return {
|
||||
separate: /分别发送/.test(txt),
|
||||
together: /一起发送/.test(txt),
|
||||
radios: document.querySelectorAll('input[type=radio]').length,
|
||||
// 两种模式的后果必须写清楚,否则人分不出该选哪个
|
||||
explains: /互相看不到/.test(txt) && /共享同一条线索/.test(txt),
|
||||
};
|
||||
});
|
||||
check('多收件人出现「分别发送」', dm.separate, added.join(' '));
|
||||
check('多收件人出现「一起发送」', dm.together);
|
||||
check('投递模式是单选而非多选', dm.radios >= 2, `${dm.radios} 个 radio`);
|
||||
check('两种模式都说明了后果', dm.explains);
|
||||
}
|
||||
|
||||
if (!(await clickText('取消', 900))) await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
await section('日历:打开已有事件(附件段只在编辑态出现)', async () => {
|
||||
// 月视图格子是 div,事件胶囊才是带 title 的 button
|
||||
const chip = page.locator('[class*="grid-cols-7"] button[title]').first();
|
||||
if ((await chip.count()) === 0) return skip('已有事件编辑态', '本月没有事件可点');
|
||||
const label = await chip.getAttribute('title');
|
||||
await chip.click().catch(() => {});
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const e = await page.evaluate(() => {
|
||||
const txt = document.body.innerText;
|
||||
return {
|
||||
attach: /附件(随提醒邮件一起发出)/.test(txt),
|
||||
// 暂停/取消的事件不再触发提醒 —— 状态必须可改,否则只能删了重建
|
||||
status: !!document.querySelector('select') && /暂停|已取消/.test(txt),
|
||||
del: [...document.querySelectorAll('button')].some((b) => /删除/.test(b.innerText || '')),
|
||||
};
|
||||
});
|
||||
check('编辑已有事件时出现附件段', e.attach, label || '');
|
||||
check('可改事件状态', e.status);
|
||||
check('有删除入口(未点击)', e.del);
|
||||
|
||||
if (!(await clickText('取消', 900))) await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
await section('新建邮件:整页 + 三段式补全', async () => {
|
||||
await nav('收件箱', 1000);
|
||||
if (!(await nav('新建邮件', 1500))) return bad('找不到新建邮件入口', (await titles()).join(' / '));
|
||||
|
||||
const c = await page.evaluate(() => {
|
||||
const txt = document.body.innerText;
|
||||
return {
|
||||
// 「新建邮件 = 右侧整页而非弹窗」是明确的设计决定:
|
||||
// 判据是没有铺满视口的半透明遮罩
|
||||
overlay: !!document.querySelector('div.fixed.inset-0[class*="bg-black"]'),
|
||||
subject: /主题/.test(txt),
|
||||
cc: /抄送/.test(txt),
|
||||
attach: /附件/.test(txt),
|
||||
preview: /预览/.test(txt),
|
||||
};
|
||||
});
|
||||
check('是整页而非弹窗', !c.overlay);
|
||||
check('有主题', c.subject);
|
||||
check('有正文 textarea', await has('textarea'));
|
||||
check('有抄送', c.cc);
|
||||
check('有附件入口', c.attach);
|
||||
check('正文有 Markdown 预览', c.preview);
|
||||
|
||||
const addr = page.locator('input[spellcheck=false]').first();
|
||||
const typeAddr = async (v) => {
|
||||
await addr.click().catch(() => {});
|
||||
await addr.fill(v).catch(() => {});
|
||||
await page.waitForTimeout(1300);
|
||||
};
|
||||
// 补全下拉是 button 列表,取每项首行当候选
|
||||
const options = () =>
|
||||
page.evaluate(() =>
|
||||
[...document.querySelectorAll('div.absolute.z-20 button')]
|
||||
.map((b) => (b.innerText || '').trim().split('\n')[0]));
|
||||
|
||||
await typeAddr('p');
|
||||
const s1 = await options();
|
||||
check('name 段有候选', s1.includes('pi'), `${s1.length} 个:${s1.slice(0, 4).join(' ')}`);
|
||||
|
||||
await typeAddr('pi@');
|
||||
const s2 = await options();
|
||||
check('path 段有候选', s2.some((o) => o.startsWith('/')), s2.slice(0, 3).join(' '));
|
||||
|
||||
await typeAddr('pi@/home/program/agentmail.');
|
||||
const s3 = await options();
|
||||
check('session 段候选含保留字 new', s3.includes('new'), `${s3.length} 个候选`);
|
||||
|
||||
// `session_alias` 与 `max_rounds` 只在本次投递新建会话时生效 —— 界面必须据此
|
||||
// 显隐,否则人会以为续谈时也能改这两个约定
|
||||
await typeAddr('pi@/home/program/agentmail.new');
|
||||
await page.keyboard.press('Escape'); // 关掉补全,免得它盖住下面的字段
|
||||
await page.waitForTimeout(500);
|
||||
check('.new 时出现「会话别名」输入', await has('input[placeholder="refactor-auth"]'));
|
||||
check('.new 时出现「往返预算」输入', await has('input[inputmode="numeric"]'));
|
||||
|
||||
await typeAddr('pi@/home/program/agentmail.子任务-跑一条命令');
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(500);
|
||||
check('续谈已有会话时隐藏「会话别名」', !(await has('input[placeholder="refactor-auth"]')));
|
||||
check('续谈已有会话时隐藏「往返预算」', !(await has('input[inputmode="numeric"]')));
|
||||
|
||||
if (!(await clickText('取消', 900))) await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
await section('对话树 + 转发页(填完取消)', async () => {
|
||||
await nav('收件箱', 1300);
|
||||
|
||||
// 收件箱按会话分组:组头与邮件行的 className 都含 `w-full text-left`,
|
||||
// 靠「点完有没有出现含『发件』的 dl」区分
|
||||
let opened = false;
|
||||
for (let round = 0; round < 3 && !opened; round++) {
|
||||
const btns = await page.$$('button.w-full.text-left');
|
||||
for (const b of btns) {
|
||||
await b.click().catch(() => {});
|
||||
await page.waitForTimeout(340);
|
||||
opened = await page.evaluate(() =>
|
||||
[...document.querySelectorAll('dl')].some((dl) => dl.textContent.includes('发件')));
|
||||
if (opened) break;
|
||||
}
|
||||
}
|
||||
if (!opened) return bad('打开一封邮件', '没找到可点开的邮件行');
|
||||
ok('打开一封邮件');
|
||||
|
||||
if (await clickText('对话树', 1800)) {
|
||||
const tree = await page.evaluate(() => ({
|
||||
back: [...document.querySelectorAll('button')].some((b) => /返回|关闭|收起/.test(b.innerText || '')),
|
||||
// 每个节点按 depth 缩进:style={{ paddingLeft: indent }}
|
||||
nodes: document.querySelectorAll('[style*="padding-left"]').length,
|
||||
}));
|
||||
check('对话树有返回出口', tree.back);
|
||||
check('对话树按层级缩进', tree.nodes > 0, `${tree.nodes} 个节点`);
|
||||
for (const t of ['返回', '关闭', '收起']) if (await clickText(t, 1000)) break;
|
||||
} else skip('对话树', '按钮不存在');
|
||||
|
||||
if (await clickText('转发', 1300)) {
|
||||
const f = await page.evaluate(() => ({
|
||||
inputs: document.querySelectorAll('input').length,
|
||||
cc: /抄送/.test(document.body.innerText),
|
||||
hint: /转发|Fwd|原文|新收件人/.test(document.body.innerText),
|
||||
}));
|
||||
check('转发页有收件人输入', f.inputs > 0, `${f.inputs} 个输入框`);
|
||||
check('转发页有抄送', f.cc);
|
||||
check('转发页有转发语境提示', f.hint);
|
||||
if (!(await clickText('取消', 900))) await page.keyboard.press('Escape');
|
||||
} else skip('转发', '按钮不存在');
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
await section('管理页四个 tab(只看不动)', async () => {
|
||||
if (!(await nav('用户管理', 1700))) return bad('找不到管理入口', (await titles()).join(' / '));
|
||||
|
||||
const tabs = await page.evaluate(() =>
|
||||
[...document.querySelectorAll('button')]
|
||||
.map((b) => (b.innerText || '').replace(/\s+/g, ' ').trim())
|
||||
.filter((t) => /^(用户管理|Agent 密钥|Agent 管理|模型范围)/.test(t)));
|
||||
check('四个 tab 都在', tabs.length >= 4, tabs.join(' | '));
|
||||
|
||||
const u = await page.evaluate(() => ({
|
||||
admin: /jianf/.test(document.body.innerText),
|
||||
role: /管理员/.test(document.body.innerText),
|
||||
create: [...document.querySelectorAll('button')].some((b) => /新建用户/.test(b.innerText || '')),
|
||||
}));
|
||||
check('用户列表显示管理员', u.admin);
|
||||
check('显示角色徽标', u.role);
|
||||
check('有新建用户入口', u.create);
|
||||
|
||||
if (await clickText('Agent 密钥', 1500)) {
|
||||
const k = await page.evaluate(() => {
|
||||
const codes = [...document.querySelectorAll('code')].map((c) => (c.textContent || '').trim());
|
||||
return {
|
||||
heading: /Agent 接入密钥/.test(document.body.innerText),
|
||||
n: codes.length,
|
||||
max: Math.max(0, ...codes.map((c) => c.length)),
|
||||
sample: codes.slice(0, 3),
|
||||
};
|
||||
});
|
||||
check('标题是「Agent 接入密钥」', k.heading);
|
||||
check('列出了密钥', k.n > 0, `${k.n} 条`);
|
||||
// 密钥全文只在刚签发时展示一次,列表只该给 token_hint
|
||||
check('列表不泄露密钥全文', k.max <= 24, `最长 ${k.max} 字符:${k.sample.join(' ')}`);
|
||||
|
||||
// 三种生命周期在创建表单里,表单默认收起 —— 不展开是看不到的
|
||||
if (await clickText('新建密钥', 1100)) {
|
||||
const f = await page.evaluate(() => {
|
||||
const txt = document.body.innerText;
|
||||
return {
|
||||
types: ['长期', '一次性', '限时'].filter((t) => txt.includes(t)),
|
||||
hints: /永不过期/.test(txt) && /首次使用后立即失效/.test(txt),
|
||||
// Agent 面板独有的两项:绑定 Agent 名、登记插件本地生成的密钥
|
||||
agentOnly: document.querySelectorAll(
|
||||
'input[placeholder*="绑定到 Agent"], input[placeholder*="登记插件"]').length,
|
||||
};
|
||||
});
|
||||
check('三种生命周期都在', f.types.length === 3, f.types.join(','));
|
||||
check('每种都写了含义', f.hints);
|
||||
check('Agent 面板有绑定/登记两项', f.agentOnly === 2, `${f.agentOnly} 个`);
|
||||
if (!(await clickText('取消', 900))) await page.keyboard.press('Escape');
|
||||
ok('未创建任何密钥(点了取消)');
|
||||
} else skip('密钥生命周期', '找不到新建密钥按钮');
|
||||
} else skip('Agent 密钥 tab', '按钮不存在');
|
||||
|
||||
if (await clickText('Agent 管理', 1500)) {
|
||||
const a = await page.evaluate(() => {
|
||||
const txt = document.body.innerText;
|
||||
return {
|
||||
agents: ['pi', 'opencode', 'dsh', 'homeagent'].filter((n) => new RegExp(`\\b${n}\\b`).test(txt)),
|
||||
online: (txt.match(/在线/g) || []).length,
|
||||
budgetInputs: document.querySelectorAll('input[inputmode=numeric]').length,
|
||||
disable: document.querySelectorAll('button[title*="停用"], button[title*="恢复"]').length,
|
||||
del: document.querySelectorAll('button[title*="彻底删除"]').length,
|
||||
// 停用/恢复的后果必须写在界面上(密钥不会自动回来)。少了这句,
|
||||
// 实测代价是 opencode 拿旧密钥重试 18 小时、gateway 日志 2690 次 401
|
||||
warnsKeys: /密钥不会自动回来|重新签发/.test(txt),
|
||||
};
|
||||
});
|
||||
check('四个 Agent 都列出来了', a.agents.length === 4, a.agents.join(','));
|
||||
check('显示在线状态', a.online > 0, `${a.online} 处「在线」`);
|
||||
check('有默认预算输入框', a.budgetInputs >= 4, `${a.budgetInputs} 个`);
|
||||
check('有停用/恢复入口', a.disable > 0, `${a.disable} 个`);
|
||||
check('有删除入口', a.del > 0, `${a.del} 个`);
|
||||
check('说明了恢复后须重新签发密钥', a.warnsKeys);
|
||||
ok('未点击任何停用/删除(只读巡检)');
|
||||
} else skip('Agent 管理 tab', '按钮不存在');
|
||||
|
||||
if (await clickText('模型范围', 1500)) {
|
||||
const before = (await bodyText()).length;
|
||||
// 明确展开 opencode:本机只有它的目录足够大(24 个模型)。
|
||||
// 随便点第一行可能落在 homeagent —— 它一个模型都没上报,
|
||||
// 于是「清单长什么样」根本验不到。
|
||||
let expanded = false;
|
||||
for (const r of await page.$$('button')) {
|
||||
if ((await r.innerText().catch(() => '')).trim().startsWith('opencode')) {
|
||||
await r.click().catch(() => {});
|
||||
expanded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!expanded) return skip('模型清单', '找不到 opencode 那一行');
|
||||
await page.waitForTimeout(1900);
|
||||
|
||||
const m = await page.evaluate(() => {
|
||||
const txt = document.body.innerText;
|
||||
return {
|
||||
len: txt.length,
|
||||
catalogHeader: /平台上报的模型/.test(txt),
|
||||
// 「配可用模型必须是选择而非手打」:清单是一排可切换的等宽按钮,
|
||||
// 不是让人敲模型名的输入框
|
||||
toggles: [...document.querySelectorAll('button')]
|
||||
.filter((b) => /font-mono/.test(b.className || '')).length,
|
||||
freeText: [...document.querySelectorAll('input')]
|
||||
.filter((i) => /model|模型/i.test(i.placeholder || '')).length,
|
||||
order: /尝试顺序|按序尝试/.test(txt),
|
||||
unrestricted: /不限定/.test(txt),
|
||||
};
|
||||
});
|
||||
check('展开后内容变多', m.len > before, `${before} → ${m.len} 字符`);
|
||||
check('列出平台上报的模型清单', m.catalogHeader);
|
||||
check('模型是点选而非手打', m.toggles > 0 && m.freeText === 0,
|
||||
`${m.toggles} 个可切换项 / ${m.freeText} 个自由输入`);
|
||||
check('说明了顺序即优先级', m.order);
|
||||
check('说明了不选 = 不限定', m.unrestricted);
|
||||
} else skip('模型范围 tab', '按钮不存在');
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
await section('账号页:密钥面板 + 主题三态', async () => {
|
||||
// 账号入口是头像按钮,可见文字是用户名前两字
|
||||
if (!(await click('button[title*="点击管理账号"]', 1700))) {
|
||||
return bad('找不到账号入口', (await titles()).join(' / '));
|
||||
}
|
||||
|
||||
const a = await page.evaluate(() => {
|
||||
const txt = document.body.innerText;
|
||||
return {
|
||||
profile: /基本资料/.test(txt),
|
||||
pwd: /修改密码/.test(txt),
|
||||
clientKeys: /客户端连接密钥/.test(txt),
|
||||
cannotRegister: /不能用于注册 Agent/.test(txt),
|
||||
logout: /退出登录/.test(txt),
|
||||
themes: [...document.querySelectorAll('button[title]')]
|
||||
.map((b) => b.title)
|
||||
.filter((t) => /始终使用浅色|始终使用深色|随系统/.test(t)),
|
||||
};
|
||||
});
|
||||
check('显示基本资料', a.profile);
|
||||
check('有修改密码(未提交)', a.pwd);
|
||||
check('有客户端连接密钥面板', a.clientKeys);
|
||||
check('写明用户密钥不能注册 Agent', a.cannotRegister);
|
||||
check('主题是三态而非开关', a.themes.length === 3, a.themes.join(' / '));
|
||||
check('有退出登录(未点击)', a.logout);
|
||||
|
||||
// 同一个 KeyPanel 的 user variant:不该出现 Agent 专属的两项
|
||||
if (await clickText('新建密钥', 1100)) {
|
||||
const n = await page.evaluate(() => document.querySelectorAll(
|
||||
'input[placeholder*="绑定到 Agent"], input[placeholder*="登记插件"]').length);
|
||||
check('用户面板没有绑定/登记项', n === 0, `${n} 个`);
|
||||
if (!(await clickText('取消', 900))) await page.keyboard.press('Escape');
|
||||
} else skip('用户密钥面板 variant 差异', '找不到新建密钥按钮');
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
await section('联系人页:列表 / 卡片双视图 + 归档确认框', async () => {
|
||||
// 中间栏的列表/卡片切换属于 ContactPanel,而它只在「联系人」视图挂载 ——
|
||||
// 在收件箱里那一栏是 MailList,找不到这个按钮
|
||||
if (!(await nav('联系人', 1600))) return bad('找不到联系人入口', (await titles()).join(' / '));
|
||||
|
||||
const c = await page.evaluate(() => ({
|
||||
heading: /联系人/.test(document.body.innerText),
|
||||
archiveBtns: document.querySelectorAll('button[title^="归档该"]').length,
|
||||
paths: (document.body.innerText.match(/\/home\/|\/tmp\/|\/root/g) || []).length,
|
||||
viewArchived: [...document.querySelectorAll('button')].some((b) => (b.innerText || '').trim() === '归档'),
|
||||
}));
|
||||
check('中间栏标题是「联系人」', c.heading);
|
||||
check('列出联系人行', c.archiveBtns > 0, `${c.archiveBtns} 行`);
|
||||
check('行内显示工作区路径', c.paths > 0, `${c.paths} 处路径`);
|
||||
check('头部有「查看已归档」开关', c.viewArchived);
|
||||
|
||||
if (!(await click('button[title="切换到卡片视图"]', 1700))) {
|
||||
skip('卡片视图', '没找到切到卡片的按钮');
|
||||
} else {
|
||||
const card = await page.evaluate(() => ({
|
||||
heading: /工作列表/.test(document.body.innerText),
|
||||
// 卡片带预算徽标:title 形如「往返预算:已用 3/20」
|
||||
budgetChip: document.querySelectorAll('[title^="往返预算"]').length,
|
||||
pref: localStorage.getItem('agentmail.contactView'),
|
||||
}));
|
||||
check('切到卡片后标题变「工作列表」', card.heading);
|
||||
check('卡片有往返预算徽标', card.budgetChip > 0, `${card.budgetChip} 个`);
|
||||
check('视图偏好落 localStorage', card.pref === 'card', `agentmail.contactView=${card.pref}`);
|
||||
|
||||
check('能切回列表视图', await click('button[title="切换到列表视图"]', 1400));
|
||||
const back = await page.evaluate(() => ({
|
||||
heading: /联系人/.test(document.body.innerText),
|
||||
pref: localStorage.getItem('agentmail.contactView'),
|
||||
}));
|
||||
check('切回后标题恢复「联系人」', back.heading);
|
||||
check('偏好跟着回到 list', back.pref === 'list', `agentmail.contactView=${back.pref}`);
|
||||
}
|
||||
|
||||
if (c.archiveBtns === 0) return skip('归档确认框', '没有归档按钮');
|
||||
// requestArchive 只写前端状态(为渲染确认框),不发请求 —— 点它是安全的
|
||||
await click('button[title^="归档该"]', 1000);
|
||||
const confirm = await page.evaluate(() => {
|
||||
const txt = document.body.innerText;
|
||||
return {
|
||||
// 确认框是唯一渲染 contact.address 全文的地方,顺带验三段地址成形
|
||||
addr: (txt.match(/[a-z][\w.-]*@\/[^\s??]+/) || [null])[0],
|
||||
explains: /session 将被归档/.test(txt),
|
||||
confirmBtn: [...document.querySelectorAll('button')].some((b) => /确认归档/.test(b.innerText || '')),
|
||||
cancelBtn: [...document.querySelectorAll('button')].some((b) => (b.innerText || '').trim() === '取消'),
|
||||
};
|
||||
});
|
||||
check('确认框显示完整三段地址', !!confirm.addr && confirm.addr.includes('.'), confirm.addr || '(没找到)');
|
||||
check('确认框说明了后果', confirm.explains);
|
||||
check('确认框有确认按钮(未点击)', confirm.confirmBtn);
|
||||
check('确认框可取消', confirm.cancelBtn);
|
||||
if (confirm.cancelBtn) {
|
||||
await clickText('取消', 900);
|
||||
const gone = await page.evaluate(() =>
|
||||
![...document.querySelectorAll('button')].some((b) => /确认归档/.test(b.innerText || '')));
|
||||
check('取消后确认框消失,未归档任何会话', gone);
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
console.log('\n─── JS 运行时错误 ───');
|
||||
if (issues.length === 0) ok('无 pageerror / console.error');
|
||||
else {
|
||||
bad(`${issues.length} 条运行时问题`);
|
||||
issues.slice(0, 8).forEach((i) => console.log(' ', i));
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
console.log(`\n═══ ${pass} 通过 / ${fails.length} 失败 / ${skips.length} 跳过 ═══`);
|
||||
if (fails.length) { console.log('失败项:'); fails.forEach((f) => console.log(' -', f)); }
|
||||
process.exit(fails.length ? 1 : 0);
|
||||
71
client/electron/test/manual/wide-regression.mjs
Normal file
71
client/electron/test/manual/wide-regression.mjs
Normal file
@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 宽屏回归:窄屏修复不能把桌面布局改坏。
|
||||
*
|
||||
* 特别是两个只该在窄屏生效的东西:
|
||||
* - `.tap` 的伪元素命中区(桌面密排工具栏里会互相重叠)
|
||||
* - `BackButton`(宽屏列表与详情并排,返回没有意义)
|
||||
*
|
||||
* 用法:ADMIN_PW=<密码> node client/electron/test/manual/wide-regression.mjs
|
||||
*/
|
||||
import { openApp, WIDE } from './narrow-probe-helper.mjs';
|
||||
|
||||
const { browser, page, issues } = await openApp(WIDE);
|
||||
const failed = [];
|
||||
const chk = (n, ok, note = '') => {
|
||||
console.log(` ${ok ? '通过' : '失败'} ${n}${note ? ' — ' + note : ''}`);
|
||||
if (!ok) failed.push(n);
|
||||
};
|
||||
|
||||
console.log('宽屏回归(1280px):');
|
||||
|
||||
const cols = await page.evaluate(() => {
|
||||
const root = document.querySelector('#root > div');
|
||||
return { n: root?.children.length, first: root?.children[0]?.className?.toString().slice(0, 30) };
|
||||
});
|
||||
chk('仍是三栏并排', cols.n === 3, `栏数=${cols.n} 首栏=${cols.first}`);
|
||||
|
||||
// 常驻侧栏是宽屏唯一的退出入口(账号页也有,两处都要在)
|
||||
const side = await page.evaluate(() => {
|
||||
const s = document.querySelector('#root > div > div');
|
||||
const btns = s
|
||||
? [...s.querySelectorAll('button')].map(b =>
|
||||
(b.getAttribute('title') || b.textContent || '').trim().slice(0, 12)
|
||||
)
|
||||
: [];
|
||||
return { w: s ? Math.round(s.getBoundingClientRect().width) : 0, btns };
|
||||
});
|
||||
chk('常驻侧栏仍有退出登录', side.btns.some(b => b.includes('退出')), `宽=${side.w}`);
|
||||
|
||||
// 宽屏不该出现返回按钮
|
||||
// 邮件行是 <button class="w-full text-left ...">
|
||||
const rows = page.locator('button.w-full.text-left');
|
||||
if ((await rows.count()) > 0) await rows.first().click();
|
||||
await page.waitForTimeout(1000);
|
||||
const backN = await page.locator('button[aria-label="返回"], button[aria-label="会话"]').count();
|
||||
chk('没有返回按钮', backN === 0, `${backN} 个`);
|
||||
|
||||
// .tap 只在 max-width:767px 生效
|
||||
const tapWide = await page.evaluate(() => {
|
||||
const b = [...document.querySelectorAll('.tap')].find(x => x.getBoundingClientRect().height > 0);
|
||||
if (!b) return null;
|
||||
const cs = getComputedStyle(b, '::after');
|
||||
return { content: cs.content, w: cs.width, h: cs.height };
|
||||
});
|
||||
chk(
|
||||
'.tap 伪元素在宽屏不生效',
|
||||
!tapWide || tapWide.content === 'none' || tapWide.w === 'auto',
|
||||
JSON.stringify(tapWide)
|
||||
);
|
||||
|
||||
const of = await page.evaluate(() => ({
|
||||
d: document.documentElement.clientWidth,
|
||||
s: document.documentElement.scrollWidth
|
||||
}));
|
||||
chk('无横向溢出', of.s <= of.d, `doc=${of.d} scroll=${of.s}`);
|
||||
|
||||
console.log('\nissues:', issues.length ? issues : '无');
|
||||
console.log(failed.length === 0 ? '\n宽屏回归:全部通过' : `\n宽屏回归:${failed.length} 项失败`);
|
||||
|
||||
await page.close();
|
||||
await browser.close();
|
||||
process.exit(failed.length === 0 ? 0 : 1);
|
||||
43
client/electron/test/markdown-xss.test.mjs
Normal file
43
client/electron/test/markdown-xss.test.mjs
Normal file
@ -0,0 +1,43 @@
|
||||
// 回归测试:确认邮件正文的 Markdown 渲染不会执行注入的脚本。
|
||||
// react-markdown 默认不解析 raw HTML(无 rehype-raw),且用 defaultUrlTransform
|
||||
// 清空非 http(s)/mailto 协议的 URL —— 本测试守住这两个前提,防止日后有人
|
||||
// 为了「支持 HTML 邮件」顺手加上 rehype-raw 而不自觉地开了 XSS 口子。
|
||||
//
|
||||
// 运行:node test/markdown-xss.test.mjs
|
||||
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import React from 'react';
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
// 只有【真实标签】里的危险内容才算漏。
|
||||
// 注意不能直接搜 onerror=:raw HTML 被转义成 <img … onerror=" 后,
|
||||
// 文本里仍含该字样但已无执行能力,按标签边界匹配才不会误报。
|
||||
const dangerous = /<(script|iframe|object|embed)\b|<[a-z][^>]*\son[a-z]+\s*=|<[a-z][^>]*(href|src)\s*=\s*"javascript:/i;
|
||||
|
||||
const payloads = [
|
||||
'<script>alert(1)</script>',
|
||||
'<img src=x onerror="alert(1)">',
|
||||
'[click](javascript:alert(1))',
|
||||
'<a href="javascript:alert(1)">x</a>',
|
||||
'<iframe src="https://evil.com"></iframe>',
|
||||
')',
|
||||
'<div onmouseover="alert(1)">hover</div>',
|
||||
'[ok](https://example.com)',
|
||||
'**bold** `code`',
|
||||
];
|
||||
|
||||
let leaks = 0;
|
||||
for (const p of payloads) {
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(Markdown, { remarkPlugins: [remarkGfm] }, p)
|
||||
);
|
||||
const bad = dangerous.test(html);
|
||||
if (bad) leaks++;
|
||||
console.log((bad ? 'LEAK ' : 'safe '), JSON.stringify(p), '->', html.slice(0, 80));
|
||||
}
|
||||
if (leaks > 0) {
|
||||
console.error(`\n失败:${leaks} 处 XSS 泄漏`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\n通过:raw HTML 被转义,javascript: URL 被清空');
|
||||
185
client/electron/test/narrow-layout.test.mjs
Normal file
185
client/electron/test/narrow-layout.test.mjs
Normal file
@ -0,0 +1,185 @@
|
||||
// 窄屏布局的结构性回归测试。
|
||||
//
|
||||
// 不做视觉快照:那需要 headless 浏览器,且像素级比对在字体差异下极脆。
|
||||
// 这里守住几条真正会坏掉的不变量。
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const read = p => readFileSync(new URL(p, import.meta.url), 'utf8');
|
||||
let failed = 0;
|
||||
const check = (name, cond, detail = '') => {
|
||||
if (cond) {
|
||||
console.log(` 通过 ${name}`);
|
||||
} else {
|
||||
console.error(` 失败 ${name}${detail ? ' — ' + detail : ''}`);
|
||||
failed++;
|
||||
}
|
||||
};
|
||||
|
||||
console.log('窄屏布局回归:');
|
||||
|
||||
// 1) 覆盖式而非分栏:NarrowStack 必须同时挂载 base 与 overlay
|
||||
const stack = read('../src/components/NarrowStack.tsx');
|
||||
check(
|
||||
'覆盖层与底层同时在 DOM 里(底层不卸载,滚动位置与选中态才能保留)',
|
||||
stack.includes('{base}') && stack.includes('{overlay}') && stack.includes('absolute inset-0')
|
||||
);
|
||||
check(
|
||||
'关闭时延迟卸载,退出动画才有东西可播',
|
||||
/setTimeout\(/.test(stack) && stack.includes('setMounted(false)')
|
||||
);
|
||||
check(
|
||||
'入场用双层 rAF,避免与挂载合帧导致 transition 不触发',
|
||||
(stack.match(/requestAnimationFrame/g) || []).length >= 2
|
||||
);
|
||||
check(
|
||||
'尊重 prefers-reduced-motion',
|
||||
stack.includes('motion-reduce:transition-none')
|
||||
);
|
||||
|
||||
// 2) 手机与竖屏平板统一用单栏;三栏只在 Tailwind lg(1024px)启用。
|
||||
const narrowHook = read('../src/hooks/useIsNarrow.ts');
|
||||
check('JS 单栏断点与 lg 一致', narrowHook.includes('(max-width: 1023px)'));
|
||||
for (const f of ['MailList', 'PermissionList', 'ContactPanel', 'CalendarView']) {
|
||||
const src = read(`../src/components/${f}.tsx`);
|
||||
const narrowFullWidth = src.includes('w-full');
|
||||
const bareFixed = (src.match(/(?<![-\w])w-\[(\d+)px\]/g) || []).filter(m => {
|
||||
const px = Number(m.match(/\d+/)?.[0] || 0);
|
||||
return px >= 200 && !src.includes('lg:' + m);
|
||||
});
|
||||
check(
|
||||
`${f} 平板单栏全宽,固定宽度仅在 lg 之后`,
|
||||
narrowFullWidth && bareFixed.length === 0 && !/md:w-\[/.test(src),
|
||||
bareFixed.length ? `裸固定宽度:${bareFixed.join(', ')}` : '存在 md 固定栏或缺少 w-full'
|
||||
);
|
||||
}
|
||||
|
||||
// 3) 详情页必须有返回出口,否则窄屏进去就出不来
|
||||
const view = read('../src/components/MailView.tsx');
|
||||
check('邮件详情有返回按钮', view.includes('<BackButton'));
|
||||
const compose = read('../src/components/ComposePage.tsx');
|
||||
check('写信页有返回出口', compose.includes('cancelCompose') && compose.includes('NarrowOnly'));
|
||||
|
||||
// 4) 窄屏专属控件不能只靠 CSS 隐藏 —— 那样宽屏 Tab 会聚焦到看不见的按钮。
|
||||
// 注释里提到 md:hidden 是在解释「为什么不用它」,所以先剥掉注释再查。
|
||||
const stripComments = src =>
|
||||
src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||
for (const f of ['BackButton', 'NarrowOnly']) {
|
||||
const src = read(`../src/components/${f}.tsx`);
|
||||
const code = stripComments(src);
|
||||
check(
|
||||
`${f} 用 useIsNarrow 条件渲染而非 md:hidden`,
|
||||
src.includes('useIsNarrow') && /\bnull\b/.test(code) && !code.includes('md:hidden')
|
||||
);
|
||||
}
|
||||
|
||||
// 5) 底部导航要避开 iPhone 手势条
|
||||
const nav = read('../src/components/NarrowNav.tsx');
|
||||
check('底部导航留了安全区内边距', nav.includes('safe-area-inset-bottom'));
|
||||
|
||||
// 5.1) 抽屉式侧栏已删。
|
||||
// 它装的六项与底部导航完全重复,唯一独有的是退出登录;代价是 z-50 的
|
||||
// fixed 层铺满视口高度,把底部导航最左那一项盖住点不到
|
||||
// (实测 elementFromPoint 命中抽屉里的 SVG)。
|
||||
const app = read('../src/App.tsx');
|
||||
check(
|
||||
'窄屏没有抽屉式侧栏(它曾遮挡底部导航)',
|
||||
!app.includes('navOpen') && !app.includes('bg-black/40')
|
||||
);
|
||||
const ui = read('../src/stores/uiStore.ts');
|
||||
check('uiStore 不再有抽屉状态', !ui.includes('navOpen') && !ui.includes('toggleNav'));
|
||||
|
||||
// 5.2) 退出登录必须还有地方可点 —— 删抽屉时它是唯一的独有入口
|
||||
const account = read('../src/components/AccountPage.tsx');
|
||||
check(
|
||||
'退出登录已移到账号页(窄屏唯一出口)',
|
||||
account.includes('logout') && account.includes('退出登录')
|
||||
);
|
||||
|
||||
// 5.3) 触摸命中区:44x44 是移动端下限,而这些按钮视觉高度只有 15-24px。
|
||||
// .tap 用居中的透明伪元素扩大命中区,视觉尺寸不变。
|
||||
const css = read('../src/index.css');
|
||||
check(
|
||||
'.tap 提供 44px 触摸命中区且覆盖手机与竖屏平板',
|
||||
/\.tap::after/.test(css) && css.includes('min-width: 44px') &&
|
||||
css.includes('min-height: 44px') && /max-width:\s*1023px/.test(css)
|
||||
);
|
||||
// 详情页那排工具按钮是实测最小的一组(「抄送」只有 20x15)
|
||||
const viewSrc = read('../src/components/MailView.tsx');
|
||||
for (const label of ['标记已读', '对话树', '转发']) {
|
||||
const re = new RegExp('className="tap[^"]*"[^>]*>[\\s\\S]{0,120}' + label);
|
||||
check(`详情页「${label}」有 .tap 命中区`, re.test(viewSrc));
|
||||
}
|
||||
|
||||
// 5.4) 悬停才显形的次要动作在触摸设备上必须默认可见。
|
||||
// `opacity-0 group-hover:opacity-100` 在没有 hover 的设备上永远透明,
|
||||
// 却仍然接收点击 —— 一个看不见却按得动的「归档」比没有按钮更糟。
|
||||
check(
|
||||
'.reveal 只在支持悬停的设备上隐藏',
|
||||
css.includes('.reveal') && /@media\s*\(hover:\s*hover\)\s*and\s*\(pointer:\s*fine\)/.test(css)
|
||||
);
|
||||
for (const f of ['ContactPanel', 'WorkCard']) {
|
||||
const src = read(`../src/components/${f}.tsx`);
|
||||
check(
|
||||
`${f} 用 .reveal 而非裸 opacity-0 group-hover`,
|
||||
src.includes('reveal') && !src.includes('opacity-0 group-hover:opacity-100')
|
||||
);
|
||||
}
|
||||
|
||||
// 5.5) 对话树:缩进随屏宽变,且窄屏要有返回出口。
|
||||
// 固定「每级 20px、上限 8 级」在 320px 屏上把卡片压到 110px 可用宽度。
|
||||
const thread = read('../src/components/ThreadView.tsx');
|
||||
check('对话树缩进随屏宽自适应', thread.includes('useIsNarrow') && /narrow \? 10 : 20/.test(thread));
|
||||
check('对话树窄屏有返回出口', thread.includes('<BackButton'));
|
||||
|
||||
// 5.6) 每个页面级组件都要有纵向滚动容器。
|
||||
// 窄屏外壳是 `h-full flex flex-col overflow-hidden`,页面本身是
|
||||
// `flex-1 min-w-0 flex flex-col` —— 内容超过视口时**没有任何办法滚到**,
|
||||
// 超出那段直接被裁。AccountPage 曾经就缺这个:390px 下内容需 860px、
|
||||
// 容器 795px,「退出登录」按钮连同下面 65px 一起消失。
|
||||
// 判据是「存在 overflow-y-auto」,不是「当前正在滚动」——
|
||||
// 内容暂时不够高时后者为假,但页面是健康的。
|
||||
for (const f of ['AccountPage', 'AdminUsersPage', 'MailView', 'ComposePage', 'ThreadView', 'ContactPanel', 'MailList', 'PermissionList', 'CalendarView', 'CalendarEventEditor']) {
|
||||
const src = read(`../src/components/${f}.tsx`);
|
||||
check(`${f} 有纵向滚动容器`, src.includes('overflow-y-auto'));
|
||||
}
|
||||
|
||||
// 5.7) 居中的单卡片页(登录 / 初始化)在矮屏必须能滚到底。
|
||||
// `items-center` 在内容超高时让卡片上下同时溢出,而溢出到顶部那段
|
||||
// 滚不到(scrollTop 最小是 0)—— 实测 568x280 下「登录」按钮完全在
|
||||
// 视口外。改用卡片自己的 my-auto:空间不足时 auto margin 退化为 0。
|
||||
for (const f of ['LoginPage', 'SetupPage']) {
|
||||
const src = read(`../src/components/${f}.tsx`);
|
||||
check(
|
||||
`${f} 矮屏可滚且不用 items-center 居中`,
|
||||
src.includes('overflow-y-auto') && src.includes('my-auto') &&
|
||||
!/h-full[^"]*items-center/.test(src)
|
||||
);
|
||||
}
|
||||
|
||||
// 5.8) 写信页不能靠 flex 把正文压成一行。
|
||||
// 软键盘出现时可见高度骤减:顶部字段和底部附件/按钮都是固定内容,原先唯一
|
||||
// 可收缩的正文区只有 min-h-0,于是会被压到接近 0。窄屏改为整页可滚,正文
|
||||
// 保留明确的最小高度;宽屏仍使用 flex 填满剩余空间。
|
||||
check(
|
||||
'写信页窄屏整页可滚,正文有明确最小高度',
|
||||
compose.includes('overflow-y-auto lg:overflow-hidden') &&
|
||||
compose.includes('min-h-[12rem]') &&
|
||||
compose.includes('lg:flex-1')
|
||||
);
|
||||
check(
|
||||
'写信页附件与操作栏不参与正文压缩',
|
||||
compose.includes('shrink-0 px-4 md:px-6 pb-20 lg:pb-3') &&
|
||||
compose.includes('sticky bottom-0')
|
||||
);
|
||||
check('根视口使用 100dvh 跟随软键盘', css.includes('@supports (height: 100dvh)'));
|
||||
|
||||
// 6) 横向内边距在窄屏收窄(px-6 在 375px 屏上白吃 48px)
|
||||
const wide = ['MailView', 'ComposePage', 'ThreadView', 'AccountPage', 'AdminUsersPage'];
|
||||
for (const f of wide) {
|
||||
const src = read(`../src/components/${f}.tsx`);
|
||||
const bare = src.match(/className="[^"]*(?<![-:])\bpx-6\b/g) || [];
|
||||
check(`${f} 没有裸 px-6(应为 px-4 md:px-6)`, bare.length === 0, `发现 ${bare.length} 处`);
|
||||
}
|
||||
|
||||
console.log(failed === 0 ? '\n窄屏布局:全部通过' : `\n窄屏布局:${failed} 项失败`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
395
client/electron/test/theme.test.mjs
Normal file
395
client/electron/test/theme.test.mjs
Normal file
@ -0,0 +1,395 @@
|
||||
/**
|
||||
* 深色主题的结构性检查。
|
||||
*
|
||||
* 判据全部是「源码里存在/不存在某种形态」,不需要浏览器 ——
|
||||
* 真正的视觉验收靠手工脚本(test/manual/theme-verify.mjs)。
|
||||
*
|
||||
* 这些检查存在的理由:深色模式的 bug 形态是**白底白字**,
|
||||
* 它不报错、不影响构建、只有肉眼能发现,而且往往只出现在某个不常开的页面。
|
||||
*/
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const read = p => readFileSync(join(here, p), 'utf8');
|
||||
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
const check = (name, ok, detail = '') => {
|
||||
if (ok) {
|
||||
pass++;
|
||||
console.log(` 通过 ${name}`);
|
||||
} else {
|
||||
fail++;
|
||||
console.log(` 失败 ${name}${detail ? ' — ' + detail : ''}`);
|
||||
}
|
||||
};
|
||||
|
||||
const css = read('../src/index.css');
|
||||
const cfg = read('../tailwind.config.js');
|
||||
const html = read('../index.html');
|
||||
|
||||
// 1) tailwind 必须走 class 策略。
|
||||
// media 策略下主题无法被人显式选择 —— 白天想开深色就做不到。
|
||||
check('darkMode 为 class 策略', /darkMode:\s*['"]class['"]/.test(cfg));
|
||||
|
||||
// 2) 颜色必须经 CSS 变量。写死十六进制的话深色模式无从切换。
|
||||
check(
|
||||
'调色板指向 CSS 变量',
|
||||
cfg.includes('rgb(var(') && cfg.includes('<alpha-value>'),
|
||||
'缺少 rgb(var(--x) / <alpha-value>) 形态'
|
||||
);
|
||||
|
||||
// 3) 变量值必须是 RGB 三元组而不是 #hex。
|
||||
// 代码里有 bg-blue-50/70 这类透明度修饰符,#hex 会生成无效 CSS,
|
||||
// 那些半透明高亮静默失效(不报错,只是不透明)。
|
||||
const varLines = css.match(/--c-[a-z]+-?\d*:\s*[^;]+;/g) || [];
|
||||
const hexVars = varLines.filter(l => l.includes('#'));
|
||||
check(
|
||||
'色板变量存 RGB 三元组而非 #hex',
|
||||
varLines.length >= 100 && hexVars.length === 0,
|
||||
hexVars.length ? `${hexVars.length} 个变量是 hex:${hexVars[0]}` : `只找到 ${varLines.length} 个变量`
|
||||
);
|
||||
|
||||
// 4) 必须有 .dark 覆盖块,且覆盖了同样多的变量。
|
||||
// 漏掉的那些会在深色下保持浅色值 —— 那正是白底白字的来源。
|
||||
const lightBlock = css.slice(css.indexOf(':root'), css.indexOf('.dark'));
|
||||
const darkBlock = css.slice(css.indexOf('.dark {'));
|
||||
const lightVars = new Set((lightBlock.match(/--c-[\w-]+(?=:)/g) || []));
|
||||
const darkVars = new Set((darkBlock.match(/--c-[\w-]+(?=:)/g) || []));
|
||||
const missing = [...lightVars].filter(v => !darkVars.has(v));
|
||||
check(
|
||||
'.dark 覆盖了全部色板变量',
|
||||
lightVars.size >= 15 && missing.length === 0,
|
||||
missing.length ? `深色缺 ${missing.length} 个:${missing.slice(0, 5).join(', ')}` : `浅色只有 ${lightVars.size} 个`
|
||||
);
|
||||
|
||||
// 5) 灰阶必须真的反转:深色的 white 要比 gray-900 暗。
|
||||
// 不反转的话组件里的 `bg-white text-gray-900` 在深色下依然是白底黑字。
|
||||
const lum = (block, name) => {
|
||||
const m = block.match(new RegExp(`--c-${name}:\\s*(\\d+)\\s+(\\d+)\\s+(\\d+)`));
|
||||
if (!m) return null;
|
||||
return (Number(m[1]) + Number(m[2]) + Number(m[3])) / 3;
|
||||
};
|
||||
const darkWhite = lum(darkBlock, 'white');
|
||||
const darkG900 = lum(darkBlock, 'gray-900');
|
||||
check(
|
||||
'深色下灰阶已反转(white 比 gray-900 暗)',
|
||||
darkWhite !== null && darkG900 !== null && darkWhite < darkG900,
|
||||
`white=${darkWhite} gray-900=${darkG900}`
|
||||
);
|
||||
|
||||
// 6) 深色的页面底(gray-50)必须比卡片(white)更暗。
|
||||
// 浅色下页面底比卡片浅,深色下要反过来 —— 否则卡片陷进背景失去边界。
|
||||
const darkG50 = lum(darkBlock, 'gray-50');
|
||||
check(
|
||||
'深色下页面底比卡片更暗',
|
||||
darkG50 !== null && darkWhite !== null && darkG50 < darkWhite,
|
||||
`gray-50=${darkG50} white=${darkWhite}`
|
||||
);
|
||||
|
||||
// 7) 次要文字(gray-400/500)在深色下必须提亮。
|
||||
// 照搬浅色值只有约 2:1 对比度,远低于 WCAG AA 的 4.5:1 ——
|
||||
// 实际效果是「看得见但读不动」。
|
||||
const lightG400 = lum(lightBlock, 'gray-400');
|
||||
const darkG400 = lum(darkBlock, 'gray-400');
|
||||
check(
|
||||
'深色下次要文字未沿用浅色值',
|
||||
darkG400 !== null && lightG400 !== null && Math.abs(darkG400 - lightG400) > 5,
|
||||
`light=${lightG400} dark=${darkG400}`
|
||||
);
|
||||
|
||||
// 8) color-scheme 两处都要设。
|
||||
// 不设的话深色页面上会出现浅色滚动条与白底的 autofill 输入框。
|
||||
check(
|
||||
':root 与 .dark 都声明 color-scheme',
|
||||
/color-scheme:\s*light/.test(lightBlock) && /color-scheme:\s*dark/.test(darkBlock)
|
||||
);
|
||||
|
||||
// 9) index.html 必须有同步内联脚本消除首帧闪屏。
|
||||
// bundle 有几百 KB,从 HTML 解析完到 React 挂载之间页面是 body 默认色 ——
|
||||
// 深色用户每次刷新都被闪一下白屏。外链或 defer 都晚于首次绘制。
|
||||
check(
|
||||
'index.html 内联防闪屏脚本',
|
||||
html.includes('agentmail.theme') &&
|
||||
html.includes('prefers-color-scheme') &&
|
||||
html.includes("classList.add('dark')") &&
|
||||
!/<script[^>]+(src=|defer)[^>]*>[\s\S]*?agentmail\.theme/.test(html),
|
||||
'缺少同步内联脚本'
|
||||
);
|
||||
|
||||
// 10) 内联脚本与 themeStore 必须用同一个 localStorage 键。
|
||||
// 不一致的后果是首帧按 A 键渲染、React 挂载后按 B 键重渲染 —— 闪一下再变回去
|
||||
const store = read('../src/stores/themeStore.ts');
|
||||
const keyInStore = store.match(/STORAGE_KEY\s*=\s*'([^']+)'/);
|
||||
check(
|
||||
'内联脚本与 themeStore 共用同一 storage 键',
|
||||
keyInStore !== null && html.includes(`'${keyInStore[1]}'`),
|
||||
keyInStore ? `store 用 ${keyInStore[1]}` : '未找到 STORAGE_KEY'
|
||||
);
|
||||
|
||||
// 11) body 必须有显式底色:移动端橡皮筋回弹露出的是 body 背景,
|
||||
// 不设的话深色下滑到边界会闪出白边。
|
||||
check(
|
||||
'body 有显式主题底色',
|
||||
/body\s*\{[^}]*background-color:\s*rgb\(var\(--c-/.test(css)
|
||||
);
|
||||
|
||||
// 12) 组件里不该残留写死的十六进制颜色。
|
||||
// 它们不经变量,深色模式下不会变 —— 而这类遗漏只有肉眼能发现。
|
||||
const compDir = join(here, '../src/components');
|
||||
const offenders = [];
|
||||
for (const f of readdirSync(compDir).filter(x => x.endsWith('.tsx'))) {
|
||||
const src = readFileSync(join(compDir, f), 'utf8');
|
||||
// 只看 className 与 style 里的颜色;SVG 的 currentColor 不算
|
||||
const hits = (src.match(/#[0-9a-fA-F]{3,6}\b/g) || []);
|
||||
if (hits.length) offenders.push(`${f}(${hits.join(',')})`);
|
||||
}
|
||||
check(
|
||||
'组件里没有写死的十六进制颜色',
|
||||
offenders.length === 0,
|
||||
offenders.join(' ')
|
||||
);
|
||||
|
||||
// 13) 主题三态:system 不能被当成 light 的别名。
|
||||
// 只给开关的话,白天设浅色之后晚上系统切深色应用不会跟着变
|
||||
check(
|
||||
'ThemePref 是三态且含 system',
|
||||
/'light'\s*\|\s*'dark'\s*\|\s*'system'/.test(store) && store.includes("=== 'system'")
|
||||
);
|
||||
|
||||
// 14) 只有 pref 为 system 时才跟随系统变化。
|
||||
// 显式选了 light/dark 的人不该因为日落被切换主题
|
||||
check(
|
||||
'仅 system 偏好跟随系统变化',
|
||||
/if\s*\(pref === 'system'\)/.test(store)
|
||||
);
|
||||
|
||||
// 15) text-white 必须与 bg-white 用不同的变量。
|
||||
// `white` 服务两种冲突用途:卡片表面(深色下变暗)与彩色按钮上的文字
|
||||
// (深色下必须保持浅色)。共用一个变量时后者跟着变暗 —— 实测激活导航项的
|
||||
// 「收件」在 bg-chrome-700 上只剩 1.34:1,几乎看不见。
|
||||
check(
|
||||
'textColor.white 指向独立变量(不跟 bg-white 一起反转)',
|
||||
/textColor:\s*\{[^}]*white:\s*withAlpha\('--c-on-accent'\)/.test(cfg) &&
|
||||
/--c-on-accent:/.test(lightBlock) &&
|
||||
/--c-on-accent:/.test(darkBlock)
|
||||
);
|
||||
|
||||
// 16) --c-on-accent 在深色下必须仍然是浅色。
|
||||
// 它变暗就是上一条描述的那个 bug。
|
||||
const onAccentDark = lum(darkBlock, 'on-accent');
|
||||
check(
|
||||
'深色下 on-accent 仍是浅色',
|
||||
onAccentDark !== null && onAccentDark > 200,
|
||||
`on-accent 亮度 ${onAccentDark}`
|
||||
);
|
||||
|
||||
// 17) 每个被 Tailwind 实际使用的 CSS 变量都必须在 index.css 有定义。
|
||||
//
|
||||
// 这一条守的是本项目最贵的一次视觉 bug:tailwind.config.js 里 `colors`
|
||||
// 同时写了固定 hex 与 accent() 两份 red/green/amber/orange/yellow ——
|
||||
// JS 对象字面量重复键**后者胜出**(不报错、不警告),而 index.css 里当时
|
||||
// 没有对应的变量。`rgb(var(--c-red-600) / 1)` 里变量未定义会让整条
|
||||
// background-color 声明失效,于是 bg-red-600 退回透明、text-white 的白字
|
||||
// 落在白卡片上:**按钮看不见但点得动**。32 个变量全部缺失,
|
||||
// 波及所有 red/green/amber/orange/yellow 的地方。
|
||||
//
|
||||
// 判据必须走 resolveConfig 而不是正则扫配置文本:真正出问题的那批变量名
|
||||
// 是 accent('red') 这类**模板拼出来的**,配置源码里根本没有 `--c-red-600`
|
||||
// 这个字面量 —— 扫文本会漏掉正是要防的那一类。
|
||||
const resolveConfig = (await import('tailwindcss/resolveConfig.js')).default;
|
||||
const resolved = resolveConfig((await import('../tailwind.config.js')).default);
|
||||
|
||||
const declared = new Set([...css.matchAll(/--[cs]-[a-z0-9-]+(?=\s*:)/g)].map(m => m[0]));
|
||||
const usedVars = new Set();
|
||||
const walk = (v) => {
|
||||
if (typeof v === 'string') {
|
||||
for (const m of v.matchAll(/var\((--[a-z0-9-]+)/g)) usedVars.add(m[1]);
|
||||
} else if (v && typeof v === 'object') {
|
||||
for (const k of Object.keys(v)) walk(v[k]);
|
||||
}
|
||||
};
|
||||
// 只看颜色相关的 theme 段:其余(spacing/fontSize/...)不走变量
|
||||
for (const key of ['colors', 'textColor', 'backgroundColor', 'borderColor', 'ringColor', 'divideColor']) {
|
||||
walk(resolved.theme[key]);
|
||||
}
|
||||
const undef = [...usedVars].filter(v => !declared.has(v));
|
||||
check(
|
||||
'Tailwind 实际使用的每个变量都在 index.css 有定义',
|
||||
usedVars.size >= 100 && undef.length === 0,
|
||||
undef.length
|
||||
? `${undef.length} 个未定义:${undef.slice(0, 6).join(', ')}`
|
||||
: `只解析出 ${usedVars.size} 个变量引用`
|
||||
);
|
||||
|
||||
// 18) colors 里每个颜色名只能定义一次。
|
||||
// 重复键静默生效,是上一条那个 bug 的**成因**:读代码的人看到固定 hex
|
||||
// 以为在用它,实际生效的是下面那份 accent()。
|
||||
const colorsBlock = cfg.slice(cfg.indexOf('colors: {'));
|
||||
const dupNames = [];
|
||||
for (const name of ['blue', 'red', 'green', 'amber', 'orange', 'yellow', 'gray', 'chrome']) {
|
||||
const n = (colorsBlock.match(new RegExp(`^\\s{8}${name}:`, 'gm')) || []).length;
|
||||
if (n > 1) dupNames.push(`${name}×${n}`);
|
||||
}
|
||||
check('colors 里没有重复的颜色名', dupNames.length === 0, dupNames.join(' '));
|
||||
|
||||
// 19) 强调色的**表面段**(50–300)深色下必须变暗。
|
||||
// 照搬浅色值的话 red-50 (#fef2f2) 在深色页面上是一块近白亮斑 ——
|
||||
// 那是错误提示条的底,结果比正文还抢眼,上面的红字反而读不动。
|
||||
const surfaceOffenders = [];
|
||||
for (const name of ['blue', 'red', 'green', 'amber', 'orange', 'yellow']) {
|
||||
for (const shade of [50, 100, 200, 300]) {
|
||||
const l = lum(lightBlock, `${name}-${shade}`);
|
||||
const d = lum(darkBlock, `${name}-${shade}`);
|
||||
if (l === null || d === null) { surfaceOffenders.push(`${name}-${shade}:缺失`); continue; }
|
||||
if (d >= l) surfaceOffenders.push(`${name}-${shade}(${d}≥${l})`);
|
||||
}
|
||||
}
|
||||
check(
|
||||
'强调色表面段在深色下变暗',
|
||||
surfaceOffenders.length === 0,
|
||||
surfaceOffenders.slice(0, 6).join(' ')
|
||||
);
|
||||
|
||||
// 20) 强调色的**前景段**(400–900)在深色卡片上必须达到 WCAG AA 4.5:1。
|
||||
// 照搬浅色值时 red-700 只有 2.67:1、amber-900 只有 1.90:1 ——
|
||||
// 「看得见但读不动」跟看不见是同一类 bug。
|
||||
const rgbOf = (block, name) => {
|
||||
const m = block.match(new RegExp(`--c-${name}:\\s*(\\d+)\\s+(\\d+)\\s+(\\d+)`));
|
||||
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
||||
};
|
||||
const srgb = c => { c /= 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
|
||||
const relLum = ([r, g, b]) => 0.2126 * srgb(r) + 0.7152 * srgb(g) + 0.0722 * srgb(b);
|
||||
const contrast = (a, b) => {
|
||||
const l1 = relLum(a), l2 = relLum(b);
|
||||
const [hi, lo] = l1 > l2 ? [l1, l2] : [l2, l1];
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
};
|
||||
const darkCard = rgbOf(darkBlock, 'white');
|
||||
const lowContrast = [];
|
||||
for (const name of ['blue', 'red', 'green', 'amber', 'orange', 'yellow']) {
|
||||
for (const shade of [400, 500, 600, 700, 800, 900]) {
|
||||
const fg = rgbOf(darkBlock, `${name}-${shade}`);
|
||||
if (!fg) { lowContrast.push(`${name}-${shade}:缺失`); continue; }
|
||||
const r = contrast(fg, darkCard);
|
||||
if (r < 4.5) lowContrast.push(`${name}-${shade}:${r.toFixed(2)}`);
|
||||
}
|
||||
}
|
||||
check(
|
||||
'强调色前景段在深色卡片上达到 4.5:1',
|
||||
darkCard !== null && lowContrast.length === 0,
|
||||
lowContrast.slice(0, 6).join(' ')
|
||||
);
|
||||
|
||||
// 21) 实心按钮底走独立的 --s-* 且**两种模式同值**。
|
||||
//
|
||||
// accent 的 400–900 在深色下被提亮(为了 text-red-600 读得动),
|
||||
// 而 `bg-red-600 text-white` 的白字落在那个浅红上只有 1.6:1。
|
||||
// 一个名字服务两种语义必然坏掉一头 —— 与 text-white/bg-white 那次同理。
|
||||
check(
|
||||
'实心按钮底走 --s-* 并只覆盖 backgroundColor',
|
||||
/const solid = \(name\) =>/.test(cfg) &&
|
||||
/backgroundColor:\s*\{[^}]*red:\s*solid\('red'\)/s.test(cfg) &&
|
||||
/--s-red-600:/.test(css)
|
||||
);
|
||||
|
||||
// 22) --s-* 不能出现在 .dark 里:它必须两种模式同值。
|
||||
// 在 .dark 覆盖等于把「危险操作是红的」这件事也一起反转了。
|
||||
check(
|
||||
'--s-* 未被 .dark 覆盖(实心底不随主题变)',
|
||||
!/--s-[a-z]+-\d+:/.test(darkBlock)
|
||||
);
|
||||
|
||||
// 23) 白字落在实心按钮底上必须达到 4.5:1(两种模式同一组值,只需算一次)。
|
||||
const solidBlock = css.slice(css.indexOf(':root'), css.indexOf('.dark'));
|
||||
const sRgb = name => {
|
||||
const m = solidBlock.match(new RegExp(`--s-${name}:\\s*(\\d+)\\s+(\\d+)\\s+(\\d+)`));
|
||||
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
||||
};
|
||||
// 代码里真正出现过的「实心底 + 白字」组合
|
||||
const solidPairs = [
|
||||
['blue-600'], ['blue-700'],
|
||||
['red-600'], ['red-700'],
|
||||
['green-700'], ['orange-700']
|
||||
];
|
||||
const onAccentLight = rgbOf(lightBlock, 'on-accent') ||
|
||||
(lightBlock.match(/--c-on-accent:\s*(\d+)\s+(\d+)\s+(\d+)/) || []).slice(1).map(Number);
|
||||
const weakButtons = [];
|
||||
for (const [name] of solidPairs) {
|
||||
const bg = sRgb(name);
|
||||
if (!bg) { weakButtons.push(`${name}:缺失`); continue; }
|
||||
// 这些按钮文字只有 9–14px,按普通文字执行 WCAG AA 4.5:1。
|
||||
const r = contrast(onAccentLight, bg);
|
||||
if (r < 4.5) weakButtons.push(`${name}:${r.toFixed(2)}`);
|
||||
}
|
||||
check(
|
||||
'白字在实心按钮底上达到 4.5:1',
|
||||
weakButtons.length === 0,
|
||||
weakButtons.join(' ')
|
||||
);
|
||||
|
||||
// 24) 应用框架(侧栏 / 底部导航)必须有独立色阶。
|
||||
// 它在浅色模式下本来就是深色的 —— 并入反转的 gray 之后深色模式下会变成
|
||||
// 近白色,比内容区还亮,整个层次翻过来(实测 rgb(243,245,248))。
|
||||
check(
|
||||
'框架有独立的 chrome 色阶',
|
||||
/chrome:\s*\{/.test(cfg) &&
|
||||
/--c-chrome-900:/.test(lightBlock) &&
|
||||
/--c-chrome-900:/.test(darkBlock)
|
||||
);
|
||||
|
||||
// 25) 深色下框架必须比内容区更沉(保持浅色下就有的层次关系)。
|
||||
const chromeDark = lum(darkBlock, 'chrome-900');
|
||||
check(
|
||||
'深色下框架比内容区更暗',
|
||||
chromeDark !== null && darkG50 !== null && chromeDark < darkG50,
|
||||
`chrome-900=${chromeDark} gray-50=${darkG50}`
|
||||
);
|
||||
|
||||
// 26) 侧栏与底部导航里不该残留 slate-*(那条色阶指向反转的 gray)。
|
||||
const chromeFiles = ['Sidebar', 'NarrowNav'];
|
||||
const slateLeft = [];
|
||||
for (const f of chromeFiles) {
|
||||
const src = readFileSync(join(here, `../src/components/${f}.tsx`), 'utf8');
|
||||
const hits = src.match(/(?:bg|text|border|hover:bg|hover:text|active:bg|ring)-slate-\d+/g) || [];
|
||||
if (hits.length) slateLeft.push(`${f}: ${hits.join(',')}`);
|
||||
}
|
||||
check('框架组件已全部改用 chrome 色阶', slateLeft.length === 0, slateLeft.join(' | '));
|
||||
|
||||
// 27) 可逆灰阶不能作为白字实心按钮底。gray-700/800/900 在深色模式下会
|
||||
// 被反转成近白色,`bg-gray-900 text-white` 因而只剩约 1:1。
|
||||
const componentSources = readdirSync(compDir)
|
||||
.filter(x => x.endsWith('.tsx'))
|
||||
.map(f => [f, readFileSync(join(compDir, f), 'utf8')]);
|
||||
const graySolid = componentSources.flatMap(([f, src]) =>
|
||||
(src.match(/(?:bg-gray-(?:700|800|900)[^'"\n]*text-white|text-white[^'"\n]*bg-gray-(?:700|800|900))/g) || [])
|
||||
.map(hit => `${f}:${hit}`)
|
||||
);
|
||||
check('白字实心控件不使用可逆 gray 底色', graySolid.length === 0, graySolid.slice(0, 4).join(' | '));
|
||||
|
||||
// 28) 未映射色族会绕过 CSS 变量主题,浅色值会原样落进深色页面。
|
||||
const unmapped = componentSources.flatMap(([f, src]) =>
|
||||
(src.match(/(?:bg|text|border)-(?:emerald|purple)-\d+/g) || []).map(hit => `${f}:${hit}`)
|
||||
);
|
||||
check('组件未使用未映射的 emerald/purple 色族', unmapped.length === 0, unmapped.slice(0, 6).join(' | '));
|
||||
|
||||
// 29) 本地 markdown 层替代未安装 typography 插件时无效的 prose 类。
|
||||
const inertProse = componentSources.flatMap(([f, src]) =>
|
||||
(src.match(/\bprose(?:-sm)?\b/g) || []).map(hit => `${f}:${hit}`)
|
||||
);
|
||||
check('Markdown 内容使用本地 .markdown 排版层', inertProse.length === 0, inertProse.slice(0, 4).join(' | '));
|
||||
|
||||
// 30) gray-400 承担 9–12px 元数据与 placeholder,白卡片上也必须达到 4.5:1。
|
||||
const lightCard = rgbOf(lightBlock, 'white');
|
||||
const lightSecondary = rgbOf(lightBlock, 'gray-400');
|
||||
const secondaryContrast = lightCard && lightSecondary ? contrast(lightCard, lightSecondary) : 0;
|
||||
check(
|
||||
'浅色 gray-400 在白卡片上达到 4.5:1',
|
||||
secondaryContrast >= 4.5,
|
||||
secondaryContrast.toFixed(2)
|
||||
);
|
||||
|
||||
console.log(`\n主题:${pass} 通过${fail ? `,${fail} 失败` : ''}`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
24
client/electron/tsconfig.json
Normal file
24
client/electron/tsconfig.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
// vitest 的 globals: true 让 describe/it/expect 无需 import;
|
||||
// jest-dom 提供 toBeInTheDocument 等 matcher 的类型。
|
||||
// 不加这两个的话 tsc --noEmit 会把整套测试报成「找不到名称」。
|
||||
"types": ["vitest/globals", "@testing-library/jest-dom"]
|
||||
},
|
||||
"include": ["src", "test/components", "vitest.config.ts"]
|
||||
}
|
||||
16
client/electron/vite.config.ts
Normal file
16
client/electron/vite.config.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
// Gateway 默认监听 8180(与 config.Load() 的 PORT 默认值一致)
|
||||
'/api': {
|
||||
target: 'http://localhost:8180',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
31
client/electron/vitest.config.ts
Normal file
31
client/electron/vitest.config.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
/**
|
||||
* 组件测试配置。
|
||||
*
|
||||
* 与 vite.config.ts 分开:那份是构建与开发服务器的配置,插件链和 test 段混在
|
||||
* 一起时 `vite build` 也会解析 jsdom 这些只有测试才需要的依赖。
|
||||
*
|
||||
* 只跑 test/components/ 下的用例。test/ 顶层那几个(markdown-xss、
|
||||
* narrow-layout)是 node:test / 手写断言脚本,由 `npm test` 直接用 node 跑 ——
|
||||
* 它们不需要 DOM,套一层 vitest 只是变慢。
|
||||
*/
|
||||
export default defineConfig({
|
||||
// 测试必须使用含 act() 的 React 开发构建;宿主进程可能继承 NODE_ENV=production。
|
||||
define: {
|
||||
'process.env.NODE_ENV': JSON.stringify('test')
|
||||
},
|
||||
plugins: [react()],
|
||||
test: {
|
||||
include: ['test/components/**/*.test.tsx'],
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['test/components/setup.ts'],
|
||||
// 每个文件跑完清掉 DOM 与 mock,避免上一个用例的残留影响下一个
|
||||
restoreMocks: true,
|
||||
clearMocks: true,
|
||||
unstubEnvs: true,
|
||||
unstubGlobals: true
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user