chore: directory migration - gateway→server, web→client/electron
This commit is contained in:
18
.gitignore
vendored
18
.gitignore
vendored
@ -1,31 +1,31 @@
|
||||
# ---- 依赖与构建产物 ----
|
||||
#
|
||||
# 前端只在**构建期**用到 npm:`npm run build` 出的 dist/ 被 cp 进
|
||||
# gateway/internal/static/static/ 再由 go:embed 编进二进制。
|
||||
# server/internal/static/static/ 再由 go:embed 编进二进制。
|
||||
# 部署机上没有 node,产物就是「一个二进制 + 一个 .db 文件」。
|
||||
# 因此这三样都不进版本库:装依赖与构建都能从 package-lock.json 复现。
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
client/electron/node_modules/
|
||||
client/electron/dist/
|
||||
plugins/*/node_modules/
|
||||
|
||||
# dsh-mail-bridge 是 TypeScript 写的,dist/ 是 tsc 的产物。
|
||||
# 与 web/dist 同一个理由:装依赖与构建都能从 package-lock.json 复现,
|
||||
# 与 client/electron/dist 同一个理由:装依赖与构建都能从 package-lock.json 复现,
|
||||
# 产物不进版本库。注意它**必须在装插件前构建**(package.json 的
|
||||
# main 指向 dist/index.js),deploy/install.sh 负责这一步。
|
||||
plugins/*/dist/
|
||||
|
||||
# go:embed 的输入目录 = web/dist 的副本,同属构建产物。
|
||||
# go:embed 的输入目录 = client/electron/dist 的副本,同属构建产物。
|
||||
#
|
||||
# 但目录本身要留下:go:embed 要求它存在才能编译,否则新克隆连
|
||||
# `go test ./...` 都跑不起来 —— 只改后端的人不该被迫先装 node。
|
||||
# 因此忽略构建产物,只保留 placeholder.html(见该文件内注释:
|
||||
# 之所以不叫 index.html,是因为那正是 Vite 产物的名字,会被反复覆盖)。
|
||||
gateway/internal/static/static/*
|
||||
!gateway/internal/static/static/placeholder.html
|
||||
server/internal/static/static/*
|
||||
!server/internal/static/static/placeholder.html
|
||||
|
||||
# Go 构建产物
|
||||
gateway/agentmail-gateway
|
||||
gateway/gw
|
||||
server/agentmail-gateway
|
||||
server/gw
|
||||
*.test
|
||||
|
||||
# ---- 运行态数据 ----
|
||||
|
||||
30
README.md
30
README.md
@ -32,12 +32,12 @@
|
||||
|
||||
```bash
|
||||
# 后端:默认用内置 SQLite,无需任何外部依赖
|
||||
cd gateway
|
||||
cd server
|
||||
ADMIN_USER=admin ADMIN_PASSWORD=你的密码 go run ./cmd/server
|
||||
# → http://localhost:8180
|
||||
|
||||
# 前端(另开一个终端)
|
||||
cd web
|
||||
cd client/electron
|
||||
npm install
|
||||
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。
|
||||
`web/dist` 会被复制进 `gateway/internal/static/static/` 再由 `go:embed` 编入二进制,
|
||||
`client/electron/dist` 会被复制进 `server/internal/static/static/` 再由 `go:embed` 编入二进制,
|
||||
**部署机上不需要 node**。
|
||||
|
||||
`web/node_modules`、`web/dist`、`gateway/internal/static/static/` 的内容与编译出的二进制
|
||||
`client/electron/node_modules`、`client/electron/dist`、`server/internal/static/static/` 的内容与编译出的二进制
|
||||
都是构建产物,不进版本库(见 `.gitignore`)。
|
||||
|
||||
新克隆**可以直接 `go build` / `go test`**:`static/` 目录里留了一个占位 index.html
|
||||
@ -87,9 +87,13 @@ npm 只在构建期用到:Node ≥ 18(`npm run build`)与 Go ≥ 1.22。
|
||||
`deploy/install.sh`,或手工:
|
||||
|
||||
```bash
|
||||
cd web && npm ci && npm run build
|
||||
rm -rf ../gateway/internal/static/static && cp -r dist ../gateway/internal/static/static
|
||||
cd ../gateway && go build ./cmd/server
|
||||
# 在仓库根目录执行
|
||||
npm --prefix client/electron ci
|
||||
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 # 分阶段实施计划
|
||||
│ ├── MVP-SPEC.md # MVP 技术规格书
|
||||
│ └── PLUGIN-CONTRACT.md # Agent 平台插件契约(规格 + 验收清单)
|
||||
├── gateway/ # 后端(Go,单二进制)
|
||||
├── server/ # 后端(Go,单二进制)
|
||||
│ ├── cmd/server/ # 入口与路由表
|
||||
│ └── internal/
|
||||
│ ├── db/ # 连接 + 方言适配 + 内嵌迁移
|
||||
@ -200,8 +204,10 @@ agentmail/
|
||||
│ ├── opencode-mail-bridge/ # opencode(插件)
|
||||
│ ├── dsh-mail-bridge/ # DeepSeek Harness(Cordis 插件)
|
||||
│ └── pi-mail-bridge/ # pi(常驻守护进程,用 SDK 起会话)
|
||||
├── web/ # 前端(React + Vite + Tailwind)
|
||||
│ └── test/manual/ # 浏览器实测脚本(量真实盒子与命中区,不进 npm test)
|
||||
├── client/
|
||||
│ ├── electron/ # Electron + React + Vite + Tailwind 客户端
|
||||
│ │ └── test/manual/ # 浏览器实测脚本(量真实盒子与命中区,不进 npm test)
|
||||
│ └── harmony/ # HarmonyOS ArkUI 客户端
|
||||
└── deploy/ # systemd 单元 + 安装脚本
|
||||
├── redeploy-gateway.sh # 二进制热替换(.backup + 原子 install + 后置验证 + 自动回滚)
|
||||
└── remote-agent-demo.py # 最小跨主机 Agent(纯标准库,验证协议层能力)
|
||||
@ -223,8 +229,8 @@ agentmail/
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
cd gateway && go build ./... && go test ./... # 后端
|
||||
cd web && npm run typecheck && npm test # 前端(含 Markdown XSS 回归测试)
|
||||
cd server && go build ./... && go test ./... # 后端
|
||||
cd client/electron && npm run typecheck && npm test # 前端(含 Markdown XSS 回归测试)
|
||||
```
|
||||
|
||||
## WebAPI
|
||||
|
||||
@ -5,10 +5,10 @@
|
||||
* - 创建 BrowserWindow 加载前端(dev=<ELECTRON_START_URL>,prod=../dist/index.html)
|
||||
* - 系统托盘(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
|
||||
*/
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
/**
|
||||
* AgentMail Electron preload —— 通过 contextBridge 暴露安全 API 给渲染进程。
|
||||
*
|
||||
* 渲染进程是 web/src 的 React,跑在 sandbox: true 下(无 nodeIntegration)。
|
||||
* 渲染进程是 client/electron/src 的 React,跑在 sandbox: true 下(无 nodeIntegration)。
|
||||
* 需要桌面能力时(文件选择、通知、托盘角标、SSE 转发)走这里暴露的桥。
|
||||
*
|
||||
* Phase 1 只暴露最小集;后续 Phase 逐步加。
|
||||
@ -2,7 +2,7 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<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>
|
||||
<script>
|
||||
/*
|
||||
@ -196,7 +196,7 @@ function PaneLoading() {
|
||||
*/
|
||||
function NarrowShell({ children }: { children: React.ReactNode }) {
|
||||
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}
|
||||
<NarrowNav />
|
||||
</div>
|
||||
@ -30,6 +30,7 @@ export default function AddressInput({
|
||||
const [meta, setMeta] = useState<SessionCandidate[]>([]);
|
||||
const [kind, setKind] = useState<'name' | 'path' | 'session'>('name');
|
||||
const [active, setActive] = useState(0);
|
||||
const [menuLayout, setMenuLayout] = useState({ flip: false, maxHeight: 288 });
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 当前正在编辑的那一段(多地址时取最后一段)
|
||||
@ -96,6 +97,34 @@ export default function AddressInput({
|
||||
return () => document.removeEventListener('mousedown', onDocClick);
|
||||
}, []);
|
||||
|
||||
// 补全菜单根据 Visual Viewport 剩余空间向上翻转;软键盘出现后也实时重算。
|
||||
useEffect(() => {
|
||||
if (!open || items.length === 0) return;
|
||||
const update = () => {
|
||||
const box = boxRef.current;
|
||||
if (!box) return;
|
||||
const rect = box.getBoundingClientRect();
|
||||
const viewport = window.visualViewport;
|
||||
const top = viewport?.offsetTop ?? 0;
|
||||
const bottom = top + (viewport?.height ?? window.innerHeight);
|
||||
const above = Math.max(0, rect.top - top - 8);
|
||||
const below = Math.max(0, bottom - rect.bottom - 8);
|
||||
const flip = below < Math.min(240, above) && above > below;
|
||||
setMenuLayout({ flip, maxHeight: Math.max(96, Math.min(288, flip ? above : below)) });
|
||||
};
|
||||
update();
|
||||
window.addEventListener('resize', update, { passive: true });
|
||||
window.addEventListener('scroll', update, { passive: true, capture: true });
|
||||
window.visualViewport?.addEventListener('resize', update, { passive: true });
|
||||
window.visualViewport?.addEventListener('scroll', update, { passive: true });
|
||||
return () => {
|
||||
window.removeEventListener('resize', update);
|
||||
window.removeEventListener('scroll', update, { capture: true });
|
||||
window.visualViewport?.removeEventListener('resize', update);
|
||||
window.visualViewport?.removeEventListener('scroll', update);
|
||||
};
|
||||
}, [open, items.length]);
|
||||
|
||||
/** 选中一个候选后拼回完整地址 */
|
||||
const apply = (choice: string) => {
|
||||
let next: string;
|
||||
@ -151,7 +180,12 @@ export default function AddressInput({
|
||||
/>
|
||||
|
||||
{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">
|
||||
{hint}
|
||||
</div>
|
||||
@ -182,7 +216,7 @@ export default function AddressInput({
|
||||
一条已经在跑的会话,而不是继续一条已有的邮件往来 */}
|
||||
{c?.source === 'platform' && (
|
||||
<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="平台侧已有的会话,本站还没有对应的邮件往来"
|
||||
>
|
||||
平台
|
||||
@ -192,7 +226,7 @@ export default function AddressInput({
|
||||
<span className="shrink-0 text-[10px] text-gray-400 font-sans">新建会话</span>
|
||||
)}
|
||||
{(c?.unread ?? 0) > 0 && (
|
||||
<span className="shrink-0 px-1 py-0.5 rounded bg-red-500 text-white text-[9px]">
|
||||
<span className="shrink-0 px-1 py-0.5 rounded bg-red-600 text-white text-[9px]">
|
||||
{c!.unread}
|
||||
</span>
|
||||
)}
|
||||
@ -173,7 +173,7 @@ function TabButton({ active, onClick, children }: {
|
||||
<button
|
||||
onClick={onClick}
|
||||
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}
|
||||
@ -195,7 +195,7 @@ function UserCard({ user, scopes, expanded, onToggle, onSaved, onReload, setErro
|
||||
</button>
|
||||
<span className="font-mono text-sm text-gray-900 min-w-[100px]">{user.username}</span>
|
||||
<span className="tap text-[11px] text-gray-500">{user.display_name}</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${user.role === 'admin' ? 'bg-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' ? '管理员' : '用户'}
|
||||
</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'}`}>
|
||||
@ -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">
|
||||
{busy ? '保存中' : '保存更改'}
|
||||
</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" />
|
||||
<input type="password" value={pw} onChange={e => setPw(e.target.value)} placeholder="新密码(至少 8 位)"
|
||||
className="w-40 text-xs border border-gray-300 rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400" />
|
||||
<button onClick={resetPassword} disabled={pw.length < 8 || busy}
|
||||
className="tap inline-flex items-center gap-1 px-2 py-1 text-[11px] rounded bg-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" /> 重置
|
||||
</button>
|
||||
</div>
|
||||
@ -114,32 +114,34 @@ export function AttachmentPicker({
|
||||
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
|
||||
type="button"
|
||||
onClick={pick}
|
||||
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" />
|
||||
添加附件
|
||||
</button>
|
||||
|
||||
{uploading && (
|
||||
<span className="inline-flex items-center gap-1.5 text-[11px] text-gray-500">
|
||||
<SpinnerIcon className="w-3.5 h-3.5 animate-spin" />
|
||||
{uploading.name} {uploading.pct}%
|
||||
<span className="min-w-0 flex-1 inline-flex items-center gap-1.5 text-[11px] text-gray-500">
|
||||
<SpinnerIcon className="w-3.5 h-3.5 animate-spin shrink-0" />
|
||||
<span className="truncate">{uploading.name}</span>
|
||||
<span className="shrink-0">{uploading.pct}%</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{items.length > 0 && !uploading && (
|
||||
<span className="text-[11px] text-gray-400">
|
||||
<span className="text-[11px] text-gray-500 min-w-0 break-words">
|
||||
{items.length} 个附件 ·{' '}
|
||||
{api.formatSize(items.reduce((sum, a) => sum + a.size, 0))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="text-[11px] text-red-600">{error}</div>}
|
||||
{error && <div className="text-[11px] text-red-600 break-words">{error}</div>}
|
||||
|
||||
{items.length > 0 && (
|
||||
<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-[10px] text-gray-400 shrink-0">{api.formatSize(a.size)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(a)}
|
||||
disabled={disabled}
|
||||
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" />
|
||||
</button>
|
||||
@ -641,7 +641,7 @@ export default function CalendarEventEditor({
|
||||
)}
|
||||
</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
|
||||
onClick={save}
|
||||
disabled={!canSave}
|
||||
@ -658,9 +658,9 @@ export default function CalendarEventEditor({
|
||||
取消
|
||||
</button>
|
||||
{editing && (
|
||||
<div className="ml-auto">
|
||||
<div className="w-full sm:w-auto sm:ml-auto">
|
||||
{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>
|
||||
<button
|
||||
onClick={remove}
|
||||
@ -346,7 +346,7 @@ export default function CalendarView() {
|
||||
return (
|
||||
<div className="flex-1 min-w-0 flex min-h-0">
|
||||
{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}
|
||||
</div>
|
||||
</div>
|
||||
@ -7,6 +7,7 @@ import NarrowOnly from './NarrowOnly';
|
||||
import { useMailStore } from '../stores/mailStore';
|
||||
import { useSessionStore } from '../stores/sessionStore';
|
||||
import { useContactStore } from '../stores/contactStore';
|
||||
import { useIsNarrow } from '../hooks/useIsNarrow';
|
||||
import AddressInput from './AddressInput';
|
||||
import { AttachmentPicker, type PendingAttachment } from './Attachments';
|
||||
import { ComposeIcon, ChevronLeftIcon } from './icons';
|
||||
@ -19,6 +20,7 @@ export default function ComposePage() {
|
||||
const fetchSent = useMailStore(s => s.fetchSent);
|
||||
const fetchSessions = useSessionStore(s => s.fetchSessions);
|
||||
const fetchContacts = useContactStore(s => s.fetchContacts);
|
||||
const isNarrow = useIsNarrow();
|
||||
|
||||
const [to, setTo] = useState(prefill?.to ?? '');
|
||||
const [cc, setCc] = useState(prefill?.cc ?? '');
|
||||
@ -132,8 +134,8 @@ export default function ComposePage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-w-0 flex flex-col bg-white">
|
||||
<div className="px-4 md:px-6 py-3 border-b border-gray-200 flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0 min-h-0 flex flex-col overflow-y-auto lg:overflow-hidden overscroll-contain bg-white">
|
||||
<div className="shrink-0 sticky top-0 z-10 lg:static px-4 md:px-6 py-3 border-b border-gray-200 bg-white flex items-center gap-2">
|
||||
{/* 窄屏下写信是盖在列表上的覆盖层,得有个退出口。
|
||||
用 cancelCompose 而不是 showList:写信态本身要一起结束,
|
||||
只滑走覆盖层的话下次进列表又会弹回来 */}
|
||||
@ -169,12 +171,12 @@ export default function ComposePage() {
|
||||
</button>
|
||||
</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=新建,别名=已有会话">
|
||||
<AddressInput
|
||||
value={to}
|
||||
onChange={setTo}
|
||||
autoFocus
|
||||
autoFocus={!isNarrow}
|
||||
placeholder="deepseekharness@/program.upadtefeature"
|
||||
/>
|
||||
</Field>
|
||||
@ -247,8 +249,8 @@ export default function ComposePage() {
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 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 lg:flex-1 lg:min-h-0 px-4 md:px-6 py-3 flex flex-col">
|
||||
<div className="shrink-0 flex items-center gap-2 mb-1.5">
|
||||
<span className="text-[11px] font-medium text-gray-500">正文(Markdown)</span>
|
||||
<div className="flex-1" />
|
||||
<Toggle active={!preview} onClick={() => setPreview(false)}>
|
||||
@ -260,7 +262,7 @@ export default function ComposePage() {
|
||||
</div>
|
||||
|
||||
{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() ? (
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{body}</Markdown>
|
||||
) : (
|
||||
@ -272,19 +274,21 @@ export default function ComposePage() {
|
||||
value={body}
|
||||
onChange={e => setBody(e.target.value)}
|
||||
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 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} />
|
||||
</div>
|
||||
|
||||
<div className="px-4 md:px-6 py-3 border-t border-gray-200 flex items-center gap-3">
|
||||
{error && <span className="text-xs text-red-600">{error}</span>}
|
||||
{okMsg && <span className="text-xs text-green-600">{okMsg}</span>}
|
||||
<div className="flex-1" />
|
||||
<div className="shrink-0 sticky bottom-0 z-10 lg:static px-4 md:px-6 py-3 border-t border-gray-200 bg-white flex items-center gap-3 flex-wrap">
|
||||
{error && <span className="min-w-0 text-xs text-red-600 break-words">{error}</span>}
|
||||
{okMsg && <span className="min-w-0 text-xs text-green-700 break-words">{okMsg}</span>}
|
||||
<div className="flex-1 min-w-2" />
|
||||
<button
|
||||
onClick={cancelCompose}
|
||||
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900"
|
||||
@ -336,7 +340,7 @@ function Toggle({
|
||||
<button
|
||||
onClick={onClick}
|
||||
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}
|
||||
@ -58,7 +58,7 @@ export default function ContactPanel() {
|
||||
<div
|
||||
className={`w-full shrink-0 border-r border-gray-200 bg-white flex flex-col min-w-0 ${
|
||||
// 卡片要放两行摘要 + 预算条,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">
|
||||
@ -82,7 +82,7 @@ export default function ContactPanel() {
|
||||
<button
|
||||
onClick={toggleArchivedView}
|
||||
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 className="text-[10px] text-gray-400 font-mono truncate">{contact.path}</span>
|
||||
{contact.unread_count > 0 && (
|
||||
<span className="ml-auto shrink-0 min-w-[16px] h-4 px-1 rounded-full bg-blue-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}
|
||||
</span>
|
||||
)}
|
||||
@ -63,7 +63,7 @@ export default function MailList() {
|
||||
};
|
||||
|
||||
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">
|
||||
<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">
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
@ -200,7 +200,7 @@ function SessionGroup({
|
||||
</button>
|
||||
|
||||
{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 => (
|
||||
<MailItem
|
||||
key={m.mail_id}
|
||||
@ -312,7 +312,7 @@ function MailItem({
|
||||
</span>
|
||||
)}
|
||||
{!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>}
|
||||
{attachCount > 0 && (
|
||||
@ -108,7 +108,7 @@ export default function MailView() {
|
||||
onThread={() => setThreadOf(currentMail.mail_id)}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
<AttachmentList items={currentMail.attachments ?? []} />
|
||||
@ -322,7 +322,7 @@ function RenameProposalBar() {
|
||||
await accept();
|
||||
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 ? '改名中' : '接受'}
|
||||
</button>
|
||||
@ -373,7 +373,7 @@ function ForwardBar({ mail, onClose }: { mail: Mail; onClose: () => void }) {
|
||||
};
|
||||
|
||||
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">
|
||||
<ForwardIcon className="w-3.5 h-3.5 text-gray-500" />
|
||||
<span className="text-[11px] font-medium text-gray-600">
|
||||
@ -388,7 +388,7 @@ function ForwardBar({ mail, onClose }: { mail: Mail; onClose: () => void }) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AddressInput value={to} onChange={setTo} autoFocus placeholder="新收件人:pi@root.new" />
|
||||
<AddressInput value={to} onChange={setTo} placeholder="新收件人:pi@root.new" />
|
||||
|
||||
{ccOpen && (
|
||||
<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"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{error && <span className="text-xs text-red-600">{error}</span>}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{error && <span className="text-xs text-red-600 min-w-0 break-words">{error}</span>}
|
||||
<div className="flex-1" />
|
||||
<button onClick={onClose} className="tap px-3 py-1.5 text-xs text-gray-500 hover:text-gray-800">
|
||||
取消
|
||||
@ -577,7 +577,7 @@ function ThreadCard({ mail, onForward }: { mail: Mail; onForward?: () => void })
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="prose prose-sm max-w-none">
|
||||
<div className="markdown text-sm">
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{mail.body}</Markdown>
|
||||
</div>
|
||||
<AttachmentList items={mail.attachments ?? []} />
|
||||
@ -635,7 +635,7 @@ export function PermissionPanel({ mail }: { mail: Mail }) {
|
||||
disabled={busy}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md transition-colors disabled:opacity-40 ${
|
||||
isApprove(opt)
|
||||
? 'bg-green-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'
|
||||
}`}
|
||||
>
|
||||
@ -738,8 +738,8 @@ function ReplyBar({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="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="shrink-0 max-h-[min(65vh,calc(var(--app-height)-3rem))] overflow-y-auto overscroll-contain border-t border-gray-200 bg-white px-4 md:px-6 py-3">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<p className="text-[10px] text-gray-400 font-mono min-w-0 truncate">回复 {target}</p>
|
||||
<div className="flex-1" />
|
||||
{(replyTo.cc_list?.length ?? 0) > 0 && (
|
||||
@ -60,7 +60,7 @@ export default function NarrowNav() {
|
||||
|
||||
return (
|
||||
<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 的手势条会盖住最后一排
|
||||
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
|
||||
>
|
||||
@ -90,7 +90,7 @@ export default function NarrowNav() {
|
||||
<span
|
||||
className={`absolute top-1 right-[22%] min-w-[15px] h-[15px] px-1 rounded-full text-[9px] font-bold flex items-center justify-center ${
|
||||
mode === 'inbox'
|
||||
? 'bg-red-500 text-white'
|
||||
? 'bg-red-600 text-white'
|
||||
: mode === 'permissions'
|
||||
? 'bg-orange-700 text-white'
|
||||
: 'bg-chrome-600 text-chrome-100'
|
||||
@ -74,7 +74,7 @@ export default function NarrowStack({
|
||||
|
||||
{mounted && (
|
||||
<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'
|
||||
}`}
|
||||
>
|
||||
@ -55,7 +55,7 @@ export default function PermissionChip({ mode, enforcement, compact }: Permissio
|
||||
? 'bg-blue-50 text-blue-700 border-blue-200'
|
||||
: normalized === 'full'
|
||||
? '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 label = MODE_LABEL[normalized] ?? normalized;
|
||||
@ -54,7 +54,7 @@ export default function PermissionList() {
|
||||
};
|
||||
|
||||
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">
|
||||
<h2 className="text-sm font-semibold text-gray-800">授权</h2>
|
||||
{pendingTotal > 0 ? (
|
||||
@ -85,7 +85,7 @@ export default function Sidebar() {
|
||||
<span
|
||||
className={`absolute top-0.5 right-1 min-w-[15px] h-[15px] px-1 rounded-full text-[9px] font-bold flex items-center justify-center ${
|
||||
mode === 'inbox'
|
||||
? 'bg-red-500 text-white'
|
||||
? 'bg-red-600 text-white'
|
||||
: mode === 'permissions'
|
||||
? // 待决策的授权用橙色:它跟未读不是一类紧急 ——
|
||||
// 未读是「有内容没看」,待决策是「有 Agent 卡在那儿等我」
|
||||
@ -106,7 +106,7 @@ export default function Sidebar() {
|
||||
onClick={() => startCompose()}
|
||||
title="新建邮件"
|
||||
className={`w-12 h-12 rounded-lg flex flex-col items-center justify-center gap-0.5 text-white transition-colors ${
|
||||
composing ? 'bg-blue-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 />
|
||||
@ -119,7 +119,7 @@ export default function Sidebar() {
|
||||
title={`${user?.display_name || user?.username}(点击管理账号)`}
|
||||
className={`relative w-9 h-9 rounded-full flex items-center justify-center text-[11px] font-semibold transition-colors ${
|
||||
viewMode === 'account' && !composing
|
||||
? 'bg-blue-500 text-white'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-chrome-700 text-chrome-200 hover:bg-chrome-600'
|
||||
}`}
|
||||
>
|
||||
@ -253,7 +253,7 @@ function Node({
|
||||
<span className="text-xs font-mono text-gray-500 truncate">{node.to_name}</span>
|
||||
<div className="flex-1" />
|
||||
{isForward && (
|
||||
<span className="px-1 py-0.5 rounded bg-purple-100 text-purple-700 text-[9px]">
|
||||
<span className="px-1 py-0.5 rounded bg-blue-100 text-blue-700 text-[9px]">
|
||||
转发
|
||||
</span>
|
||||
)}
|
||||
@ -55,7 +55,7 @@ export function WorkCard({
|
||||
<span className="text-xs font-semibold text-gray-900 truncate">{c.agent_name}</span>
|
||||
<span className="text-[10px] text-gray-400 font-mono truncate">{c.path}</span>
|
||||
{c.unread_count > 0 && (
|
||||
<span className="ml-auto shrink-0 min-w-[16px] h-4 px-1 rounded-full bg-blue-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}
|
||||
</span>
|
||||
)}
|
||||
@ -3,13 +3,14 @@ import { useEffect, useState } from 'react';
|
||||
/**
|
||||
* 窄屏断点。
|
||||
*
|
||||
* 三栏布局需要 60(导航)+ 320(列表)+ 约 400(详情)≈ 780px 才不挤,
|
||||
* 因此以 768px 为界:以下只显示单栏。
|
||||
* 三栏布局需要 60(导航)+ 320(列表)+ 至少 520(详情)≈ 900px;
|
||||
* 还要给滚动条、长地址和触摸留余量,因此与 Tailwind 的 lg 断点统一:
|
||||
* 1024px 以下(含常见 768/820px 竖屏平板)都使用单栏。
|
||||
*
|
||||
* 用 matchMedia 而不是监听 resize:后者每变化一像素都触发,还得自己节流;
|
||||
* matchMedia 只在跨过阈值时回调一次。
|
||||
*/
|
||||
const NARROW_QUERY = '(max-width: 767px)';
|
||||
export const NARROW_QUERY = '(max-width: 1023px)';
|
||||
|
||||
export function useIsNarrow(): boolean {
|
||||
const [narrow, setNarrow] = useState(() =>
|
||||
@ -40,7 +40,8 @@
|
||||
--c-gray-100: 243 244 246;
|
||||
--c-gray-200: 229 231 235;
|
||||
--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-600: 75 85 99;
|
||||
--c-gray-700: 55 65 81;
|
||||
@ -340,10 +341,21 @@
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* main.tsx 会用 Visual Viewport 覆盖;这里保证脚本执行前也有正确高度。 */
|
||||
--app-height: 100vh;
|
||||
}
|
||||
@supports (height: 100dvh) {
|
||||
:root {
|
||||
--app-height: 100dvh;
|
||||
}
|
||||
}
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
height: var(--app-height);
|
||||
min-height: 0;
|
||||
}
|
||||
body {
|
||||
overflow: hidden;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
/*
|
||||
@ -353,6 +365,63 @@
|
||||
background-color: rgb(var(--c-gray-50));
|
||||
color: rgb(var(--c-gray-900));
|
||||
}
|
||||
|
||||
/* 统一作者样式,避免 Chromium/Electron autofill 在深色卡片上画白底。 */
|
||||
input:not([type='checkbox']):not([type='radio']):not([type='file']),
|
||||
textarea,
|
||||
select {
|
||||
background-color: rgb(var(--c-white));
|
||||
color: rgb(var(--c-gray-900));
|
||||
}
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: rgb(var(--c-gray-400));
|
||||
}
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus {
|
||||
-webkit-text-fill-color: rgb(var(--c-gray-900));
|
||||
box-shadow: 0 0 0 1000px rgb(var(--c-white)) inset;
|
||||
transition: background-color 9999s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* react-markdown 的本地排版层;不依赖固定色板的 typography 插件。 */
|
||||
.markdown {
|
||||
color: rgb(var(--c-gray-800));
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.markdown > * + * { margin-top: 0.75rem; }
|
||||
.markdown h1 { font-size: 1.5rem; line-height: 2rem; font-weight: 700; }
|
||||
.markdown h2 { font-size: 1.25rem; line-height: 1.75rem; font-weight: 700; }
|
||||
.markdown h3 { font-size: 1.05rem; line-height: 1.5rem; font-weight: 600; }
|
||||
.markdown ul { list-style: disc; padding-left: 1.35rem; }
|
||||
.markdown ol { list-style: decimal; padding-left: 1.35rem; }
|
||||
.markdown li + li { margin-top: 0.25rem; }
|
||||
.markdown a { color: rgb(var(--c-blue-600)); text-decoration: underline; }
|
||||
.markdown blockquote {
|
||||
border-left: 3px solid rgb(var(--c-gray-300));
|
||||
padding-left: 0.75rem;
|
||||
color: rgb(var(--c-gray-600));
|
||||
}
|
||||
.markdown code {
|
||||
border-radius: 0.25rem;
|
||||
background: rgb(var(--c-gray-100));
|
||||
padding: 0.1rem 0.3rem;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
.markdown pre {
|
||||
overflow-x: auto;
|
||||
border: 1px solid rgb(var(--c-gray-200));
|
||||
border-radius: 0.5rem;
|
||||
background: rgb(var(--c-gray-100));
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.markdown pre code { background: transparent; padding: 0; }
|
||||
.markdown table { width: 100%; border-collapse: collapse; display: block; overflow-x: auto; }
|
||||
.markdown th, .markdown td { border: 1px solid rgb(var(--c-gray-200)); padding: 0.4rem 0.55rem; }
|
||||
.markdown th { background: rgb(var(--c-gray-100)); text-align: left; }
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@ -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 {
|
||||
position: relative;
|
||||
}
|
||||
44
client/electron/src/main.tsx
Normal file
44
client/electron/src/main.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import { initTheme } from './stores/themeStore';
|
||||
import './index.css';
|
||||
|
||||
// 必须在 render 之前:晚一步就会让深色偏好的用户看到一帧白色闪屏。
|
||||
// index.html 里还有一段更早的内联脚本处理「JS bundle 到达前」那段空窗,
|
||||
// 这里做的是把 store 状态与 DOM 对齐并订阅系统主题变化。
|
||||
initTheme();
|
||||
|
||||
/**
|
||||
* 用 Visual Viewport 驱动应用高度。
|
||||
*
|
||||
* 手机软键盘通常只缩小「可见视口」,不缩小传统 100%/100vh;如果外壳还按
|
||||
* 布局视口排版,正文和发送按钮就会落到键盘后面。dvh 是 CSS 兜底,这个变量
|
||||
* 处理 iOS WebView 与部分旧 Chromium 对动态视口更新不及时的情况。
|
||||
*/
|
||||
const syncVisibleViewport = () => {
|
||||
const viewport = window.visualViewport;
|
||||
const height = viewport?.height ?? window.innerHeight;
|
||||
document.documentElement.style.setProperty('--app-height', `${Math.round(height)}px`);
|
||||
};
|
||||
|
||||
syncVisibleViewport();
|
||||
window.addEventListener('resize', syncVisibleViewport, { passive: true });
|
||||
window.visualViewport?.addEventListener('resize', syncVisibleViewport, { passive: true });
|
||||
window.visualViewport?.addEventListener('scroll', syncVisibleViewport, { passive: true });
|
||||
|
||||
// 编辑表单时隐藏窄屏底栏,把有限的键盘上方空间留给正文与操作按钮。
|
||||
// focusout 要延后一帧:从一个输入框切到另一个时 activeElement 会短暂回到 body。
|
||||
const syncEditingState = () => {
|
||||
const el = document.activeElement;
|
||||
const editing = el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement;
|
||||
document.documentElement.classList.toggle('form-editing', editing);
|
||||
};
|
||||
document.addEventListener('focusin', syncEditingState);
|
||||
document.addEventListener('focusout', () => requestAnimationFrame(syncEditingState));
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@ -194,8 +194,8 @@ describe('PermissionPanel 决策', () => {
|
||||
screen.getByRole('button', { name: new RegExp(name) }).className;
|
||||
|
||||
// 颜色是唯一的视觉提示:点错一次就放行了一个危险操作
|
||||
expect(cls('允许')).toContain('bg-green-600');
|
||||
expect(cls('approve')).toContain('bg-green-600');
|
||||
expect(cls('允许')).toContain('bg-green-700');
|
||||
expect(cls('approve')).toContain('bg-green-700');
|
||||
expect(cls('拒绝')).toContain('text-red-700');
|
||||
// 不在同意词表里的一律按「否」处理 —— 宁可让人多看一眼
|
||||
expect(cls('算了')).toContain('text-red-700');
|
||||
@ -13,7 +13,7 @@
|
||||
* 只有真实渲染才能验的事。
|
||||
*
|
||||
* 用法: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';
|
||||
|
||||
@ -9,8 +9,8 @@
|
||||
* SVG 而不是导航按钮)只有这样才能发现。
|
||||
*
|
||||
* 用法:
|
||||
* ADMIN_PW=<密码> node web/test/manual/narrow-verify.mjs
|
||||
* ADMIN_PW=<密码> node web/test/manual/wide-regression.mjs
|
||||
* ADMIN_PW=<密码> node client/electron/test/manual/narrow-verify.mjs
|
||||
* ADMIN_PW=<密码> node client/electron/test/manual/wide-regression.mjs
|
||||
*
|
||||
* 环境变量:
|
||||
* ADMIN_PW 必填,管理员密码
|
||||
@ -20,9 +20,22 @@
|
||||
*/
|
||||
const PLAYWRIGHT = process.env.PLAYWRIGHT || '/usr/lib/node_modules/playwright/index.mjs';
|
||||
const { chromium } = await import(PLAYWRIGHT);
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
const { extname, join } = await import('node:path');
|
||||
|
||||
const CDP = process.env.CDP_URL || 'http://127.0.0.1:9222';
|
||||
const APP = (process.env.AGENTMAIL_URL || 'https://mail.jianfgit.xyz').replace(/\/$/, '');
|
||||
const DIST = process.env.AGENTMAIL_DIST?.replace(/\/$/, '');
|
||||
const contentTypes = {
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.webp': 'image/webp',
|
||||
'.woff2': 'font/woff2'
|
||||
};
|
||||
|
||||
export const PHONE = { width: 390, height: 844 }; // iPhone 14 Pro
|
||||
export const SMALL = { width: 320, height: 568 }; // iPhone SE 1 代
|
||||
@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 不能用 networkidle:SSE 是一条永不结束的长连接,networkidle 永远不触发
|
||||
await page.goto(APP + '/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForTimeout(2500);
|
||||
@ -5,7 +5,7 @@
|
||||
* 抽屉盖住底部导航、工具按钮只有 16px 高、看不见却按得动的「归档」、
|
||||
* 对话树缩进把卡片压成竖条、对话树没有返回出口。
|
||||
*
|
||||
* 用法:ADMIN_PW=<密码> node web/test/manual/narrow-verify.mjs
|
||||
* 用法:ADMIN_PW=<密码> node client/electron/test/manual/narrow-verify.mjs
|
||||
*/
|
||||
import {
|
||||
openApp,
|
||||
221
client/electron/test/manual/responsive-verify.mjs
Normal file
221
client/electron/test/manual/responsive-verify.mjs
Normal file
@ -0,0 +1,221 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { extname, join } from 'node:path';
|
||||
|
||||
const PLAYWRIGHT = process.env.PLAYWRIGHT || '/usr/lib/node_modules/playwright/index.mjs';
|
||||
const { chromium } = await import(PLAYWRIGHT);
|
||||
const CDP = process.env.CDP_URL || 'http://127.0.0.1:9222';
|
||||
const APP = 'http://127.0.0.1:8180';
|
||||
const DIST = process.env.AGENTMAIL_DIST || '/home/program/agentmail/client/electron/dist';
|
||||
const types = {
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.woff2': 'font/woff2'
|
||||
};
|
||||
|
||||
const user = {
|
||||
user_id: 'u-test', username: 'tester', display_name: '测试用户', role: 'admin',
|
||||
status: 'active', allowed_agents: [], allowed_paths: [], created_at: '2025-01-01T00:00:00Z'
|
||||
};
|
||||
const mail = {
|
||||
mail_id: 'm-test', session_id: 's-test', parent_mail_id: null,
|
||||
from_name: 'deepseekharness', from_workspace: 'deepseekharness', to_name: 'tester', to_workspace: '',
|
||||
cc_list: [], subject: '响应式布局测试邮件', body: '## 正文\n\n用于浏览器布局验证。', body_preview: '用于浏览器布局验证。',
|
||||
mail_type: 'normal', permission_options: null, permission_result: null, status: 'unread',
|
||||
created_at: '2025-01-02T03:04:00Z', session_alias: 'layout-test', session_workspace: '/program/test',
|
||||
attachments: [], from_human: false, to_human: true
|
||||
};
|
||||
const contact = {
|
||||
session_id: 's-test', agent_name: 'deepseekharness', path: '/program/test', session_alias: 'layout-test',
|
||||
address: 'deepseekharness@/program/test.layout-test', status: 'active', mail_count: 1, unread_count: 1,
|
||||
last_activity: '2025-01-02T03:04:00Z', subject: '响应式布局测试邮件', max_rounds: 0, used_rounds: 0,
|
||||
last_from: 'deepseekharness', last_preview: '用于浏览器布局验证。'
|
||||
};
|
||||
|
||||
function json(route, body, status = 200) {
|
||||
return route.fulfill({ status, contentType: 'application/json; charset=utf-8', body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
async function mockApi(route, url) {
|
||||
const p = url.pathname.replace(/^\/api\/v1/, '');
|
||||
if (p === '/auth/me') return json(route, { user });
|
||||
if (p === '/setup/status') return json(route, { needs_setup: false });
|
||||
if (p === '/me/mail/inbox') return json(route, { mails: [mail], total: 1 });
|
||||
if (p === '/me/mail/sent') return json(route, { mails: [] });
|
||||
if (p === '/me/sessions') return json(route, { sessions: [] });
|
||||
if (p === '/contacts') return json(route, { contacts: [contact] });
|
||||
if (p === '/contacts/suggest') {
|
||||
return json(route, {
|
||||
kind: 'name', suggestions: ['deepseekharness', 'pi', 'opencode'],
|
||||
candidates: [
|
||||
{ alias: 'deepseekharness', source: 'mail', title: '布局测试', unread: 1 },
|
||||
{ alias: 'pi', source: 'platform', title: 'Pi' },
|
||||
{ alias: 'opencode', source: 'platform', title: 'OpenCode' }
|
||||
]
|
||||
});
|
||||
}
|
||||
if (p === '/agents') return json(route, { agents: [] });
|
||||
if (p === '/me/keys') return json(route, { keys: [] });
|
||||
if (p === '/admin/users') return json(route, { users: [user] });
|
||||
if (p === '/admin/scopes') return json(route, { agents: [], paths: [] });
|
||||
if (p === '/admin/quotas') return json(route, { quotas: [] });
|
||||
if (p === '/calendar/events') return json(route, { events: [] });
|
||||
if (p === '/events/stream') {
|
||||
return route.fulfill({ status: 200, contentType: 'text/event-stream', body: 'event: connected\ndata: {}\n\n' });
|
||||
}
|
||||
if (p === '/mail/m-test') return json(route, mail);
|
||||
if (p === '/mail/m-test/read') return json(route, { status: 'ok' });
|
||||
if (p === '/mail/m-test/thread') {
|
||||
return json(route, { anchor_mail_id: 'm-test', root_mail_id: 'm-test', anchor_depth: 0, nodes: [], total: 0, hidden: 0, has_more: false, next_offset: 0 });
|
||||
}
|
||||
return json(route, { error: `unmocked ${p}` }, 404);
|
||||
}
|
||||
|
||||
async function installRoutes(page) {
|
||||
await page.route(`${APP}/**`, async route => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.pathname.startsWith('/api/v1/')) return mockApi(route, url);
|
||||
const rel = url.pathname === '/' ? 'index.html' : url.pathname.replace(/^\/+/, '');
|
||||
const file = join(DIST, rel);
|
||||
try {
|
||||
const body = await readFile(file);
|
||||
return route.fulfill({ status: 200, body, contentType: types[extname(file)] || 'application/octet-stream' });
|
||||
} catch {
|
||||
return route.fulfill({ status: 404, body: 'not found' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const browser = await chromium.connectOverCDP(CDP);
|
||||
const context = browser.contexts()[0];
|
||||
const failures = [];
|
||||
let passed = 0;
|
||||
function check(name, ok, detail = '') {
|
||||
console.log(` ${ok ? '通过' : '失败'} ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
if (ok) passed++; else failures.push(name);
|
||||
}
|
||||
|
||||
async function open(viewport) {
|
||||
const page = await context.newPage();
|
||||
await page.setViewportSize(viewport);
|
||||
await installRoutes(page);
|
||||
await page.goto(`${APP}/`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('text=收件箱');
|
||||
await page.waitForTimeout(250);
|
||||
return page;
|
||||
}
|
||||
|
||||
async function openCompose(page, narrow) {
|
||||
if (narrow) await page.locator('nav button').filter({ hasText: '新建' }).click();
|
||||
else await page.locator('button[title="新建邮件"]').click();
|
||||
await page.waitForSelector('textarea');
|
||||
}
|
||||
|
||||
async function measureCompose(page, name, narrow) {
|
||||
await openCompose(page, narrow);
|
||||
const m = await page.evaluate(() => {
|
||||
const ta = document.querySelector('textarea');
|
||||
const attach = [...document.querySelectorAll('button')].find(b => b.textContent?.includes('添加附件'));
|
||||
const send = [...document.querySelectorAll('button')].find(b => b.textContent?.trim() === '发送');
|
||||
const root = ta?.closest('.overflow-y-auto, .lg\\:overflow-hidden');
|
||||
attach?.scrollIntoView({ block: 'center' });
|
||||
const tr = ta?.getBoundingClientRect();
|
||||
const ar = attach?.getBoundingClientRect();
|
||||
const sr = send?.parentElement?.getBoundingClientRect();
|
||||
return {
|
||||
textareaH: tr?.height || 0,
|
||||
textareaBottom: tr?.bottom || 0,
|
||||
attachmentTop: ar?.top || 0,
|
||||
attachmentBottom: ar?.bottom || 0,
|
||||
footerTop: sr?.top || 0,
|
||||
separated: !!tr && !!ar && ar.top >= tr.bottom - 1,
|
||||
footerSeparated: !!ar && !!sr && sr.top >= ar.bottom - 1,
|
||||
docW: document.documentElement.clientWidth,
|
||||
scrollW: document.documentElement.scrollWidth,
|
||||
composeScrollH: root?.scrollHeight || 0,
|
||||
composeClientH: root?.clientHeight || 0
|
||||
};
|
||||
});
|
||||
check(
|
||||
narrow ? `${name} 正文不少于 12rem` : `${name} 桌面正文占据剩余高度`,
|
||||
m.textareaH >= (narrow ? 191 : 120),
|
||||
`${Math.round(m.textareaH)}px`
|
||||
);
|
||||
check(
|
||||
`${name} 附件不遮挡正文`,
|
||||
m.separated,
|
||||
`正文底=${Math.round(m.textareaBottom)} 附件顶=${Math.round(m.attachmentTop)}`
|
||||
);
|
||||
check(
|
||||
`${name} 操作栏不遮挡附件`,
|
||||
m.footerSeparated,
|
||||
`附件底=${Math.round(m.attachmentBottom)} 操作栏顶=${Math.round(m.footerTop)}`
|
||||
);
|
||||
check(`${name} 无横向溢出`, m.scrollW <= m.docW, `${m.docW}/${m.scrollW}`);
|
||||
return m;
|
||||
}
|
||||
|
||||
for (const viewport of [
|
||||
{ width: 320, height: 568 }, { width: 390, height: 844 },
|
||||
{ width: 768, height: 900 }, { width: 820, height: 980 }
|
||||
]) {
|
||||
const page = await open(viewport);
|
||||
const label = `${viewport.width}x${viewport.height}`;
|
||||
check(`${label} 使用单栏底部导航`, await page.locator('nav.narrow-nav').isVisible());
|
||||
await measureCompose(page, label, true);
|
||||
await page.close();
|
||||
}
|
||||
|
||||
{
|
||||
const page = await open({ width: 390, height: 420 });
|
||||
const m = await measureCompose(page, '390x420 键盘态', true);
|
||||
check('键盘态写信页可纵向滚动', m.composeScrollH > m.composeClientH, `${m.composeClientH}/${m.composeScrollH}`);
|
||||
await page.locator('textarea').focus();
|
||||
await page.waitForTimeout(50);
|
||||
const navHidden = await page.locator('nav.narrow-nav').evaluate(el => getComputedStyle(el).display === 'none');
|
||||
check('输入时隐藏底部导航', navHidden);
|
||||
const to = page.locator('input[placeholder*="deepseekharness"]').first();
|
||||
await to.fill('d');
|
||||
await page.waitForTimeout(250);
|
||||
const menu = page.locator('div.absolute.z-20').first();
|
||||
const bounds = await menu.evaluate(el => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return { top: r.top, bottom: r.bottom, vh: window.visualViewport?.height || innerHeight };
|
||||
});
|
||||
check('地址补全不越出可视视口', bounds.top >= 0 && bounds.bottom <= bounds.vh + 1, JSON.stringify(bounds));
|
||||
await page.close();
|
||||
}
|
||||
|
||||
for (const viewport of [{ width: 1024, height: 768 }, { width: 1280, height: 800 }]) {
|
||||
const page = await open(viewport);
|
||||
const label = `${viewport.width}x${viewport.height}`;
|
||||
check(`${label} 使用桌面侧栏`, await page.locator('button[title="新建邮件"]').isVisible());
|
||||
await measureCompose(page, label, false);
|
||||
await page.close();
|
||||
}
|
||||
|
||||
{
|
||||
const page = await open({ width: 1280, height: 800 });
|
||||
for (const mode of ['light', 'dark']) {
|
||||
await page.evaluate(mode => {
|
||||
localStorage.setItem('agentmail.theme', mode);
|
||||
document.documentElement.classList.toggle('dark', mode === 'dark');
|
||||
document.documentElement.style.colorScheme = mode;
|
||||
}, mode);
|
||||
const colors = await page.evaluate(() => ({
|
||||
body: getComputedStyle(document.body).backgroundColor,
|
||||
card: getComputedStyle(document.querySelector('.bg-white')).backgroundColor,
|
||||
text: getComputedStyle(document.querySelector('.text-gray-900') || document.body).color
|
||||
}));
|
||||
check(`${mode} 主题颜色均已解析`, !Object.values(colors).some(c => c === 'rgba(0, 0, 0, 0)'), JSON.stringify(colors));
|
||||
}
|
||||
await page.close();
|
||||
}
|
||||
|
||||
console.log(`\n响应式浏览器验收:${passed} 通过,${failures.length} 失败`);
|
||||
if (failures.length) console.log(failures.join('\n'));
|
||||
await browser.close();
|
||||
process.exit(failures.length ? 1 : 0);
|
||||
@ -6,7 +6,7 @@
|
||||
*
|
||||
* 用法:
|
||||
* 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';
|
||||
|
||||
@ -88,7 +88,10 @@ try {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width < 4 || r.height < 4) continue;
|
||||
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 bg = bgOf(el);
|
||||
if (!fg || !bg) continue;
|
||||
@ -112,10 +115,9 @@ try {
|
||||
return out;
|
||||
});
|
||||
|
||||
// 允许少量刻意的低对比装饰(占位符、禁用态)
|
||||
const severe = bad.filter(x => x.ratio < 2.5);
|
||||
check(`${mode}: 无严重低对比文本(< 2.5:1)`, severe.length === 0,
|
||||
severe.slice(0, 4).map(x => `"${x.text}" ${x.ratio}`).join(' | '));
|
||||
// 禁用态与纯图标已在页面端过滤,其余可见文字都应达到自身 AA 阈值。
|
||||
check(`${mode}: 可见文字全部达到 WCAG AA`, bad.length === 0,
|
||||
bad.slice(0, 4).map(x => `"${x.text}" ${x.ratio}/${x.need}`).join(' | '));
|
||||
if (bad.length) {
|
||||
console.log(` (${bad.length} 处低于 AA 阈值,最差 ${Math.min(...bad.map(x => x.ratio))}:1)`);
|
||||
// 逐条列出来而不只报个数:不知道是哪一处就没法修
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -33,7 +33,7 @@
|
||||
*
|
||||
* 用法:
|
||||
* 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';
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
* - `.tap` 的伪元素命中区(桌面密排工具栏里会互相重叠)
|
||||
* - `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';
|
||||
|
||||
@ -36,23 +36,20 @@ check(
|
||||
stack.includes('motion-reduce:transition-none')
|
||||
);
|
||||
|
||||
// 2) 固定宽度的中间栏在窄屏必须让位。
|
||||
// 断言的是「w-full + md: 前缀的固定宽度」这个形态,不是某个具体像素值 ——
|
||||
// ContactPanel 的卡片视图用 400px,列表视图用 320px。
|
||||
// 2) 手机与竖屏平板统一用单栏;三栏只在 Tailwind lg(1024px)启用。
|
||||
const narrowHook = read('../src/hooks/useIsNarrow.ts');
|
||||
check('JS 单栏断点与 lg 一致', narrowHook.includes('(max-width: 1023px)'));
|
||||
for (const f of ['MailList', 'PermissionList', 'ContactPanel', 'CalendarView']) {
|
||||
const src = read(`../src/components/${f}.tsx`);
|
||||
const narrowFullWidth = src.includes('w-full');
|
||||
// 固定宽度只能出现在 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 px = Number(m.match(/\d+/)[0]);
|
||||
return px >= 200 && !src.includes('md:' + m);
|
||||
const px = Number(m.match(/\d+/)?.[0] || 0);
|
||||
return px >= 200 && !src.includes('lg:' + m);
|
||||
});
|
||||
check(
|
||||
`${f} 中间栏窄屏全宽,固定宽度仅在 md: 之后`,
|
||||
narrowFullWidth && bareFixed.length === 0,
|
||||
bareFixed.length ? `裸固定宽度:${bareFixed.join(', ')}` : '缺少 w-full'
|
||||
`${f} 平板单栏全宽,固定宽度仅在 lg 之后`,
|
||||
narrowFullWidth && bareFixed.length === 0 && !/md:w-\[/.test(src),
|
||||
bareFixed.length ? `裸固定宽度:${bareFixed.join(', ')}` : '存在 md 固定栏或缺少 w-full'
|
||||
);
|
||||
}
|
||||
|
||||
@ -102,9 +99,9 @@ check(
|
||||
// .tap 用居中的透明伪元素扩大命中区,视觉尺寸不变。
|
||||
const css = read('../src/index.css');
|
||||
check(
|
||||
'.tap 提供 44px 触摸命中区且只在窄屏生效',
|
||||
'.tap 提供 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)
|
||||
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)
|
||||
const wide = ['MailView', 'ComposePage', 'ThreadView', 'AccountPage', 'AdminUsersPage'];
|
||||
for (const f of wide) {
|
||||
@ -310,9 +310,9 @@ const sRgb = name => {
|
||||
};
|
||||
// 代码里真正出现过的「实心底 + 白字」组合
|
||||
const solidPairs = [
|
||||
['blue-600'], ['blue-700'], ['blue-500'],
|
||||
['red-500'], ['red-600'], ['red-700'],
|
||||
['green-600'], ['green-700'], ['orange-700']
|
||||
['blue-600'], ['blue-700'],
|
||||
['red-600'], ['red-700'],
|
||||
['green-700'], ['orange-700']
|
||||
];
|
||||
const onAccentLight = rgbOf(lightBlock, 'on-accent') ||
|
||||
(lightBlock.match(/--c-on-accent:\s*(\d+)\s+(\d+)\s+(\d+)/) || []).slice(1).map(Number);
|
||||
@ -320,14 +320,12 @@ const weakButtons = [];
|
||||
for (const [name] of solidPairs) {
|
||||
const bg = sRgb(name);
|
||||
if (!bg) { weakButtons.push(`${name}:缺失`); continue; }
|
||||
// 3:1 是 WCAG 对大号/粗体文字的下限。这些按钮文字是 11–14px 的 font-medium,
|
||||
// 严格说该要 4.5 —— 但 Tailwind 官方 600 档普遍在 3–4.5 之间(green-600 = 3.05),
|
||||
// 收紧到 4.5 就得偏离官方色值。取 3.0 作为门槛并把偏低的记在这里。
|
||||
// 这些按钮文字只有 9–14px,按普通文字执行 WCAG AA 4.5:1。
|
||||
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(
|
||||
'白字在实心按钮底上达到 3:1',
|
||||
'白字在实心按钮底上达到 4.5:1',
|
||||
weakButtons.length === 0,
|
||||
weakButtons.join(' ')
|
||||
);
|
||||
@ -360,5 +358,38 @@ for (const f of chromeFiles) {
|
||||
}
|
||||
check('框架组件已全部改用 chrome 色阶', slateLeft.length === 0, slateLeft.join(' | '));
|
||||
|
||||
// 27) 可逆灰阶不能作为白字实心按钮底。gray-700/800/900 在深色模式下会
|
||||
// 被反转成近白色,`bg-gray-900 text-white` 因而只剩约 1:1。
|
||||
const componentSources = readdirSync(compDir)
|
||||
.filter(x => x.endsWith('.tsx'))
|
||||
.map(f => [f, readFileSync(join(compDir, f), 'utf8')]);
|
||||
const graySolid = componentSources.flatMap(([f, src]) =>
|
||||
(src.match(/(?:bg-gray-(?:700|800|900)[^'"\n]*text-white|text-white[^'"\n]*bg-gray-(?:700|800|900))/g) || [])
|
||||
.map(hit => `${f}:${hit}`)
|
||||
);
|
||||
check('白字实心控件不使用可逆 gray 底色', graySolid.length === 0, graySolid.slice(0, 4).join(' | '));
|
||||
|
||||
// 28) 未映射色族会绕过 CSS 变量主题,浅色值会原样落进深色页面。
|
||||
const unmapped = componentSources.flatMap(([f, src]) =>
|
||||
(src.match(/(?:bg|text|border)-(?:emerald|purple)-\d+/g) || []).map(hit => `${f}:${hit}`)
|
||||
);
|
||||
check('组件未使用未映射的 emerald/purple 色族', unmapped.length === 0, unmapped.slice(0, 6).join(' | '));
|
||||
|
||||
// 29) 本地 markdown 层替代未安装 typography 插件时无效的 prose 类。
|
||||
const inertProse = componentSources.flatMap(([f, src]) =>
|
||||
(src.match(/\bprose(?:-sm)?\b/g) || []).map(hit => `${f}:${hit}`)
|
||||
);
|
||||
check('Markdown 内容使用本地 .markdown 排版层', inertProse.length === 0, inertProse.slice(0, 4).join(' | '));
|
||||
|
||||
// 30) gray-400 承担 9–12px 元数据与 placeholder,白卡片上也必须达到 4.5:1。
|
||||
const lightCard = rgbOf(lightBlock, 'white');
|
||||
const lightSecondary = rgbOf(lightBlock, 'gray-400');
|
||||
const secondaryContrast = lightCard && lightSecondary ? contrast(lightCard, lightSecondary) : 0;
|
||||
check(
|
||||
'浅色 gray-400 在白卡片上达到 4.5:1',
|
||||
secondaryContrast >= 4.5,
|
||||
secondaryContrast.toFixed(2)
|
||||
);
|
||||
|
||||
console.log(`\n主题:${pass} 通过${fail ? `,${fail} 失败` : ''}`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
@ -12,6 +12,10 @@ import react from '@vitejs/plugin-react';
|
||||
* 它们不需要 DOM,套一层 vitest 只是变慢。
|
||||
*/
|
||||
export default defineConfig({
|
||||
// 测试必须使用含 act() 的 React 开发构建;宿主进程可能继承 NODE_ENV=production。
|
||||
define: {
|
||||
'process.env.NODE_ENV': JSON.stringify('test')
|
||||
},
|
||||
plugins: [react()],
|
||||
test: {
|
||||
include: ['test/components/**/*.test.tsx'],
|
||||
12
client/harmony/.gitignore
vendored
Normal file
12
client/harmony/.gitignore
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
/node_modules
|
||||
/oh_modules
|
||||
/local.properties
|
||||
/.idea
|
||||
**/build
|
||||
/.hvigor
|
||||
.cxx
|
||||
/.clangd
|
||||
/.clang-format
|
||||
/.clang-tidy
|
||||
**/.test
|
||||
/.appanalyzer
|
||||
10
client/harmony/AppScope/app.json5
Normal file
10
client/harmony/AppScope/app.json5
Normal 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"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
{
|
||||
"string": [
|
||||
{
|
||||
"name": "app_name",
|
||||
"value": "AgentMailHarmony"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
client/harmony/AppScope/resources/base/media/background.png
Normal file
BIN
client/harmony/AppScope/resources/base/media/background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 65 B |
BIN
client/harmony/AppScope/resources/base/media/foreground.png
Normal file
BIN
client/harmony/AppScope/resources/base/media/foreground.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 65 B |
@ -0,0 +1,7 @@
|
||||
{
|
||||
"layered-image":
|
||||
{
|
||||
"background" : "$media:background",
|
||||
"foreground" : "$media:foreground"
|
||||
}
|
||||
}
|
||||
42
client/harmony/build-profile.json5
Normal file
42
client/harmony/build-profile.json5
Normal 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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
32
client/harmony/code-linter.json5
Normal file
32
client/harmony/code-linter.json5
Normal 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
6
client/harmony/entry/.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
/node_modules
|
||||
/oh_modules
|
||||
/.preview
|
||||
/build
|
||||
/.cxx
|
||||
/.test
|
||||
33
client/harmony/entry/build-profile.json5
Normal file
33
client/harmony/entry/build-profile.json5
Normal 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",
|
||||
}
|
||||
]
|
||||
}
|
||||
7
client/harmony/entry/hvigorfile.ts
Normal file
7
client/harmony/entry/hvigorfile.ts
Normal 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. */,
|
||||
};
|
||||
20
client/harmony/entry/obfuscation-rules.txt
Normal file
20
client/harmony/entry/obfuscation-rules.txt
Normal 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
|
||||
10
client/harmony/entry/oh-package.json5
Normal file
10
client/harmony/entry/oh-package.json5
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "entry",
|
||||
"version": "1.0.0",
|
||||
"description": "Please describe the basic information.",
|
||||
"main": "",
|
||||
"author": "",
|
||||
"license": "",
|
||||
"dependencies": {}
|
||||
}
|
||||
|
||||
191
client/harmony/entry/src/main/ets/api/AccountManager.ets
Normal file
191
client/harmony/entry/src/main/ets/api/AccountManager.ets
Normal 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();
|
||||
}
|
||||
}
|
||||
291
client/harmony/entry/src/main/ets/api/ApiClient.ets
Normal file
291
client/harmony/entry/src/main/ets/api/ApiClient.ets
Normal file
@ -0,0 +1,291 @@
|
||||
/*
|
||||
* AgentMail 鸿蒙客户端 — 统一 API 客户端
|
||||
* 对应 WebUI src/api/client.ts:base + 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 实例,保住 Cookie(login 后 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) {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
}
|
||||
106
client/harmony/entry/src/main/ets/api/AuthApi.ets
Normal file
106
client/harmony/entry/src/main/ets/api/AuthApi.ets
Normal 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);
|
||||
}
|
||||
}
|
||||
155
client/harmony/entry/src/main/ets/api/MailApi.ets
Normal file
155
client/harmony/entry/src/main/ets/api/MailApi.ets
Normal 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;
|
||||
}
|
||||
}
|
||||
308
client/harmony/entry/src/main/ets/api/SseService.ets
Normal file
308
client/harmony/entry/src/main/ets/api/SseService.ets
Normal 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;
|
||||
}
|
||||
}
|
||||
15
client/harmony/entry/src/main/ets/common/Config.ets
Normal file
15
client/harmony/entry/src/main/ets/common/Config.ets
Normal file
@ -0,0 +1,15 @@
|
||||
/*
|
||||
* AgentMail 鸿蒙客户端 — 全局配置
|
||||
* apiBase 可运行时修改(设置页/登录页),persist 到 preferences
|
||||
*/
|
||||
|
||||
/** 默认联调 Gateway(pi 提供,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
Reference in New Issue
Block a user