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

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

18
.gitignore vendored
View File

@ -1,31 +1,31 @@
# ---- 依赖与构建产物 ---- # ---- 依赖与构建产物 ----
# #
# 前端只在**构建期**用到 npm`npm run build` 出的 dist/ 被 cp 进 # 前端只在**构建期**用到 npm`npm run build` 出的 dist/ 被 cp 进
# gateway/internal/static/static/ 再由 go:embed 编进二进制。 # server/internal/static/static/ 再由 go:embed 编进二进制。
# 部署机上没有 node产物就是「一个二进制 + 一个 .db 文件」。 # 部署机上没有 node产物就是「一个二进制 + 一个 .db 文件」。
# 因此这三样都不进版本库:装依赖与构建都能从 package-lock.json 复现。 # 因此这三样都不进版本库:装依赖与构建都能从 package-lock.json 复现。
web/node_modules/ client/electron/node_modules/
web/dist/ client/electron/dist/
plugins/*/node_modules/ plugins/*/node_modules/
# dsh-mail-bridge 是 TypeScript 写的dist/ 是 tsc 的产物。 # dsh-mail-bridge 是 TypeScript 写的dist/ 是 tsc 的产物。
# 与 web/dist 同一个理由:装依赖与构建都能从 package-lock.json 复现, # 与 client/electron/dist 同一个理由:装依赖与构建都能从 package-lock.json 复现,
# 产物不进版本库。注意它**必须在装插件前构建**package.json 的 # 产物不进版本库。注意它**必须在装插件前构建**package.json 的
# main 指向 dist/index.jsdeploy/install.sh 负责这一步。 # main 指向 dist/index.jsdeploy/install.sh 负责这一步。
plugins/*/dist/ plugins/*/dist/
# go:embed 的输入目录 = web/dist 的副本,同属构建产物。 # go:embed 的输入目录 = client/electron/dist 的副本,同属构建产物。
# #
# 但目录本身要留下go:embed 要求它存在才能编译,否则新克隆连 # 但目录本身要留下go:embed 要求它存在才能编译,否则新克隆连
# `go test ./...` 都跑不起来 —— 只改后端的人不该被迫先装 node。 # `go test ./...` 都跑不起来 —— 只改后端的人不该被迫先装 node。
# 因此忽略构建产物,只保留 placeholder.html见该文件内注释 # 因此忽略构建产物,只保留 placeholder.html见该文件内注释
# 之所以不叫 index.html是因为那正是 Vite 产物的名字,会被反复覆盖)。 # 之所以不叫 index.html是因为那正是 Vite 产物的名字,会被反复覆盖)。
gateway/internal/static/static/* server/internal/static/static/*
!gateway/internal/static/static/placeholder.html !server/internal/static/static/placeholder.html
# Go 构建产物 # Go 构建产物
gateway/agentmail-gateway server/agentmail-gateway
gateway/gw server/gw
*.test *.test
# ---- 运行态数据 ---- # ---- 运行态数据 ----

View File

@ -32,12 +32,12 @@
```bash ```bash
# 后端:默认用内置 SQLite无需任何外部依赖 # 后端:默认用内置 SQLite无需任何外部依赖
cd gateway cd server
ADMIN_USER=admin ADMIN_PASSWORD=你的密码 go run ./cmd/server ADMIN_USER=admin ADMIN_PASSWORD=你的密码 go run ./cmd/server
# → http://localhost:8180 # → http://localhost:8180
# 前端(另开一个终端) # 前端(另开一个终端)
cd web cd client/electron
npm install npm install
npm run dev # → http://localhost:5173自动代理到 8180 npm run dev # → http://localhost:5173自动代理到 8180
``` ```
@ -75,10 +75,10 @@ bash deploy/redeploy-gateway.sh # 落地
### 构建期依赖 ### 构建期依赖
npm 只在构建期用到Node ≥ 18`npm run build`)与 Go ≥ 1.22。 npm 只在构建期用到Node ≥ 18`npm run build`)与 Go ≥ 1.22。
`web/dist` 会被复制进 `gateway/internal/static/static/` 再由 `go:embed` 编入二进制, `client/electron/dist` 会被复制进 `server/internal/static/static/` 再由 `go:embed` 编入二进制,
**部署机上不需要 node** **部署机上不需要 node**
`web/node_modules``web/dist``gateway/internal/static/static/` 的内容与编译出的二进制 `client/electron/node_modules``client/electron/dist``server/internal/static/static/` 的内容与编译出的二进制
都是构建产物,不进版本库(见 `.gitignore`)。 都是构建产物,不进版本库(见 `.gitignore`)。
新克隆**可以直接 `go build` / `go test`**`static/` 目录里留了一个占位 index.html 新克隆**可以直接 `go build` / `go test`**`static/` 目录里留了一个占位 index.html
@ -87,9 +87,13 @@ npm 只在构建期用到Node ≥ 18`npm run build`)与 Go ≥ 1.22。
`deploy/install.sh`,或手工: `deploy/install.sh`,或手工:
```bash ```bash
cd web && npm ci && npm run build # 在仓库根目录执行
rm -rf ../gateway/internal/static/static && cp -r dist ../gateway/internal/static/static npm --prefix client/electron ci
cd ../gateway && go build ./cmd/server npm --prefix client/electron run build
rm -rf server/internal/static/static/assets
rm -f server/internal/static/static/index.html
cp -r client/electron/dist/. server/internal/static/static/
(cd server && go build ./cmd/server)
``` ```
### 数据库 ### 数据库
@ -186,7 +190,7 @@ agentmail/
│ ├── PLAN.md # 分阶段实施计划 │ ├── PLAN.md # 分阶段实施计划
│ ├── MVP-SPEC.md # MVP 技术规格书 │ ├── MVP-SPEC.md # MVP 技术规格书
│ └── PLUGIN-CONTRACT.md # Agent 平台插件契约(规格 + 验收清单) │ └── PLUGIN-CONTRACT.md # Agent 平台插件契约(规格 + 验收清单)
├── gateway/ # 后端Go单二进制 ├── server/ # 后端Go单二进制
│ ├── cmd/server/ # 入口与路由表 │ ├── cmd/server/ # 入口与路由表
│ └── internal/ │ └── internal/
│ ├── db/ # 连接 + 方言适配 + 内嵌迁移 │ ├── db/ # 连接 + 方言适配 + 内嵌迁移
@ -200,8 +204,10 @@ agentmail/
│ ├── opencode-mail-bridge/ # opencode插件 │ ├── opencode-mail-bridge/ # opencode插件
│ ├── dsh-mail-bridge/ # DeepSeek HarnessCordis 插件) │ ├── dsh-mail-bridge/ # DeepSeek HarnessCordis 插件)
│ └── pi-mail-bridge/ # pi常驻守护进程用 SDK 起会话) │ └── pi-mail-bridge/ # pi常驻守护进程用 SDK 起会话)
├── web/ # 前端React + Vite + Tailwind ├── client/
── test/manual/ # 浏览器实测脚本(量真实盒子与命中区,不进 npm test ── electron/ # Electron + React + Vite + Tailwind 客户端
│ │ └── test/manual/ # 浏览器实测脚本(量真实盒子与命中区,不进 npm test
│ └── harmony/ # HarmonyOS ArkUI 客户端
└── deploy/ # systemd 单元 + 安装脚本 └── deploy/ # systemd 单元 + 安装脚本
├── redeploy-gateway.sh # 二进制热替换(.backup + 原子 install + 后置验证 + 自动回滚) ├── redeploy-gateway.sh # 二进制热替换(.backup + 原子 install + 后置验证 + 自动回滚)
└── remote-agent-demo.py # 最小跨主机 Agent纯标准库验证协议层能力 └── remote-agent-demo.py # 最小跨主机 Agent纯标准库验证协议层能力
@ -223,8 +229,8 @@ agentmail/
## 验证 ## 验证
```bash ```bash
cd gateway && go build ./... && go test ./... # 后端 cd server && go build ./... && go test ./... # 后端
cd web && npm run typecheck && npm test # 前端(含 Markdown XSS 回归测试) cd client/electron && npm run typecheck && npm test # 前端(含 Markdown XSS 回归测试)
``` ```
## WebAPI ## WebAPI

View File

@ -5,10 +5,10 @@
* - 创建 BrowserWindow 加载前端dev=<ELECTRON_START_URL>prod=../dist/index.html * - 创建 BrowserWindow 加载前端dev=<ELECTRON_START_URL>prod=../dist/index.html
* - 系统托盘Phase 4 完整实现 * - 系统托盘Phase 4 完整实现
* - 离线缓存Phase 4 * - 离线缓存Phase 4
* - Gateway 地址与用户密钥注入渲染进程Phase 2 web/src 以独立客户端身份连任意 Gateway * - Gateway 地址与用户密钥注入渲染进程Phase 2 client/electron/src 以独立客户端身份连任意 Gateway
* *
* 设计约束 * 设计约束
* - 渲染进程复用 web/src React 代码零改动 * - 渲染进程复用 client/electron/src React 代码零改动
* - 只通过 contextBridge 暴露必要 API不放开 nodeIntegration * - 只通过 contextBridge 暴露必要 API不放开 nodeIntegration
*/ */

View File

@ -1,7 +1,7 @@
/** /**
* AgentMail Electron preload 通过 contextBridge 暴露安全 API 给渲染进程 * AgentMail Electron preload 通过 contextBridge 暴露安全 API 给渲染进程
* *
* 渲染进程是 web/src React跑在 sandbox: true nodeIntegration * 渲染进程是 client/electron/src React跑在 sandbox: true nodeIntegration
* 需要桌面能力时文件选择通知托盘角标SSE 转发走这里暴露的桥 * 需要桌面能力时文件选择通知托盘角标SSE 转发走这里暴露的桥
* *
* Phase 1 只暴露最小集后续 Phase 逐步加 * Phase 1 只暴露最小集后续 Phase 逐步加

View File

@ -2,7 +2,7 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
<title>AgentMail</title> <title>AgentMail</title>
<script> <script>
/* /*

View File

@ -196,7 +196,7 @@ function PaneLoading() {
*/ */
function NarrowShell({ children }: { children: React.ReactNode }) { function NarrowShell({ children }: { children: React.ReactNode }) {
return ( return (
<div className="h-full flex flex-col bg-gray-50 overflow-hidden"> <div className="h-full flex flex-col bg-gray-50 overflow-hidden safe-frame">
{children} {children}
<NarrowNav /> <NarrowNav />
</div> </div>

View File

@ -30,6 +30,7 @@ export default function AddressInput({
const [meta, setMeta] = useState<SessionCandidate[]>([]); const [meta, setMeta] = useState<SessionCandidate[]>([]);
const [kind, setKind] = useState<'name' | 'path' | 'session'>('name'); const [kind, setKind] = useState<'name' | 'path' | 'session'>('name');
const [active, setActive] = useState(0); const [active, setActive] = useState(0);
const [menuLayout, setMenuLayout] = useState({ flip: false, maxHeight: 288 });
const boxRef = useRef<HTMLDivElement>(null); const boxRef = useRef<HTMLDivElement>(null);
// 当前正在编辑的那一段(多地址时取最后一段) // 当前正在编辑的那一段(多地址时取最后一段)
@ -96,6 +97,34 @@ export default function AddressInput({
return () => document.removeEventListener('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) => { const apply = (choice: string) => {
let next: string; let next: string;
@ -151,7 +180,12 @@ export default function AddressInput({
/> />
{open && items.length > 0 && ( {open && items.length > 0 && (
<div className="absolute z-20 mt-1 w-full max-h-72 overflow-y-auto bg-white border border-gray-200 rounded-md shadow-lg"> <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"> <div className="px-2.5 py-1 text-[10px] text-gray-400 border-b border-gray-100">
{hint} {hint}
</div> </div>
@ -182,7 +216,7 @@ export default function AddressInput({
*/} */}
{c?.source === 'platform' && ( {c?.source === 'platform' && (
<span <span
className="shrink-0 px-1 py-0.5 rounded bg-purple-100 text-purple-700 text-[9px]" className="shrink-0 px-1 py-0.5 rounded bg-blue-100 text-blue-700 text-[9px]"
title="平台侧已有的会话,本站还没有对应的邮件往来" title="平台侧已有的会话,本站还没有对应的邮件往来"
> >
@ -192,7 +226,7 @@ export default function AddressInput({
<span className="shrink-0 text-[10px] text-gray-400 font-sans"></span> <span className="shrink-0 text-[10px] text-gray-400 font-sans"></span>
)} )}
{(c?.unread ?? 0) > 0 && ( {(c?.unread ?? 0) > 0 && (
<span className="shrink-0 px-1 py-0.5 rounded bg-red-500 text-white text-[9px]"> <span className="shrink-0 px-1 py-0.5 rounded bg-red-600 text-white text-[9px]">
{c!.unread} {c!.unread}
</span> </span>
)} )}

View File

@ -173,7 +173,7 @@ function TabButton({ active, onClick, children }: {
<button <button
onClick={onClick} onClick={onClick}
className={`tap flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md ${ className={`tap flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md ${
active ? 'bg-gray-900 text-white' : 'text-gray-600 hover:bg-gray-100' active ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-100'
}`} }`}
> >
{children} {children}
@ -195,7 +195,7 @@ function UserCard({ user, scopes, expanded, onToggle, onSaved, onReload, setErro
</button> </button>
<span className="font-mono text-sm text-gray-900 min-w-[100px]">{user.username}</span> <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="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-purple-100 text-purple-700' : 'bg-gray-100 text-gray-600'}`}> <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' ? '管理员' : '用户'} {user.role === 'admin' ? '管理员' : '用户'}
</span> </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'}`}> <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'}`}>
@ -317,12 +317,12 @@ function UserEditor({ user, scopes, onSaved, onReload, setError }: {
<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"> <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 ? '保存中' : '保存更改'} {busy ? '保存中' : '保存更改'}
</button> </button>
<div className="flex items-center gap-1.5 ml-auto"> <div className="flex items-center gap-1.5 ml-auto flex-wrap">
<LockIcon className="w-3 h-3 text-gray-400" /> <LockIcon className="w-3 h-3 text-gray-400" />
<input type="password" value={pw} onChange={e => setPw(e.target.value)} placeholder="新密码(至少 8 位)" <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" /> 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} <button onClick={resetPassword} disabled={pw.length < 8 || busy}
className="tap inline-flex items-center gap-1 px-2 py-1 text-[11px] rounded bg-gray-700 text-white hover:bg-gray-800 disabled:opacity-40"> 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" /> <CheckIcon className="w-3 h-3" />
</button> </button>
</div> </div>

View File

@ -114,32 +114,34 @@ export function AttachmentPicker({
onChange={e => handleFiles(e.target.files)} onChange={e => handleFiles(e.target.files)}
/> />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 flex-wrap min-w-0">
<button <button
type="button"
onClick={pick} onClick={pick}
disabled={disabled || uploading !== null} disabled={disabled || uploading !== null}
className="tap 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" 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" /> <PaperclipIcon className="w-3.5 h-3.5" />
</button> </button>
{uploading && ( {uploading && (
<span className="inline-flex items-center gap-1.5 text-[11px] text-gray-500"> <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" /> <SpinnerIcon className="w-3.5 h-3.5 animate-spin shrink-0" />
{uploading.name} {uploading.pct}% <span className="truncate">{uploading.name}</span>
<span className="shrink-0">{uploading.pct}%</span>
</span> </span>
)} )}
{items.length > 0 && !uploading && ( {items.length > 0 && !uploading && (
<span className="text-[11px] text-gray-400"> <span className="text-[11px] text-gray-500 min-w-0 break-words">
{items.length} ·{' '} {items.length} ·{' '}
{api.formatSize(items.reduce((sum, a) => sum + a.size, 0))} {api.formatSize(items.reduce((sum, a) => sum + a.size, 0))}
</span> </span>
)} )}
</div> </div>
{error && <div className="text-[11px] text-red-600">{error}</div>} {error && <div className="text-[11px] text-red-600 break-words">{error}</div>}
{items.length > 0 && ( {items.length > 0 && (
<ul className="space-y-1"> <ul className="space-y-1">
@ -152,10 +154,11 @@ export function AttachmentPicker({
<span className="text-xs text-gray-800 truncate flex-1">{a.filename}</span> <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> <span className="text-[10px] text-gray-400 shrink-0">{api.formatSize(a.size)}</span>
<button <button
type="button"
onClick={() => remove(a)} onClick={() => remove(a)}
disabled={disabled} disabled={disabled}
title="移除" title="移除"
className="shrink-0 text-gray-400 hover:text-red-600 disabled:opacity-40" 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" /> <CloseIcon className="w-3.5 h-3.5" />
</button> </button>

View File

@ -641,7 +641,7 @@ export default function CalendarEventEditor({
)} )}
</div> </div>
<div className="flex items-center gap-2 px-4 py-3 border-t border-gray-200 shrink-0"> <div className="flex items-center gap-2 px-4 py-3 border-t border-gray-200 shrink-0 flex-wrap">
<button <button
onClick={save} onClick={save}
disabled={!canSave} disabled={!canSave}
@ -658,9 +658,9 @@ export default function CalendarEventEditor({
</button> </button>
{editing && ( {editing && (
<div className="ml-auto"> <div className="w-full sm:w-auto sm:ml-auto">
{confirmDelete ? ( {confirmDelete ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-gray-600"></span> <span className="text-xs text-gray-600"></span>
<button <button
onClick={remove} onClick={remove}

View File

@ -346,7 +346,7 @@ export default function CalendarView() {
return ( return (
<div className="flex-1 min-w-0 flex min-h-0"> <div className="flex-1 min-w-0 flex min-h-0">
{gridPane} {gridPane}
<div className="w-full md:w-[400px] shrink-0 border-l border-gray-200 bg-white flex flex-col min-h-0"> <div className="w-full lg:w-[400px] shrink-0 border-l border-gray-200 bg-white flex flex-col min-h-0">
{sidePane} {sidePane}
</div> </div>
</div> </div>

View File

@ -7,6 +7,7 @@ import NarrowOnly from './NarrowOnly';
import { useMailStore } from '../stores/mailStore'; import { useMailStore } from '../stores/mailStore';
import { useSessionStore } from '../stores/sessionStore'; import { useSessionStore } from '../stores/sessionStore';
import { useContactStore } from '../stores/contactStore'; import { useContactStore } from '../stores/contactStore';
import { useIsNarrow } from '../hooks/useIsNarrow';
import AddressInput from './AddressInput'; import AddressInput from './AddressInput';
import { AttachmentPicker, type PendingAttachment } from './Attachments'; import { AttachmentPicker, type PendingAttachment } from './Attachments';
import { ComposeIcon, ChevronLeftIcon } from './icons'; import { ComposeIcon, ChevronLeftIcon } from './icons';
@ -19,6 +20,7 @@ export default function ComposePage() {
const fetchSent = useMailStore(s => s.fetchSent); const fetchSent = useMailStore(s => s.fetchSent);
const fetchSessions = useSessionStore(s => s.fetchSessions); const fetchSessions = useSessionStore(s => s.fetchSessions);
const fetchContacts = useContactStore(s => s.fetchContacts); const fetchContacts = useContactStore(s => s.fetchContacts);
const isNarrow = useIsNarrow();
const [to, setTo] = useState(prefill?.to ?? ''); const [to, setTo] = useState(prefill?.to ?? '');
const [cc, setCc] = useState(prefill?.cc ?? ''); const [cc, setCc] = useState(prefill?.cc ?? '');
@ -132,8 +134,8 @@ export default function ComposePage() {
}; };
return ( return (
<div className="flex-1 min-w-0 flex flex-col bg-white"> <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="px-4 md:px-6 py-3 border-b border-gray-200 flex items-center gap-2"> <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 cancelCompose showList
*/} */}
@ -169,12 +171,12 @@ export default function ComposePage() {
</button> </button>
</div> </div>
<div className="px-4 md:px-6 py-4 space-y-3 border-b border-gray-200"> <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=新建,别名=已有会话"> <Field label="收件人" hint="name@path.session省略=默认会话new=新建,别名=已有会话">
<AddressInput <AddressInput
value={to} value={to}
onChange={setTo} onChange={setTo}
autoFocus autoFocus={!isNarrow}
placeholder="deepseekharness@/program.upadtefeature" placeholder="deepseekharness@/program.upadtefeature"
/> />
</Field> </Field>
@ -247,8 +249,8 @@ export default function ComposePage() {
</Field> </Field>
</div> </div>
<div className="flex-1 min-h-0 px-4 md:px-6 py-3 flex flex-col"> <div className="shrink-0 lg:flex-1 lg:min-h-0 px-4 md:px-6 py-3 flex flex-col">
<div className="flex items-center gap-2 mb-1.5"> <div className="shrink-0 flex items-center gap-2 mb-1.5">
<span className="text-[11px] font-medium text-gray-500">Markdown</span> <span className="text-[11px] font-medium text-gray-500">Markdown</span>
<div className="flex-1" /> <div className="flex-1" />
<Toggle active={!preview} onClick={() => setPreview(false)}> <Toggle active={!preview} onClick={() => setPreview(false)}>
@ -260,7 +262,7 @@ export default function ComposePage() {
</div> </div>
{preview ? ( {preview ? (
<div className="flex-1 min-h-0 overflow-y-auto border border-gray-200 rounded-md p-4 prose prose-sm max-w-none"> <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() ? ( {body.trim() ? (
<Markdown remarkPlugins={[remarkGfm]}>{body}</Markdown> <Markdown remarkPlugins={[remarkGfm]}>{body}</Markdown>
) : ( ) : (
@ -272,19 +274,21 @@ export default function ComposePage() {
value={body} value={body}
onChange={e => setBody(e.target.value)} onChange={e => setBody(e.target.value)}
placeholder={'## 需求\n\n请在 /program 下推进 update feature…'} placeholder={'## 需求\n\n请在 /program 下推进 update feature…'}
className="flex-1 min-h-0 w-full text-sm font-mono border border-gray-300 rounded-md p-4 resize-none focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400" 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> </div>
<div className="px-4 md:px-6 pb-3"> {/* sticky
*/}
<div className="shrink-0 px-4 md:px-6 pb-20 lg:pb-3">
<AttachmentPicker items={attachments} onChange={setAttachments} disabled={sending} /> <AttachmentPicker items={attachments} onChange={setAttachments} disabled={sending} />
</div> </div>
<div className="px-4 md:px-6 py-3 border-t border-gray-200 flex items-center gap-3"> <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="text-xs text-red-600">{error}</span>} {error && <span className="min-w-0 text-xs text-red-600 break-words">{error}</span>}
{okMsg && <span className="text-xs text-green-600">{okMsg}</span>} {okMsg && <span className="min-w-0 text-xs text-green-700 break-words">{okMsg}</span>}
<div className="flex-1" /> <div className="flex-1 min-w-2" />
<button <button
onClick={cancelCompose} onClick={cancelCompose}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900" className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900"
@ -336,7 +340,7 @@ function Toggle({
<button <button
onClick={onClick} onClick={onClick}
className={`tap text-[11px] px-2 py-0.5 rounded ${ className={`tap text-[11px] px-2 py-0.5 rounded ${
active ? 'bg-gray-900 text-white' : 'text-gray-500 hover:text-gray-800' active ? 'bg-blue-600 text-white' : 'text-gray-500 hover:text-gray-800'
}`} }`}
> >
{children} {children}

View File

@ -58,7 +58,7 @@ export default function ContactPanel() {
<div <div
className={`w-full shrink-0 border-r border-gray-200 bg-white flex flex-col min-w-0 ${ className={`w-full shrink-0 border-r border-gray-200 bg-white flex flex-col min-w-0 ${
// 卡片要放两行摘要 + 预算条320px 会挤;列表视图保持紧凑 // 卡片要放两行摘要 + 预算条320px 会挤;列表视图保持紧凑
view === 'card' ? 'md:w-[400px]' : 'md:w-[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"> <div className="px-4 py-3 border-b border-gray-200 flex items-center gap-1">
@ -82,7 +82,7 @@ export default function ContactPanel() {
<button <button
onClick={toggleArchivedView} onClick={toggleArchivedView}
className={`tap text-[11px] px-1.5 py-0.5 rounded ${ className={`tap text-[11px] px-1.5 py-0.5 rounded ${
showArchived ? 'bg-gray-900 text-white' : 'text-gray-500 hover:text-gray-800' showArchived ? 'bg-blue-600 text-white' : 'text-gray-500 hover:text-gray-800'
}`} }`}
> >
@ -237,7 +237,7 @@ function ContactRow({
</span> </span>
<span className="text-[10px] text-gray-400 font-mono truncate">{contact.path}</span> <span className="text-[10px] text-gray-400 font-mono truncate">{contact.path}</span>
{contact.unread_count > 0 && ( {contact.unread_count > 0 && (
<span className="ml-auto shrink-0 min-w-[16px] h-4 px-1 rounded-full bg-blue-500 text-white text-[9px] font-bold flex items-center justify-center"> <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} {contact.unread_count}
</span> </span>
)} )}

View File

@ -63,7 +63,7 @@ export default function MailList() {
}; };
return ( return (
<div className="w-full md:w-[320px] shrink-0 border-r border-gray-200 bg-white flex flex-col min-w-0"> <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"> <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> <h2 className="text-sm font-semibold text-gray-800">{isSent ? '发件箱' : '收件箱'}</h2>
{/* · {/* ·
@ -178,7 +178,7 @@ function SessionGroup({
<div className="flex items-center gap-1.5 mt-0.5 pl-5"> <div className="flex items-center gap-1.5 mt-0.5 pl-5">
{g.unreadCount > 0 && ( {g.unreadCount > 0 && (
<span className="shrink-0 min-w-[16px] h-4 px-1 rounded-full bg-blue-500 text-white text-[9px] font-bold flex items-center justify-center"> <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} {g.unreadCount}
</span> </span>
)} )}
@ -200,7 +200,7 @@ function SessionGroup({
</button> </button>
{open && ( {open && (
<div className="pl-3 pr-1 pb-1.5 space-y-0.5"> <div className="pl-5 pr-1 pb-1.5 space-y-0.5">
{g.mails.map(m => ( {g.mails.map(m => (
<MailItem <MailItem
key={m.mail_id} key={m.mail_id}
@ -312,7 +312,7 @@ function MailItem({
</span> </span>
)} )}
{!compact && mail.session_alias && ( {!compact && mail.session_alias && (
<span className="text-[10px] text-blue-500 font-mono">.{mail.session_alias}</span> <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>} {ccCount > 0 && <span className="text-[10px] text-gray-400"> {ccCount}</span>}
{attachCount > 0 && ( {attachCount > 0 && (

View File

@ -108,7 +108,7 @@ export default function MailView() {
onThread={() => setThreadOf(currentMail.mail_id)} onThread={() => setThreadOf(currentMail.mail_id)}
/> />
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-4"> <div className="flex-1 overflow-y-auto px-4 md:px-6 py-4">
<div className="prose prose-sm max-w-none"> <div className="markdown text-sm">
<Markdown remarkPlugins={[remarkGfm]}>{currentMail.body}</Markdown> <Markdown remarkPlugins={[remarkGfm]}>{currentMail.body}</Markdown>
</div> </div>
<AttachmentList items={currentMail.attachments ?? []} /> <AttachmentList items={currentMail.attachments ?? []} />
@ -322,7 +322,7 @@ function RenameProposalBar() {
await accept(); await accept();
setBusy(false); setBusy(false);
}} }}
className="px-2.5 py-1 rounded bg-blue-500 text-white text-xs hover:bg-blue-600 disabled:opacity-50 shrink-0" className="px-2.5 py-1 rounded bg-blue-600 text-white text-xs hover:bg-blue-600 disabled:opacity-50 shrink-0"
> >
{busy ? '改名中' : '接受'} {busy ? '改名中' : '接受'}
</button> </button>
@ -373,7 +373,7 @@ function ForwardBar({ mail, onClose }: { mail: Mail; onClose: () => void }) {
}; };
return ( return (
<div className="border-t border-gray-200 bg-white px-4 md:px-6 py-3 space-y-2"> <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"> <div className="flex items-center gap-2">
<ForwardIcon className="w-3.5 h-3.5 text-gray-500" /> <ForwardIcon className="w-3.5 h-3.5 text-gray-500" />
<span className="text-[11px] font-medium text-gray-600"> <span className="text-[11px] font-medium text-gray-600">
@ -388,7 +388,7 @@ function ForwardBar({ mail, onClose }: { mail: Mail; onClose: () => void }) {
</button> </button>
</div> </div>
<AddressInput value={to} onChange={setTo} autoFocus placeholder="新收件人pi@root.new" /> <AddressInput value={to} onChange={setTo} placeholder="新收件人pi@root.new" />
{ccOpen && ( {ccOpen && (
<AddressInput <AddressInput
@ -406,8 +406,8 @@ function ForwardBar({ mail, onClose }: { mail: Mail; onClose: () => void }) {
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" 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"> <div className="flex items-center gap-2 flex-wrap">
{error && <span className="text-xs text-red-600">{error}</span>} {error && <span className="text-xs text-red-600 min-w-0 break-words">{error}</span>}
<div className="flex-1" /> <div className="flex-1" />
<button onClick={onClose} className="tap px-3 py-1.5 text-xs text-gray-500 hover:text-gray-800"> <button onClick={onClose} className="tap px-3 py-1.5 text-xs text-gray-500 hover:text-gray-800">
@ -577,7 +577,7 @@ function ThreadCard({ mail, onForward }: { mail: Mail; onForward?: () => void })
</button> </button>
)} )}
</div> </div>
<div className="prose prose-sm max-w-none"> <div className="markdown text-sm">
<Markdown remarkPlugins={[remarkGfm]}>{mail.body}</Markdown> <Markdown remarkPlugins={[remarkGfm]}>{mail.body}</Markdown>
</div> </div>
<AttachmentList items={mail.attachments ?? []} /> <AttachmentList items={mail.attachments ?? []} />
@ -635,7 +635,7 @@ export function PermissionPanel({ mail }: { mail: Mail }) {
disabled={busy} 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 ${ 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) isApprove(opt)
? 'bg-green-600 text-white hover:bg-green-700' ? 'bg-green-700 text-white hover:bg-green-800'
: 'bg-red-50 text-red-700 border border-red-200 hover:bg-red-100' : 'bg-red-50 text-red-700 border border-red-200 hover:bg-red-100'
}`} }`}
> >
@ -738,8 +738,8 @@ function ReplyBar({
}; };
return ( return (
<div className="border-t border-gray-200 bg-white px-4 md:px-6 py-3"> <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"> <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> <p className="text-[10px] text-gray-400 font-mono min-w-0 truncate"> {target}</p>
<div className="flex-1" /> <div className="flex-1" />
{(replyTo.cc_list?.length ?? 0) > 0 && ( {(replyTo.cc_list?.length ?? 0) > 0 && (

View File

@ -60,7 +60,7 @@ export default function NarrowNav() {
return ( return (
<nav <nav
className="shrink-0 border-t border-chrome-700 bg-chrome-900 flex items-stretch" className="narrow-nav shrink-0 border-t border-chrome-700 bg-chrome-900 flex items-stretch"
// 底部安全区iPhone 的手势条会盖住最后一排 // 底部安全区iPhone 的手势条会盖住最后一排
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }} style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
> >
@ -90,7 +90,7 @@ export default function NarrowNav() {
<span <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 ${ 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' mode === 'inbox'
? 'bg-red-500 text-white' ? 'bg-red-600 text-white'
: mode === 'permissions' : mode === 'permissions'
? 'bg-orange-700 text-white' ? 'bg-orange-700 text-white'
: 'bg-chrome-600 text-chrome-100' : 'bg-chrome-600 text-chrome-100'

View File

@ -74,7 +74,7 @@ export default function NarrowStack({
{mounted && ( {mounted && (
<div <div
className={`absolute inset-0 z-10 flex bg-white shadow-2xl transition-transform duration-200 ease-out motion-reduce:transition-none ${ 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' entered ? 'translate-x-0' : 'translate-x-full'
}`} }`}
> >

View File

@ -55,7 +55,7 @@ export default function PermissionChip({ mode, enforcement, compact }: Permissio
? 'bg-blue-50 text-blue-700 border-blue-200' ? 'bg-blue-50 text-blue-700 border-blue-200'
: normalized === 'full' : normalized === 'full'
? 'bg-amber-50 text-amber-700 border-amber-200' ? 'bg-amber-50 text-amber-700 border-amber-200'
: 'bg-emerald-50 text-emerald-700 border-emerald-200'; : 'bg-green-50 text-green-700 border-green-200';
const hint = permissionModeHint(normalized, enforcement); const hint = permissionModeHint(normalized, enforcement);
const label = MODE_LABEL[normalized] ?? normalized; const label = MODE_LABEL[normalized] ?? normalized;

View File

@ -54,7 +54,7 @@ export default function PermissionList() {
}; };
return ( return (
<div className="w-full md:w-[340px] shrink-0 border-r border-gray-200 bg-white flex flex-col min-w-0"> <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"> <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> <h2 className="text-sm font-semibold text-gray-800"></h2>
{pendingTotal > 0 ? ( {pendingTotal > 0 ? (

View File

@ -85,7 +85,7 @@ export default function Sidebar() {
<span <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 ${ 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' mode === 'inbox'
? 'bg-red-500 text-white' ? 'bg-red-600 text-white'
: mode === 'permissions' : mode === 'permissions'
? // 待决策的授权用橙色:它跟未读不是一类紧急 —— ? // 待决策的授权用橙色:它跟未读不是一类紧急 ——
// 未读是「有内容没看」,待决策是「有 Agent 卡在那儿等我」 // 未读是「有内容没看」,待决策是「有 Agent 卡在那儿等我」
@ -106,7 +106,7 @@ export default function Sidebar() {
onClick={() => startCompose()} onClick={() => startCompose()}
title="新建邮件" title="新建邮件"
className={`w-12 h-12 rounded-lg flex flex-col items-center justify-center gap-0.5 text-white transition-colors ${ className={`w-12 h-12 rounded-lg flex flex-col items-center justify-center gap-0.5 text-white transition-colors ${
composing ? 'bg-blue-500 ring-2 ring-blue-300' : 'bg-blue-600 hover:bg-blue-500' composing ? 'bg-blue-700 ring-2 ring-blue-300' : 'bg-blue-600 hover:bg-blue-700'
}`} }`}
> >
<ComposeIcon /> <ComposeIcon />
@ -119,7 +119,7 @@ export default function Sidebar() {
title={`${user?.display_name || user?.username}(点击管理账号)`} 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 ${ className={`relative w-9 h-9 rounded-full flex items-center justify-center text-[11px] font-semibold transition-colors ${
viewMode === 'account' && !composing viewMode === 'account' && !composing
? 'bg-blue-500 text-white' ? 'bg-blue-600 text-white'
: 'bg-chrome-700 text-chrome-200 hover:bg-chrome-600' : 'bg-chrome-700 text-chrome-200 hover:bg-chrome-600'
}`} }`}
> >

View File

@ -253,7 +253,7 @@ function Node({
<span className="text-xs font-mono text-gray-500 truncate">{node.to_name}</span> <span className="text-xs font-mono text-gray-500 truncate">{node.to_name}</span>
<div className="flex-1" /> <div className="flex-1" />
{isForward && ( {isForward && (
<span className="px-1 py-0.5 rounded bg-purple-100 text-purple-700 text-[9px]"> <span className="px-1 py-0.5 rounded bg-blue-100 text-blue-700 text-[9px]">
</span> </span>
)} )}

View File

@ -55,7 +55,7 @@ export function WorkCard({
<span className="text-xs font-semibold text-gray-900 truncate">{c.agent_name}</span> <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> <span className="text-[10px] text-gray-400 font-mono truncate">{c.path}</span>
{c.unread_count > 0 && ( {c.unread_count > 0 && (
<span className="ml-auto shrink-0 min-w-[16px] h-4 px-1 rounded-full bg-blue-500 text-white text-[9px] font-bold flex items-center justify-center"> <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} {c.unread_count}
</span> </span>
)} )}

View File

@ -3,13 +3,14 @@ import { useEffect, useState } from 'react';
/** /**
* *
* *
* 60+ 320+ 400 780px * 60+ 320+ 520 900px
* 768px * Tailwind lg
* 1024px 768/820px 使
* *
* matchMedia resize * matchMedia resize
* matchMedia * matchMedia
*/ */
const NARROW_QUERY = '(max-width: 767px)'; export const NARROW_QUERY = '(max-width: 1023px)';
export function useIsNarrow(): boolean { export function useIsNarrow(): boolean {
const [narrow, setNarrow] = useState(() => const [narrow, setNarrow] = useState(() =>

View File

@ -40,7 +40,8 @@
--c-gray-100: 243 244 246; --c-gray-100: 243 244 246;
--c-gray-200: 229 231 235; --c-gray-200: 229 231 235;
--c-gray-300: 209 213 219; --c-gray-300: 209 213 219;
--c-gray-400: 156 163 175; /* 最小字号与 placeholder 也会使用这一档;在白底上保持至少 4.5:1。 */
--c-gray-400: 107 114 128;
--c-gray-500: 107 114 128; --c-gray-500: 107 114 128;
--c-gray-600: 75 85 99; --c-gray-600: 75 85 99;
--c-gray-700: 55 65 81; --c-gray-700: 55 65 81;
@ -340,10 +341,21 @@
} }
@layer base { @layer base {
:root {
/* main.tsx 会用 Visual Viewport 覆盖;这里保证脚本执行前也有正确高度。 */
--app-height: 100vh;
}
@supports (height: 100dvh) {
:root {
--app-height: 100dvh;
}
}
html, body, #root { html, body, #root {
height: 100%; height: var(--app-height);
min-height: 0;
} }
body { body {
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif; "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
/* /*
@ -353,6 +365,63 @@
background-color: rgb(var(--c-gray-50)); background-color: rgb(var(--c-gray-50));
color: rgb(var(--c-gray-900)); 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 { @layer utilities {
@ -368,7 +437,20 @@
* 只在窄屏生效桌面用鼠标精度足够而扩大后的命中区在密排的工具栏里 * 只在窄屏生效桌面用鼠标精度足够而扩大后的命中区在密排的工具栏里
* 会互相重叠点一个可能命中隔壁那个 * 会互相重叠点一个可能命中隔壁那个
*/ */
@media (max-width: 767px) { /* 与 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 { .tap {
position: relative; position: relative;
} }

View 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>
);

View File

@ -194,8 +194,8 @@ describe('PermissionPanel 决策', () => {
screen.getByRole('button', { name: new RegExp(name) }).className; screen.getByRole('button', { name: new RegExp(name) }).className;
// 颜色是唯一的视觉提示:点错一次就放行了一个危险操作 // 颜色是唯一的视觉提示:点错一次就放行了一个危险操作
expect(cls('允许')).toContain('bg-green-600'); expect(cls('允许')).toContain('bg-green-700');
expect(cls('approve')).toContain('bg-green-600'); expect(cls('approve')).toContain('bg-green-700');
expect(cls('拒绝')).toContain('text-red-700'); expect(cls('拒绝')).toContain('text-red-700');
// 不在同意词表里的一律按「否」处理 —— 宁可让人多看一眼 // 不在同意词表里的一律按「否」处理 —— 宁可让人多看一眼
expect(cls('算了')).toContain('text-red-700'); expect(cls('算了')).toContain('text-red-700');

View File

@ -13,7 +13,7 @@
* 只有真实渲染才能验的事 * 只有真实渲染才能验的事
* *
* 用法ADMIN_PW=<密码> ADMIN_USER=jianf AGENTMAIL_URL=http://127.0.0.1:8180 \ * 用法ADMIN_PW=<密码> ADMIN_USER=jianf AGENTMAIL_URL=http://127.0.0.1:8180 \
* node web/test/manual/inbox-group-verify.mjs * node client/electron/test/manual/inbox-group-verify.mjs
*/ */
import { openApp, WIDE } from './narrow-probe-helper.mjs'; import { openApp, WIDE } from './narrow-probe-helper.mjs';

View File

@ -9,8 +9,8 @@
* SVG 而不是导航按钮只有这样才能发现 * SVG 而不是导航按钮只有这样才能发现
* *
* 用法 * 用法
* ADMIN_PW=<密码> node web/test/manual/narrow-verify.mjs * ADMIN_PW=<密码> node client/electron/test/manual/narrow-verify.mjs
* ADMIN_PW=<密码> node web/test/manual/wide-regression.mjs * ADMIN_PW=<密码> node client/electron/test/manual/wide-regression.mjs
* *
* 环境变量 * 环境变量
* ADMIN_PW 必填管理员密码 * ADMIN_PW 必填管理员密码
@ -20,9 +20,22 @@
*/ */
const PLAYWRIGHT = process.env.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 { 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 CDP = process.env.CDP_URL || 'http://127.0.0.1:9222';
const APP = (process.env.AGENTMAIL_URL || 'https://mail.jianfgit.xyz').replace(/\/$/, ''); 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 PHONE = { width: 390, height: 844 }; // iPhone 14 Pro
export const SMALL = { width: 320, height: 568 }; // iPhone SE 1 代 export const SMALL = { width: 320, height: 568 }; // iPhone SE 1 代
@ -44,6 +57,35 @@ export async function openApp(viewport = PHONE) {
} }
}); });
// 在不替换、不重启现有 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();
}
});
}
// 不能用 networkidleSSE 是一条永不结束的长连接networkidle 永远不触发 // 不能用 networkidleSSE 是一条永不结束的长连接networkidle 永远不触发
await page.goto(APP + '/', { waitUntil: 'domcontentloaded', timeout: 30000 }); await page.goto(APP + '/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2500); await page.waitForTimeout(2500);

View File

@ -5,7 +5,7 @@
* 抽屉盖住底部导航工具按钮只有 16px 看不见却按得动的归档 * 抽屉盖住底部导航工具按钮只有 16px 看不见却按得动的归档
* 对话树缩进把卡片压成竖条对话树没有返回出口 * 对话树缩进把卡片压成竖条对话树没有返回出口
* *
* 用法ADMIN_PW=<密码> node web/test/manual/narrow-verify.mjs * 用法ADMIN_PW=<密码> node client/electron/test/manual/narrow-verify.mjs
*/ */
import { import {
openApp, openApp,

View 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);

View File

@ -6,7 +6,7 @@
* *
* 用法 * 用法
* ADMIN_USER=jianf ADMIN_PW=... AGENTMAIL_URL=http://127.0.0.1:8180 \ * ADMIN_USER=jianf ADMIN_PW=... AGENTMAIL_URL=http://127.0.0.1:8180 \
* node web/test/manual/theme-verify.mjs * node client/electron/test/manual/theme-verify.mjs
*/ */
import { openApp, WIDE } from './narrow-probe-helper.mjs'; import { openApp, WIDE } from './narrow-probe-helper.mjs';
@ -88,7 +88,10 @@ try {
const r = el.getBoundingClientRect(); const r = el.getBoundingClientRect();
if (r.width < 4 || r.height < 4) continue; if (r.width < 4 || r.height < 4) continue;
const cs = getComputedStyle(el); const cs = getComputedStyle(el);
if (cs.visibility === 'hidden' || cs.opacity === '0') continue; 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 fg = parse(cs.color);
const bg = bgOf(el); const bg = bgOf(el);
if (!fg || !bg) continue; if (!fg || !bg) continue;
@ -112,10 +115,9 @@ try {
return out; return out;
}); });
// 允许少量刻意的低对比装饰(占位符、禁用态) // 禁用态与纯图标已在页面端过滤,其余可见文字都应达到自身 AA 阈值。
const severe = bad.filter(x => x.ratio < 2.5); check(`${mode}: 可见文字全部达到 WCAG AA`, bad.length === 0,
check(`${mode}: 无严重低对比文本(< 2.5:1`, severe.length === 0, bad.slice(0, 4).map(x => `"${x.text}" ${x.ratio}/${x.need}`).join(' | '));
severe.slice(0, 4).map(x => `"${x.text}" ${x.ratio}`).join(' | '));
if (bad.length) { if (bad.length) {
console.log(` ${bad.length} 处低于 AA 阈值,最差 ${Math.min(...bad.map(x => x.ratio))}:1`); console.log(` ${bad.length} 处低于 AA 阈值,最差 ${Math.min(...bad.map(x => x.ratio))}:1`);
// 逐条列出来而不只报个数:不知道是哪一处就没法修 // 逐条列出来而不只报个数:不知道是哪一处就没法修
@ -124,11 +126,6 @@ try {
} }
} }
// 白底白字的典型形态:前景与背景几乎相同
const invisible = bad.filter(x => x.ratio < 1.3);
check(`${mode}: 没有不可见文本(< 1.3:1`, invisible.length === 0,
invisible.slice(0, 3).map(x => `"${x.text}"`).join(' | '));
await page.screenshot({ path: `/tmp/theme-${mode}.png`, fullPage: false }); await page.screenshot({ path: `/tmp/theme-${mode}.png`, fullPage: false });
} }

View File

@ -33,7 +33,7 @@
* *
* 用法 * 用法
* ADMIN_PW=<密码> ADMIN_USER=jianf AGENTMAIL_URL=http://127.0.0.1:8180 \ * ADMIN_PW=<密码> ADMIN_USER=jianf AGENTMAIL_URL=http://127.0.0.1:8180 \
* node web/test/manual/ui-sweep.mjs * node client/electron/test/manual/ui-sweep.mjs
*/ */
import { openApp, WIDE } from './narrow-probe-helper.mjs'; import { openApp, WIDE } from './narrow-probe-helper.mjs';

View File

@ -5,7 +5,7 @@
* - `.tap` 的伪元素命中区桌面密排工具栏里会互相重叠 * - `.tap` 的伪元素命中区桌面密排工具栏里会互相重叠
* - `BackButton`宽屏列表与详情并排返回没有意义 * - `BackButton`宽屏列表与详情并排返回没有意义
* *
* 用法ADMIN_PW=<密码> node web/test/manual/wide-regression.mjs * 用法ADMIN_PW=<密码> node client/electron/test/manual/wide-regression.mjs
*/ */
import { openApp, WIDE } from './narrow-probe-helper.mjs'; import { openApp, WIDE } from './narrow-probe-helper.mjs';

View File

@ -36,23 +36,20 @@ check(
stack.includes('motion-reduce:transition-none') stack.includes('motion-reduce:transition-none')
); );
// 2) 固定宽度的中间栏在窄屏必须让位 // 2) 手机与竖屏平板统一用单栏;三栏只在 Tailwind lg1024px启用
// 断言的是「w-full + md: 前缀的固定宽度」这个形态,不是某个具体像素值 —— const narrowHook = read('../src/hooks/useIsNarrow.ts');
// ContactPanel 的卡片视图用 400px列表视图用 320px。 check('JS 单栏断点与 lg 一致', narrowHook.includes('(max-width: 1023px)'));
for (const f of ['MailList', 'PermissionList', 'ContactPanel', 'CalendarView']) { for (const f of ['MailList', 'PermissionList', 'ContactPanel', 'CalendarView']) {
const src = read(`../src/components/${f}.tsx`); const src = read(`../src/components/${f}.tsx`);
const narrowFullWidth = src.includes('w-full'); const narrowFullWidth = src.includes('w-full');
// 固定宽度只能出现在 md: 断点后面;裸 w-[NNNpx] 会在 375px 屏上挤掉详情。
// 只看 >=200px 的min-w-[16px] 之类的徽标尺寸与布局无关
// (前置 (?<![-\w]) 排除 min-w- / max-w-,它们是约束不是宽度)。
const bareFixed = (src.match(/(?<![-\w])w-\[(\d+)px\]/g) || []).filter(m => { const bareFixed = (src.match(/(?<![-\w])w-\[(\d+)px\]/g) || []).filter(m => {
const px = Number(m.match(/\d+/)[0]); const px = Number(m.match(/\d+/)?.[0] || 0);
return px >= 200 && !src.includes('md:' + m); return px >= 200 && !src.includes('lg:' + m);
}); });
check( check(
`${f} 中间栏窄屏全宽,固定宽度仅在 md: 之后`, `${f} 平板单栏全宽,固定宽度仅在 lg 之后`,
narrowFullWidth && bareFixed.length === 0, narrowFullWidth && bareFixed.length === 0 && !/md:w-\[/.test(src),
bareFixed.length ? `裸固定宽度:${bareFixed.join(', ')}` : '缺少 w-full' bareFixed.length ? `裸固定宽度:${bareFixed.join(', ')}` : '存在 md 固定栏或缺少 w-full'
); );
} }
@ -102,9 +99,9 @@ check(
// .tap 用居中的透明伪元素扩大命中区,视觉尺寸不变。 // .tap 用居中的透明伪元素扩大命中区,视觉尺寸不变。
const css = read('../src/index.css'); const css = read('../src/index.css');
check( check(
'.tap 提供 44px 触摸命中区且只在窄屏生效', '.tap 提供 44px 触摸命中区且覆盖手机与竖屏平板',
/\.tap::after/.test(css) && css.includes('min-width: 44px') && /\.tap::after/.test(css) && css.includes('min-width: 44px') &&
css.includes('min-height: 44px') && /max-width:\s*767px/.test(css) css.includes('min-height: 44px') && /max-width:\s*1023px/.test(css)
); );
// 详情页那排工具按钮是实测最小的一组(「抄送」只有 20x15 // 详情页那排工具按钮是实测最小的一组(「抄送」只有 20x15
const viewSrc = read('../src/components/MailView.tsx'); const viewSrc = read('../src/components/MailView.tsx');
@ -159,6 +156,23 @@ for (const f of ['LoginPage', 'SetupPage']) {
); );
} }
// 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 // 6) 横向内边距在窄屏收窄px-6 在 375px 屏上白吃 48px
const wide = ['MailView', 'ComposePage', 'ThreadView', 'AccountPage', 'AdminUsersPage']; const wide = ['MailView', 'ComposePage', 'ThreadView', 'AccountPage', 'AdminUsersPage'];
for (const f of wide) { for (const f of wide) {

View File

@ -310,9 +310,9 @@ const sRgb = name => {
}; };
// 代码里真正出现过的「实心底 + 白字」组合 // 代码里真正出现过的「实心底 + 白字」组合
const solidPairs = [ const solidPairs = [
['blue-600'], ['blue-700'], ['blue-500'], ['blue-600'], ['blue-700'],
['red-500'], ['red-600'], ['red-700'], ['red-600'], ['red-700'],
['green-600'], ['green-700'], ['orange-700'] ['green-700'], ['orange-700']
]; ];
const onAccentLight = rgbOf(lightBlock, 'on-accent') || const onAccentLight = rgbOf(lightBlock, 'on-accent') ||
(lightBlock.match(/--c-on-accent:\s*(\d+)\s+(\d+)\s+(\d+)/) || []).slice(1).map(Number); (lightBlock.match(/--c-on-accent:\s*(\d+)\s+(\d+)\s+(\d+)/) || []).slice(1).map(Number);
@ -320,14 +320,12 @@ const weakButtons = [];
for (const [name] of solidPairs) { for (const [name] of solidPairs) {
const bg = sRgb(name); const bg = sRgb(name);
if (!bg) { weakButtons.push(`${name}:缺失`); continue; } if (!bg) { weakButtons.push(`${name}:缺失`); continue; }
// 3:1 是 WCAG 对大号/粗体文字的下限。这些按钮文字是 1114px 的 font-medium // 这些按钮文字只有 914px,按普通文字执行 WCAG AA 4.5:1。
// 严格说该要 4.5 —— 但 Tailwind 官方 600 档普遍在 34.5 之间green-600 = 3.05
// 收紧到 4.5 就得偏离官方色值。取 3.0 作为门槛并把偏低的记在这里。
const r = contrast(onAccentLight, bg); const r = contrast(onAccentLight, bg);
if (r < 3.0) weakButtons.push(`${name}:${r.toFixed(2)}`); if (r < 4.5) weakButtons.push(`${name}:${r.toFixed(2)}`);
} }
check( check(
'白字在实心按钮底上达到 3:1', '白字在实心按钮底上达到 4.5:1',
weakButtons.length === 0, weakButtons.length === 0,
weakButtons.join(' ') weakButtons.join(' ')
); );
@ -360,5 +358,38 @@ for (const f of chromeFiles) {
} }
check('框架组件已全部改用 chrome 色阶', slateLeft.length === 0, slateLeft.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 承担 912px 元数据与 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} 失败` : ''}`); console.log(`\n主题:${pass} 通过${fail ? `${fail} 失败` : ''}`);
process.exit(fail ? 1 : 0); process.exit(fail ? 1 : 0);

View File

@ -12,6 +12,10 @@ import react from '@vitejs/plugin-react';
* DOM vitest * DOM vitest
*/ */
export default defineConfig({ export default defineConfig({
// 测试必须使用含 act() 的 React 开发构建;宿主进程可能继承 NODE_ENV=production。
define: {
'process.env.NODE_ENV': JSON.stringify('test')
},
plugins: [react()], plugins: [react()],
test: { test: {
include: ['test/components/**/*.test.tsx'], include: ['test/components/**/*.test.tsx'],

12
client/harmony/.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
/node_modules
/oh_modules
/local.properties
/.idea
**/build
/.hvigor
.cxx
/.clangd
/.clang-format
/.clang-tidy
**/.test
/.appanalyzer

View File

@ -0,0 +1,10 @@
{
"app": {
"bundleName": "com.agentmail.harmony",
"vendor": "example",
"versionCode": 1000000,
"versionName": "1.0.0",
"icon": "$media:layered_image",
"label": "$string:app_name"
}
}

View File

@ -0,0 +1,8 @@
{
"string": [
{
"name": "app_name",
"value": "AgentMailHarmony"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 B

View File

@ -0,0 +1,7 @@
{
"layered-image":
{
"background" : "$media:background",
"foreground" : "$media:foreground"
}
}

View File

@ -0,0 +1,42 @@
{
"app": {
"signingConfigs": [],
"products": [
{
"name": "default",
"signingConfig": "default",
"targetSdkVersion": "6.1.0(23)",
"compatibleSdkVersion": "6.1.0(23)",
"runtimeOS": "HarmonyOS",
"buildOption": {
"strictMode": {
"caseSensitiveCheck": true,
"useNormalizedOHMUrl": true
}
}
}
],
"buildModeSet": [
{
"name": "debug",
},
{
"name": "release"
}
]
},
"modules": [
{
"name": "entry",
"srcPath": "./entry",
"targets": [
{
"name": "default",
"applyToProducts": [
"default"
]
}
]
}
]
}

View File

@ -0,0 +1,32 @@
{
"files": [
"**/*.ets"
],
"ignore": [
"**/src/ohosTest/**/*",
"**/src/test/**/*",
"**/src/mock/**/*",
"**/node_modules/**/*",
"**/oh_modules/**/*",
"**/build/**/*",
"**/.preview/**/*"
],
"ruleSet": [
"plugin:@performance/recommended",
"plugin:@typescript-eslint/recommended"
],
"rules": {
"@security/no-unsafe-aes": "error",
"@security/no-unsafe-hash": "error",
"@security/no-unsafe-mac": "warn",
"@security/no-unsafe-dh": "error",
"@security/no-unsafe-dsa": "error",
"@security/no-unsafe-ecdsa": "error",
"@security/no-unsafe-rsa-encrypt": "error",
"@security/no-unsafe-rsa-sign": "error",
"@security/no-unsafe-rsa-key": "error",
"@security/no-unsafe-dsa-key": "error",
"@security/no-unsafe-dh-key": "error",
"@security/no-unsafe-3des": "error"
}
}

6
client/harmony/entry/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
/node_modules
/oh_modules
/.preview
/build
/.cxx
/.test

View File

@ -0,0 +1,33 @@
{
"apiType": "stageMode",
"buildOption": {
"resOptions": {
"copyCodeResource": {
"enable": false
}
}
},
"buildOptionSet": [
{
"name": "release",
"arkOptions": {
"obfuscation": {
"ruleOptions": {
"enable": false,
"files": [
"./obfuscation-rules.txt"
]
}
}
}
},
],
"targets": [
{
"name": "default"
},
{
"name": "ohosTest",
}
]
}

View File

@ -0,0 +1,7 @@
// @ts-nocheck Template file, only used when copied into a project directory
import { hapTasks } from '@ohos/hvigor-ohos-plugin';
export default {
system: hapTasks /* Built-in plugin of Hvigor. It cannot be modified. */,
plugins: [] /* Custom plugin to extend the functionality of Hvigor. */,
};

View File

@ -0,0 +1,20 @@
# Define project specific obfuscation rules here.
# You can include the obfuscation configuration files in the current module's build-profile.json5.
# Obfuscation options:
# -disable-obfuscation: disable all obfuscations
# -enable-property-obfuscation: obfuscate the property names
# -enable-toplevel-obfuscation: obfuscate the names in the global scope
# -compact: remove unnecessary blank spaces and all line feeds
# -remove-log: remove all console.* statements
# -print-namecache: print the name cache that contains the mapping from the old names to new names
# -apply-namecache: reuse the given cache file
# Keep options:
# -keep-property-name: specifies property names that you want to keep
# -keep-global-name: specifies names that you want to keep in the global scope
-enable-property-obfuscation
-enable-toplevel-obfuscation
-enable-filename-obfuscation
-enable-export-obfuscation

View File

@ -0,0 +1,10 @@
{
"name": "entry",
"version": "1.0.0",
"description": "Please describe the basic information.",
"main": "",
"author": "",
"license": "",
"dependencies": {}
}

View File

@ -0,0 +1,191 @@
/*
* AgentMail 鸿蒙客户端 — 多账号管理器
* 支持多账号存储、切换、删除
* 每个账号存储: server, username, token, displayName
*/
import { preferences } from '@kit.ArkData';
import { hilog } from '@kit.PerformanceAnalysisKit';
const DOMAIN = 0x0001;
const TAG = 'AccountManager';
const PREF_NAME = 'agentmail_accounts';
const KEY_ACCOUNTS = 'accounts_json';
const KEY_ACTIVE = 'active_account_id';
/** 单个账号信息 */
export class AccountInfo {
id: string = '';
server: string = '';
username: string = '';
token: string = '';
displayName: string = '';
createdAt: number = 0;
}
export class AccountManager {
private static instance: AccountManager | null = null;
private context: Context;
private accounts: AccountInfo[] = [];
private activeId: string = '';
private loaded: boolean = false;
static getInstance(context: Context): AccountManager {
if (AccountManager.instance === null || AccountManager.instance.context !== context) {
AccountManager.instance = new AccountManager(context);
}
return AccountManager.instance;
}
private constructor(context: Context) {
this.context = context;
}
/** 从 preferences 加载账号列表 */
async load(): Promise<void> {
if (this.loaded) {
return;
}
try {
const pref = await preferences.getPreferences(this.context, PREF_NAME);
const accountsJson: string = pref.getSync(KEY_ACCOUNTS, '[]') as string;
this.accounts = JSON.parse(accountsJson) as AccountInfo[];
this.activeId = pref.getSync(KEY_ACTIVE, '') as string;
// 如果有账号但没有活跃账号,默认选第一个
if (this.accounts.length > 0 && this.activeId.length === 0) {
this.activeId = this.accounts[0].id;
}
hilog.info(DOMAIN, TAG, 'loaded %{public}d accounts, active: %{public}s', this.accounts.length, this.activeId);
} catch (e) {
this.accounts = [];
this.activeId = '';
}
this.loaded = true;
}
/** 持久化账号列表 */
private async persist(): Promise<void> {
try {
const pref = await preferences.getPreferences(this.context, PREF_NAME);
pref.putSync(KEY_ACCOUNTS, JSON.stringify(this.accounts));
pref.putSync(KEY_ACTIVE, this.activeId);
await pref.flush();
} catch (e) {
hilog.error(DOMAIN, TAG, 'persist failed');
}
}
/** 获取所有账号。返回副本,避免页面直接改动内部持久化数组。 */
getAccounts(): AccountInfo[] {
return this.accounts.slice();
}
/** 获取当前活跃账号 */
getActiveAccount(): AccountInfo | null {
for (let i = 0; i < this.accounts.length; i++) {
if (this.accounts[i].id === this.activeId) {
return this.accounts[i];
}
}
if (this.accounts.length > 0) {
return this.accounts[0];
}
return null;
}
/** 获取当前活跃账号 ID */
getActiveId(): string {
return this.activeId;
}
/** 按 ID 获取账号;路由携带来源账号时使用。 */
getAccount(accountId: string): AccountInfo | null {
for (let i = 0; i < this.accounts.length; i++) {
if (this.accounts[i].id === accountId) {
return this.accounts[i];
}
}
return null;
}
/** 添加新账号 */
async addAccount(server: string, username: string, token: string, displayName: string): Promise<AccountInfo> {
const account: AccountInfo = new AccountInfo();
account.id = this.generateId();
account.server = server;
account.username = username;
account.token = token;
account.displayName = displayName.length > 0 ? displayName : username;
account.createdAt = Date.now();
this.accounts.push(account);
// 如果是第一个账号,自动设为活跃
if (this.accounts.length === 1) {
this.activeId = account.id;
}
await this.persist();
hilog.info(DOMAIN, TAG, 'added account: %{public}s', account.username);
return account;
}
/** 删除账号 */
async removeAccount(accountId: string): Promise<boolean> {
const idx: number = this.findIndex(accountId);
if (idx < 0) {
return false;
}
this.accounts.splice(idx, 1);
// 如果删除的是活跃账号,切换到第一个
if (this.activeId === accountId) {
this.activeId = this.accounts.length > 0 ? this.accounts[0].id : '';
}
await this.persist();
hilog.info(DOMAIN, TAG, 'removed account: %{public}s', accountId);
return true;
}
/** 切换活跃账号 */
async switchAccount(accountId: string): Promise<boolean> {
const idx: number = this.findIndex(accountId);
if (idx < 0) {
return false;
}
this.activeId = accountId;
await this.persist();
hilog.info(DOMAIN, TAG, 'switched to: %{public}s', accountId);
return true;
}
/** 更新账号信息(如 token 过期重新登录) */
async updateAccount(accountId: string, token: string, displayName: string): Promise<boolean> {
const idx: number = this.findIndex(accountId);
if (idx < 0) {
return false;
}
this.accounts[idx].token = token;
if (displayName.length > 0) {
this.accounts[idx].displayName = displayName;
}
await this.persist();
return true;
}
/** 获取账号数量 */
getCount(): number {
return this.accounts.length;
}
private findIndex(accountId: string): number {
for (let i = 0; i < this.accounts.length; i++) {
if (this.accounts[i].id === accountId) {
return i;
}
}
return -1;
}
private generateId(): string {
const now: number = Date.now();
const rand: number = Math.floor(Math.random() * 10000);
return 'acct_' + now.toString() + '_' + rand.toString();
}
}

View File

@ -0,0 +1,291 @@
/*
* AgentMail 鸿蒙客户端 — 统一 API 客户端
* 对应 WebUI src/api/client.tsbase + headers + 错误归一化 + 401 统一回落登录
*/
import { http } from '@kit.NetworkKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { DEFAULT_API_BASE, PREF_KEY_API_BASE, PREF_KEY_TOKEN } from '../common/Config';
import { preferences } from '@kit.ArkData';
const DOMAIN = 0x0001;
const TAG = 'AgentMailClient';
/** 统一错误类型code = HTTP 状态码message = 服务端中文文案 */
export class ApiError extends Error {
code: number = 0;
message: string = '';
constructor(code: number, message: string) {
super(message);
this.code = code;
this.message = message;
}
}
/** 空响应 2xx 的占位类型 */
export class EmptyResult {
ok: boolean = true;
}
/** 请求选项 */
export class RequestOptions {
method: string = 'GET';
path: string = '';
body: string = '';
query: string = '';
useKeyAuth: boolean = true;
}
export class ApiClient {
private static instance: ApiClient | null = null;
private apiBase: string = DEFAULT_API_BASE;
private token: string = '';
private context: Context;
/** 复用的 HTTP 请求实例:保持 Cookie 会话(登录后跨请求有效) */
private httpRequest: http.HttpRequest | null = null;
static getInstance(context: Context): ApiClient {
if (ApiClient.instance === null || ApiClient.instance.context !== context) {
if (ApiClient.instance !== null && ApiClient.instance.context !== context) {
// 不同 context 重建(测试环境),否则复用
}
ApiClient.instance = new ApiClient(context);
}
return ApiClient.instance;
}
constructor(context: Context) {
this.context = context;
}
/** 初始化:从 preferences 读 apiBase 与 token */
async init(): Promise<void> {
try {
const pref = await preferences.getPreferences(this.context, 'agentmail');
this.apiBase = pref.getSync(PREF_KEY_API_BASE, DEFAULT_API_BASE) as string;
this.token = pref.getSync(PREF_KEY_TOKEN, '') as string;
} catch (e) {
this.apiBase = DEFAULT_API_BASE;
this.token = '';
}
}
getBase(): string {
return this.apiBase;
}
setBase(base: string): void {
this.apiBase = base;
}
getToken(): string {
return this.token;
}
setToken(token: string): void {
this.token = token;
}
/** 持久化凭证 */
async persistToken(token: string): Promise<void> {
this.token = token;
try {
const pref = await preferences.getPreferences(this.context, 'agentmail');
pref.putSync(PREF_KEY_TOKEN, token);
await pref.flush();
} catch (e) {
// 持久化失败不阻断登录
}
}
async persistBase(base: string): Promise<void> {
this.apiBase = base;
try {
const pref = await preferences.getPreferences(this.context, 'agentmail');
pref.putSync(PREF_KEY_API_BASE, base);
await pref.flush();
} catch (e) {
// 忽略
}
}
/** 统一请求入口 */
async request<T>(opts: RequestOptions): Promise<T> {
const url: string = this.apiBase + opts.path + (opts.query.length > 0 ? '?' + opts.query : '');
// 复用会话级 http 实例,保住 Cookielogin 后 set-cookie 才能用于后续请求)
let httpRequest: http.HttpRequest;
if (this.httpRequest === null) {
httpRequest = http.createHttp();
this.httpRequest = httpRequest;
} else {
httpRequest = this.httpRequest;
}
try {
const header: Record<string, string> = {
'Content-Type': 'application/json'
};
if (this.token.length > 0 && opts.useKeyAuth) {
header['Authorization'] = 'Bearer ' + this.token;
}
hilog.info(DOMAIN, TAG, '→ %{public}s %{public}s', opts.method, url);
const response = await httpRequest.request(url, {
method: opts.method as http.RequestMethod,
header: header,
extraData: opts.body.length > 0 ? opts.body : undefined,
connectTimeout: 15000,
readTimeout: 30000
});
const code = response.responseCode;
const rawText = response.result as string;
if (code >= 200 && code < 300) {
if (rawText.length === 0) {
const empty = new EmptyResult();
return empty as T;
}
return JSON.parse(rawText) as T;
}
// 错误归一化:从服务端 {"error": "..."} 取文案
let message: string = 'HTTP ' + code;
try {
const parsed = JSON.parse(rawText) as Record<string, string>;
if (parsed['error'] !== undefined) {
message = parsed['error'];
}
} catch (e) {
message = rawText.length > 0 ? rawText : ('HTTP ' + code);
}
if (code === 401) {
// 清除本地凭证,交由登录页处理
this.clearAuth();
}
throw new ApiError(code, message);
} catch (e) {
if (e instanceof ApiError) {
throw e as ApiError;
}
const be = e as BusinessError;
const msg: string = be.message !== undefined ? be.message : '网络错误';
hilog.error(DOMAIN, TAG, '← %{public}s failed: %{public}s', url, msg);
throw new ApiError(0, msg);
}
// 不复用销毁:会话级实例保留 Cookie
}
/** GET 便捷 */
async get<T>(path: string, query?: string): Promise<T> {
const opts = new RequestOptions();
opts.method = 'GET';
opts.path = path;
opts.query = query ?? '';
return this.request<T>(opts);
}
/** POST 便捷JSON body */
async post<T>(path: string, bodyObj: Object, useKeyAuth?: boolean): Promise<T> {
const opts = new RequestOptions();
opts.method = 'POST';
opts.path = path;
opts.body = JSON.stringify(bodyObj);
opts.useKeyAuth = useKeyAuth ?? true;
return this.request<T>(opts);
}
/** PUT 便捷 */
async put<T>(path: string, bodyObj: Object): Promise<T> {
const opts = new RequestOptions();
opts.method = 'PUT';
opts.path = path;
opts.body = JSON.stringify(bodyObj);
return this.request<T>(opts);
}
/** DELETE 便捷 */
async del<T>(path: string): Promise<T> {
const opts = new RequestOptions();
opts.method = 'DELETE';
opts.path = path;
return this.request<T>(opts);
}
/** 上传文件multipart/form-data→ attachment_id */
async uploadFile(path: string, filePath: string, fileName: string): Promise<string> {
const url: string = this.apiBase + path;
const httpRequest = http.createHttp();
try {
const header: Record<string, string> = {};
const token: string = this.token;
if (token.length > 0) {
header['Authorization'] = 'Bearer ' + token;
}
const multiFormData: http.MultiFormData = {
name: 'file',
contentType: 'application/octet-stream',
remoteFileName: fileName,
filePath: filePath
};
const options: http.HttpRequestOptions = {
method: http.RequestMethod.POST,
header: header,
multiFormDataList: [multiFormData],
connectTimeout: 30000,
readTimeout: 60000
};
hilog.info(DOMAIN, TAG, '→ UPLOAD %{public}s', url);
const response = await httpRequest.request(url, options);
const code: number = response.responseCode;
const rawText: string = response.result as string;
if (code >= 200 && code < 300) {
// 服务端返回 {"attachment_id": "..."} 或直接返回 id 字符串
if (rawText.length === 0) {
return '';
}
try {
const parsed = JSON.parse(rawText) as Record<string, string>;
if (parsed['attachment_id'] !== undefined) {
return parsed['attachment_id'];
}
if (parsed['id'] !== undefined) {
return parsed['id'];
}
} catch (e) {
// 可能直接返回 id 字符串
}
return rawText;
}
throw new ApiError(code, rawText.length > 0 ? rawText : 'Upload failed');
} catch (e) {
if (e instanceof ApiError) {
throw e;
}
const be = e as BusinessError;
throw new ApiError(0, be.message !== undefined ? be.message : 'Upload error');
} finally {
httpRequest.destroy();
}
}
/** 清除本地认证态401 时调用) */
clearAuth(): void {
this.token = '';
try {
const pref = preferences.getPreferencesSync(this.context, { name: 'agentmail' });
pref.putSync(PREF_KEY_TOKEN, '');
pref.flush();
} catch (e) {
// 忽略
}
}
}

View File

@ -0,0 +1,106 @@
/*
* AgentMail 鸿蒙客户端 — 认证 API
* POST /auth/login / logout / GET /auth/me / POST /me/keys
*/
import { ApiClient, ApiError } from './ApiClient';
import { Me, UserKey } from '../model/Models';
/** 登录请求体 */
export class LoginPayload {
username: string = '';
password: string = '';
}
/** 创建密钥请求体 */
export class CreateKeyPayload {
label: string = '';
key_type: string = 'permanent';
expires_hours: number = 0;
}
/** 登录响应(含用户) */
export class MeResponse {
user: Me = new Me();
}
/** 创建密钥响应 */
export class CreateKeyResponse {
key: UserKey = new UserKey();
}
/** 密钥列表响应 */
export class KeyListResponse {
keys: UserKey[] = [];
}
/** 空请求体logout 等无 body 场景) */
export class EmptyPayload {
empty: boolean = true;
}
export class AuthApi {
private client: ApiClient;
constructor(client: ApiClient) {
this.client = client;
}
/** 账号密码登录Cookie 模式) */
async login(username: string, password: string): Promise<Me> {
const payload: LoginPayload = new LoginPayload();
payload.username = username;
payload.password = password;
// Cookie 由 http 模块自动管理;此处仍拿回 user
const resp = await this.client.post<MeResponse>('/auth/login', payload, false);
return resp.user;
}
/** 用户密钥登录Bearer 模式):直接用 key 调 /auth/me */
async loginWithKey(key: string): Promise<Me> {
this.client.setToken(key);
try {
const resp = await this.client.get<MeResponse>('/auth/me');
await this.client.persistToken(key);
return resp.user;
} catch (e) {
this.client.clearAuth();
throw e as ApiError;
}
}
/** 当前用户 */
async me(): Promise<Me> {
const resp = await this.client.get<MeResponse>('/auth/me');
return resp.user;
}
/** 退出登录 */
async logout(): Promise<void> {
try {
const empty: EmptyPayload = new EmptyPayload();
await this.client.post<EmptyPayload>('/auth/logout', empty);
} catch (e) {
// 忽略退出失败,本地清 token
}
this.client.clearAuth();
}
/** 创建客户端密钥 */
async createKey(label: string): Promise<UserKey> {
const payload: CreateKeyPayload = new CreateKeyPayload();
payload.label = label;
const resp = await this.client.post<CreateKeyResponse>('/me/keys', payload);
return resp.key;
}
/** 我的密钥列表 */
async listKeys(): Promise<UserKey[]> {
const resp = await this.client.get<KeyListResponse>('/me/keys');
return resp.keys;
}
/** 吊销密钥 */
async revokeKey(keyId: string): Promise<void> {
await this.client.del<Object>('/me/keys/' + keyId);
}
}

View File

@ -0,0 +1,155 @@
/*
* AgentMail 鸿蒙客户端 — 邮件与会话 API
* GET /me/inbox, /me/sessions, /me/contacts, /me/mail/{id}, /sessions/{id}/thread
* POST /me/mail/send, /me/mail/{id}/forward
* PUT /sessions/{id}/permission, /sessions/{id}/alias, /sessions/{id}/budget
*/
import { ApiClient } from './ApiClient';
import { MailSummary, Session, Contact, MailDetail, ThreadResponse, AttachmentInfo, SendMailRequest, SendMailResult } from '../model/Models';
/** 收件箱响应 */
export class InboxResponse {
mails: MailSummary[] = [];
total: number = 0;
unread: number = 0;
}
/** 会话列表响应 */
export class SessionListResponse {
sessions: Session[] = [];
total: number = 0;
}
/** 联系人列表响应 */
export class ContactListResponse {
contacts: Contact[] = [];
total: number = 0;
}
/** 邮件详情响应 */
export class MailDetailResponse {
mail: MailDetail = new MailDetail();
}
/** 对话树响应 */
export class ThreadApiResponse {
thread: ThreadResponse = new ThreadResponse();
}
/** 发信响应 */
export class SendMailResponse {
result: SendMailResult = new SendMailResult();
}
/** 附件列表响应 */
export class AttachmentListResponse {
attachments: AttachmentInfo[] = [];
}
/** 地址补全响应 */
export class AddressSuggestionResponse {
suggestions: string[] = [];
}
/** 改权限请求体 */
export class PermissionModePayload {
permission_mode: string = 'workspace';
}
/** 改别名请求体 */
export class AliasPayload {
alias: string = '';
}
/** 改预算请求体 */
export class BudgetPayload {
max_rounds: number = 0;
}
export class MailApi {
private client: ApiClient;
constructor(client: ApiClient) {
this.client = client;
}
/** 收件箱status + limit */
async inbox(status: string, limit: number): Promise<InboxResponse> {
const query: string = 'status=' + status + '&limit=' + limit;
return this.client.get<InboxResponse>('/me/mail/inbox', query);
}
/** 会话列表 */
async sessions(): Promise<SessionListResponse> {
return this.client.get<SessionListResponse>('/me/sessions');
}
/** 联系人列表 */
async contacts(): Promise<ContactListResponse> {
return this.client.get<ContactListResponse>('/contacts');
}
/** 邮件详情API 返回裸对象) */
async mailDetail(mailId: string): Promise<MailDetail> {
return this.client.get<MailDetail>('/mail/' + mailId);
}
/** 对话树 */
async thread(mailId: string, dir?: string, limit?: number): Promise<ThreadApiResponse> {
let query: string = '';
const parts: string[] = [];
if (dir !== undefined) {
parts.push('dir=' + dir);
}
if (limit !== undefined) {
parts.push('limit=' + limit);
}
if (parts.length > 0) {
query = parts.join('&');
}
return this.client.get<ThreadApiResponse>('/mail/' + mailId + '/thread', query);
}
/** 发信 */
async send(req: SendMailRequest): Promise<SendMailResponse> {
return this.client.post<SendMailResponse>('/me/mail/send', req);
}
/** 转发 */
async forward(mailId: string, req: SendMailRequest): Promise<SendMailResponse> {
return this.client.post<SendMailResponse>('/me/mail/' + mailId + '/forward', req);
}
/** 改权限档位 */
async setPermissionMode(sessionId: string, mode: string): Promise<void> {
const payload: PermissionModePayload = new PermissionModePayload();
payload.permission_mode = mode;
await this.client.put<Object>('/sessions/' + sessionId + '/permission', payload);
}
/** 改会话别名 */
async setAlias(sessionId: string, alias: string): Promise<void> {
const payload: AliasPayload = new AliasPayload();
payload.alias = alias;
await this.client.put<Object>('/sessions/' + sessionId + '/alias', payload);
}
/** 改预算 */
async setBudget(sessionId: string, maxRounds: number): Promise<void> {
const payload: BudgetPayload = new BudgetPayload();
payload.max_rounds = maxRounds;
await this.client.put<Object>('/sessions/' + sessionId + '/budget', payload);
}
/** 归档联系人 */
async archiveContact(name: string, path: string): Promise<void> {
await this.client.del<Object>('/me/contacts/' + name + '/' + path);
}
/** 上传附件multipart/form-data字段名 file→ attachment_id */
async uploadAttachment(filePath: string, fileName: string): Promise<string> {
const resp = await this.client.uploadFile('/me/attachments', filePath, fileName);
return resp;
}
}

View File

@ -0,0 +1,308 @@
/*
* AgentMail 鸿蒙客户端 — SSE 多账号实时推送服务
* 每个账号各建一条 SSE 连接(各带自己的 user_key
* AccountManager 维护连接集合,按 accountId 分发事件
*/
import { http } from '@kit.NetworkKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { AccountManager, AccountInfo } from './AccountManager';
const DOMAIN = 0x0001;
const TAG = 'SseService';
/** SSE 事件数据 */
export class SseEvent {
type: string = '';
data: string = '';
accountId: string = '';
}
/** SSE 回调 */
export type SseListener = (event: SseEvent) => void;
/** SSE 连接状态 */
export type SseStatus = 'disconnected' | 'connecting' | 'connected';
/** SSE 状态回调 */
export type SseStatusListener = (status: SseStatus) => void;
/** 单个账号的 SSE 连接状态(纯数据) */
export class SseConnection {
accountId: string = '';
server: string = '';
token: string = '';
httpRequest: http.HttpRequest | null = null;
listeners: SseListener[] = [];
status: SseStatus = 'disconnected';
reconnectTimer: number = 0;
buffer: string = '';
connected: boolean = false;
}
export class SseService {
private static instance: SseService | null = null;
private connections: Map<string, SseConnection> = new Map();
private globalListeners: SseListener[] = [];
private globalStatusListeners: SseStatusListener[] = [];
static getInstance(): SseService {
if (SseService.instance === null) {
SseService.instance = new SseService();
}
return SseService.instance;
}
private constructor() {}
/** 添加全局事件监听(所有账号的事件都会收到) */
addListener(listener: SseListener): void {
this.globalListeners.push(listener);
}
/** 移除全局事件监听 */
removeListener(listener: SseListener): void {
const idx: number = this.globalListeners.indexOf(listener);
if (idx >= 0) {
this.globalListeners.splice(idx, 1);
}
}
/** 添加全局状态监听 */
addStatusListener(listener: SseStatusListener): void {
this.globalStatusListeners.push(listener);
}
/** 移除全局状态监听 */
removeStatusListener(listener: SseStatusListener): void {
const idx: number = this.globalStatusListeners.indexOf(listener);
if (idx >= 0) {
this.globalStatusListeners.splice(idx, 1);
}
}
/** 为指定账号建立 SSE 连接 */
connectForAccount(accountId: string, server: string, token: string): void {
let conn: SseConnection | undefined = this.connections.get(accountId);
if (conn !== undefined && conn.connected) {
hilog.info(DOMAIN, TAG, 'account %{public}s already connected', accountId);
return;
}
if (conn === undefined) {
conn = new SseConnection();
conn.accountId = accountId;
this.connections.set(accountId, conn);
}
conn.server = server;
conn.token = token;
conn.connected = true;
this.setConnStatus(conn, 'connecting');
this.doConnect(conn);
}
/** 断开指定账号的 SSE 连接 */
disconnectAccount(accountId: string): void {
const conn: SseConnection | undefined = this.connections.get(accountId);
if (conn === undefined) {
return;
}
conn.connected = false;
if (conn.reconnectTimer !== 0) {
clearTimeout(conn.reconnectTimer);
conn.reconnectTimer = 0;
}
if (conn.httpRequest !== null) {
conn.httpRequest.destroy();
conn.httpRequest = null;
}
this.setConnStatus(conn, 'disconnected');
this.connections.delete(accountId);
}
/** 断开所有连接 */
disconnectAll(): void {
const keys: string[] = [];
this.connections.forEach((_conn: SseConnection, key: string) => {
keys.push(key);
});
for (let i = 0; i < keys.length; i++) {
this.disconnectAccount(keys[i]);
}
}
/** 根据 AccountManager 连接所有账号 */
async connectAll(acctMgr: AccountManager): Promise<void> {
await acctMgr.load();
const accounts: AccountInfo[] = acctMgr.getAccounts();
for (let i = 0; i < accounts.length; i++) {
const acct: AccountInfo = accounts[i];
this.connectForAccount(acct.id, acct.server, acct.token);
}
}
/** 获取指定账号的连接状态 */
getStatusForAccount(accountId: string): SseStatus {
const conn: SseConnection | undefined = this.connections.get(accountId);
if (conn === undefined) {
return 'disconnected';
}
return conn.status;
}
/** 给指定账号添加事件监听 */
addListenerForAccount(accountId: string, listener: SseListener): void {
let conn: SseConnection | undefined = this.connections.get(accountId);
if (conn === undefined) {
conn = new SseConnection();
conn.accountId = accountId;
this.connections.set(accountId, conn);
}
conn.listeners.push(listener);
}
/** 移除指定账号的事件监听 */
removeListenerForAccount(accountId: string, listener: SseListener): void {
const conn: SseConnection | undefined = this.connections.get(accountId);
if (conn !== undefined) {
const idx: number = conn.listeners.indexOf(listener);
if (idx >= 0) {
conn.listeners.splice(idx, 1);
}
}
}
private setConnStatus(conn: SseConnection, status: SseStatus): void {
if (conn.status !== status) {
conn.status = status;
hilog.info(DOMAIN, TAG, 'status[%{public}s]: %{public}s', conn.accountId, status);
for (let i = 0; i < this.globalStatusListeners.length; i++) {
this.globalStatusListeners[i](status);
}
}
}
private doConnect(conn: SseConnection): void {
if (!conn.connected) {
return;
}
const httpRequest = http.createHttp();
conn.httpRequest = httpRequest;
const url: string = conn.server + '/events/stream';
const header: Record<string, string> = {};
if (conn.token.length > 0) {
header['Authorization'] = 'Bearer ' + conn.token;
}
hilog.info(DOMAIN, TAG, 'connecting account %{public}s to %{public}s', conn.accountId, url);
httpRequest.on('dataReceive', (data: ArrayBuffer) => {
const text: string = this.arrayBufferToString(data);
conn.buffer += text;
this.processBuffer(conn);
});
httpRequest.on('dataEnd', () => {
hilog.info(DOMAIN, TAG, 'dataEnd for account %{public}s', conn.accountId);
this.setConnStatus(conn, 'disconnected');
this.scheduleReconnect(conn);
});
httpRequest.on('headersReceive', (_headers: Object) => {
hilog.info(DOMAIN, TAG, 'headers received for account %{public}s', conn.accountId);
});
const options: http.HttpRequestOptions = {
method: http.RequestMethod.GET,
header: header,
connectTimeout: 10000,
readTimeout: 0
};
httpRequest.requestInStream(url, options, (err: BusinessError, code: number) => {
if (err !== undefined && err !== null) {
hilog.error(DOMAIN, TAG, 'requestInStream error account %{public}s: %{public}s', conn.accountId, err.message);
this.setConnStatus(conn, 'disconnected');
this.scheduleReconnect(conn);
return;
}
hilog.info(DOMAIN, TAG, 'requestInStream code=%{public}d account=%{public}s', code, conn.accountId);
if (code === 200) {
this.setConnStatus(conn, 'connected');
} else {
hilog.error(DOMAIN, TAG, 'SSE failed code=%{public}d account=%{public}s', code, conn.accountId);
this.setConnStatus(conn, 'disconnected');
this.scheduleReconnect(conn);
}
});
}
private processBuffer(conn: SseConnection): void {
const lines: string[] = conn.buffer.split('\n');
conn.buffer = lines.pop() ?? '';
let eventType: string = '';
let eventData: string = '';
for (let i = 0; i < lines.length; i++) {
const line: string = lines[i];
if (line.length === 0) {
if (eventType.length > 0 || eventData.length > 0) {
const event: SseEvent = new SseEvent();
event.type = eventType.length > 0 ? eventType : 'message';
event.data = eventData;
event.accountId = conn.accountId;
this.dispatchEvent(conn, event);
}
eventType = '';
eventData = '';
} else if (line.startsWith('event:')) {
eventType = line.substring(6).trim();
} else if (line.startsWith('data:')) {
const newData: string = line.substring(5).trim();
if (eventData.length > 0) {
eventData += '\n' + newData;
} else {
eventData = newData;
}
}
}
}
private dispatchEvent(conn: SseConnection, event: SseEvent): void {
hilog.info(DOMAIN, TAG, 'event[%{public}s]: %{public}s data: %{public}s', conn.accountId, event.type,
event.data.substring(0, 100));
// 分发给账号级监听
for (let i = 0; i < conn.listeners.length; i++) {
conn.listeners[i](event);
}
// 分发给全局监听
for (let i = 0; i < this.globalListeners.length; i++) {
this.globalListeners[i](event);
}
}
private scheduleReconnect(conn: SseConnection): void {
if (!conn.connected) {
return;
}
const timer: number | undefined = setTimeout(() => {
conn.reconnectTimer = 0;
this.doConnect(conn);
}, 3000);
conn.reconnectTimer = timer ?? 0;
}
private arrayBufferToString(buffer: ArrayBuffer): string {
const uint8Array: Uint8Array = new Uint8Array(buffer);
let result: string = '';
for (let i = 0; i < uint8Array.length; i++) {
result += String.fromCharCode(uint8Array[i]);
}
return result;
}
}

View File

@ -0,0 +1,15 @@
/*
* AgentMail 鸿蒙客户端 — 全局配置
* apiBase 可运行时修改(设置页/登录页persist 到 preferences
*/
/** 默认联调 Gatewaypi 提供GUI 联调专用) */
export const DEFAULT_API_BASE: string = 'http://192.168.2.60:8180/api/v1';
/** 模拟器 NAT 访问宿主机地址(备用,若 LAN 直连不通) */
export const EMULATOR_HOST_BASE: string = 'http://10.0.2.2:8180/api/v1';
/** preferences 存储键 */
export const PREF_KEY_API_BASE: string = 'api_base';
export const PREF_KEY_TOKEN: string = 'user_token';
export const PREF_KEY_USERNAME: string = 'username';

Some files were not shown because too many files have changed in this diff Show More