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 进
|
# 前端只在**构建期**用到 npm:`npm run build` 出的 dist/ 被 cp 进
|
||||||
# gateway/internal/static/static/ 再由 go:embed 编进二进制。
|
# server/internal/static/static/ 再由 go:embed 编进二进制。
|
||||||
# 部署机上没有 node,产物就是「一个二进制 + 一个 .db 文件」。
|
# 部署机上没有 node,产物就是「一个二进制 + 一个 .db 文件」。
|
||||||
# 因此这三样都不进版本库:装依赖与构建都能从 package-lock.json 复现。
|
# 因此这三样都不进版本库:装依赖与构建都能从 package-lock.json 复现。
|
||||||
web/node_modules/
|
client/electron/node_modules/
|
||||||
web/dist/
|
client/electron/dist/
|
||||||
plugins/*/node_modules/
|
plugins/*/node_modules/
|
||||||
|
|
||||||
# dsh-mail-bridge 是 TypeScript 写的,dist/ 是 tsc 的产物。
|
# dsh-mail-bridge 是 TypeScript 写的,dist/ 是 tsc 的产物。
|
||||||
# 与 web/dist 同一个理由:装依赖与构建都能从 package-lock.json 复现,
|
# 与 client/electron/dist 同一个理由:装依赖与构建都能从 package-lock.json 复现,
|
||||||
# 产物不进版本库。注意它**必须在装插件前构建**(package.json 的
|
# 产物不进版本库。注意它**必须在装插件前构建**(package.json 的
|
||||||
# main 指向 dist/index.js),deploy/install.sh 负责这一步。
|
# main 指向 dist/index.js),deploy/install.sh 负责这一步。
|
||||||
plugins/*/dist/
|
plugins/*/dist/
|
||||||
|
|
||||||
# go:embed 的输入目录 = web/dist 的副本,同属构建产物。
|
# go:embed 的输入目录 = client/electron/dist 的副本,同属构建产物。
|
||||||
#
|
#
|
||||||
# 但目录本身要留下:go:embed 要求它存在才能编译,否则新克隆连
|
# 但目录本身要留下:go:embed 要求它存在才能编译,否则新克隆连
|
||||||
# `go test ./...` 都跑不起来 —— 只改后端的人不该被迫先装 node。
|
# `go test ./...` 都跑不起来 —— 只改后端的人不该被迫先装 node。
|
||||||
# 因此忽略构建产物,只保留 placeholder.html(见该文件内注释:
|
# 因此忽略构建产物,只保留 placeholder.html(见该文件内注释:
|
||||||
# 之所以不叫 index.html,是因为那正是 Vite 产物的名字,会被反复覆盖)。
|
# 之所以不叫 index.html,是因为那正是 Vite 产物的名字,会被反复覆盖)。
|
||||||
gateway/internal/static/static/*
|
server/internal/static/static/*
|
||||||
!gateway/internal/static/static/placeholder.html
|
!server/internal/static/static/placeholder.html
|
||||||
|
|
||||||
# Go 构建产物
|
# Go 构建产物
|
||||||
gateway/agentmail-gateway
|
server/agentmail-gateway
|
||||||
gateway/gw
|
server/gw
|
||||||
*.test
|
*.test
|
||||||
|
|
||||||
# ---- 运行态数据 ----
|
# ---- 运行态数据 ----
|
||||||
|
|||||||
30
README.md
30
README.md
@ -32,12 +32,12 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 后端:默认用内置 SQLite,无需任何外部依赖
|
# 后端:默认用内置 SQLite,无需任何外部依赖
|
||||||
cd gateway
|
cd server
|
||||||
ADMIN_USER=admin ADMIN_PASSWORD=你的密码 go run ./cmd/server
|
ADMIN_USER=admin ADMIN_PASSWORD=你的密码 go run ./cmd/server
|
||||||
# → http://localhost:8180
|
# → http://localhost:8180
|
||||||
|
|
||||||
# 前端(另开一个终端)
|
# 前端(另开一个终端)
|
||||||
cd web
|
cd client/electron
|
||||||
npm install
|
npm install
|
||||||
npm run dev # → http://localhost:5173,自动代理到 8180
|
npm run dev # → http://localhost:5173,自动代理到 8180
|
||||||
```
|
```
|
||||||
@ -75,10 +75,10 @@ bash deploy/redeploy-gateway.sh # 落地
|
|||||||
### 构建期依赖
|
### 构建期依赖
|
||||||
|
|
||||||
npm 只在构建期用到:Node ≥ 18(`npm run build`)与 Go ≥ 1.22。
|
npm 只在构建期用到:Node ≥ 18(`npm run build`)与 Go ≥ 1.22。
|
||||||
`web/dist` 会被复制进 `gateway/internal/static/static/` 再由 `go:embed` 编入二进制,
|
`client/electron/dist` 会被复制进 `server/internal/static/static/` 再由 `go:embed` 编入二进制,
|
||||||
**部署机上不需要 node**。
|
**部署机上不需要 node**。
|
||||||
|
|
||||||
`web/node_modules`、`web/dist`、`gateway/internal/static/static/` 的内容与编译出的二进制
|
`client/electron/node_modules`、`client/electron/dist`、`server/internal/static/static/` 的内容与编译出的二进制
|
||||||
都是构建产物,不进版本库(见 `.gitignore`)。
|
都是构建产物,不进版本库(见 `.gitignore`)。
|
||||||
|
|
||||||
新克隆**可以直接 `go build` / `go test`**:`static/` 目录里留了一个占位 index.html
|
新克隆**可以直接 `go build` / `go test`**:`static/` 目录里留了一个占位 index.html
|
||||||
@ -87,9 +87,13 @@ npm 只在构建期用到:Node ≥ 18(`npm run build`)与 Go ≥ 1.22。
|
|||||||
`deploy/install.sh`,或手工:
|
`deploy/install.sh`,或手工:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd web && npm ci && npm run build
|
# 在仓库根目录执行
|
||||||
rm -rf ../gateway/internal/static/static && cp -r dist ../gateway/internal/static/static
|
npm --prefix client/electron ci
|
||||||
cd ../gateway && go build ./cmd/server
|
npm --prefix client/electron run build
|
||||||
|
rm -rf server/internal/static/static/assets
|
||||||
|
rm -f server/internal/static/static/index.html
|
||||||
|
cp -r client/electron/dist/. server/internal/static/static/
|
||||||
|
(cd server && go build ./cmd/server)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 数据库
|
### 数据库
|
||||||
@ -186,7 +190,7 @@ agentmail/
|
|||||||
│ ├── PLAN.md # 分阶段实施计划
|
│ ├── PLAN.md # 分阶段实施计划
|
||||||
│ ├── MVP-SPEC.md # MVP 技术规格书
|
│ ├── MVP-SPEC.md # MVP 技术规格书
|
||||||
│ └── PLUGIN-CONTRACT.md # Agent 平台插件契约(规格 + 验收清单)
|
│ └── PLUGIN-CONTRACT.md # Agent 平台插件契约(规格 + 验收清单)
|
||||||
├── gateway/ # 后端(Go,单二进制)
|
├── server/ # 后端(Go,单二进制)
|
||||||
│ ├── cmd/server/ # 入口与路由表
|
│ ├── cmd/server/ # 入口与路由表
|
||||||
│ └── internal/
|
│ └── internal/
|
||||||
│ ├── db/ # 连接 + 方言适配 + 内嵌迁移
|
│ ├── db/ # 连接 + 方言适配 + 内嵌迁移
|
||||||
@ -200,8 +204,10 @@ agentmail/
|
|||||||
│ ├── opencode-mail-bridge/ # opencode(插件)
|
│ ├── opencode-mail-bridge/ # opencode(插件)
|
||||||
│ ├── dsh-mail-bridge/ # DeepSeek Harness(Cordis 插件)
|
│ ├── dsh-mail-bridge/ # DeepSeek Harness(Cordis 插件)
|
||||||
│ └── pi-mail-bridge/ # pi(常驻守护进程,用 SDK 起会话)
|
│ └── pi-mail-bridge/ # pi(常驻守护进程,用 SDK 起会话)
|
||||||
├── web/ # 前端(React + Vite + Tailwind)
|
├── client/
|
||||||
│ └── test/manual/ # 浏览器实测脚本(量真实盒子与命中区,不进 npm test)
|
│ ├── electron/ # Electron + React + Vite + Tailwind 客户端
|
||||||
|
│ │ └── test/manual/ # 浏览器实测脚本(量真实盒子与命中区,不进 npm test)
|
||||||
|
│ └── harmony/ # HarmonyOS ArkUI 客户端
|
||||||
└── deploy/ # systemd 单元 + 安装脚本
|
└── deploy/ # systemd 单元 + 安装脚本
|
||||||
├── redeploy-gateway.sh # 二进制热替换(.backup + 原子 install + 后置验证 + 自动回滚)
|
├── redeploy-gateway.sh # 二进制热替换(.backup + 原子 install + 后置验证 + 自动回滚)
|
||||||
└── remote-agent-demo.py # 最小跨主机 Agent(纯标准库,验证协议层能力)
|
└── remote-agent-demo.py # 最小跨主机 Agent(纯标准库,验证协议层能力)
|
||||||
@ -223,8 +229,8 @@ agentmail/
|
|||||||
## 验证
|
## 验证
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd gateway && go build ./... && go test ./... # 后端
|
cd server && go build ./... && go test ./... # 后端
|
||||||
cd web && npm run typecheck && npm test # 前端(含 Markdown XSS 回归测试)
|
cd client/electron && npm run typecheck && npm test # 前端(含 Markdown XSS 回归测试)
|
||||||
```
|
```
|
||||||
|
|
||||||
## WebAPI
|
## WebAPI
|
||||||
|
|||||||
@ -5,10 +5,10 @@
|
|||||||
* - 创建 BrowserWindow 加载前端(dev=<ELECTRON_START_URL>,prod=../dist/index.html)
|
* - 创建 BrowserWindow 加载前端(dev=<ELECTRON_START_URL>,prod=../dist/index.html)
|
||||||
* - 系统托盘(Phase 4 完整实现)
|
* - 系统托盘(Phase 4 完整实现)
|
||||||
* - 离线缓存(Phase 4)
|
* - 离线缓存(Phase 4)
|
||||||
* - 把 Gateway 地址与用户密钥注入渲染进程(Phase 2:让 web/src 以独立客户端身份连任意 Gateway)
|
* - 把 Gateway 地址与用户密钥注入渲染进程(Phase 2:让 client/electron/src 以独立客户端身份连任意 Gateway)
|
||||||
*
|
*
|
||||||
* 设计约束:
|
* 设计约束:
|
||||||
* - 渲染进程复用 web/src 的 React 代码,零改动
|
* - 渲染进程复用 client/electron/src 的 React 代码,零改动
|
||||||
* - 只通过 contextBridge 暴露必要 API,不放开 nodeIntegration
|
* - 只通过 contextBridge 暴露必要 API,不放开 nodeIntegration
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* AgentMail Electron preload —— 通过 contextBridge 暴露安全 API 给渲染进程。
|
* AgentMail Electron preload —— 通过 contextBridge 暴露安全 API 给渲染进程。
|
||||||
*
|
*
|
||||||
* 渲染进程是 web/src 的 React,跑在 sandbox: true 下(无 nodeIntegration)。
|
* 渲染进程是 client/electron/src 的 React,跑在 sandbox: true 下(无 nodeIntegration)。
|
||||||
* 需要桌面能力时(文件选择、通知、托盘角标、SSE 转发)走这里暴露的桥。
|
* 需要桌面能力时(文件选择、通知、托盘角标、SSE 转发)走这里暴露的桥。
|
||||||
*
|
*
|
||||||
* Phase 1 只暴露最小集;后续 Phase 逐步加。
|
* Phase 1 只暴露最小集;后续 Phase 逐步加。
|
||||||
@ -2,7 +2,7 @@
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||||
<title>AgentMail</title>
|
<title>AgentMail</title>
|
||||||
<script>
|
<script>
|
||||||
/*
|
/*
|
||||||
@ -196,7 +196,7 @@ function PaneLoading() {
|
|||||||
*/
|
*/
|
||||||
function NarrowShell({ children }: { children: React.ReactNode }) {
|
function NarrowShell({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col bg-gray-50 overflow-hidden">
|
<div className="h-full flex flex-col bg-gray-50 overflow-hidden safe-frame">
|
||||||
{children}
|
{children}
|
||||||
<NarrowNav />
|
<NarrowNav />
|
||||||
</div>
|
</div>
|
||||||
@ -30,6 +30,7 @@ export default function AddressInput({
|
|||||||
const [meta, setMeta] = useState<SessionCandidate[]>([]);
|
const [meta, setMeta] = useState<SessionCandidate[]>([]);
|
||||||
const [kind, setKind] = useState<'name' | 'path' | 'session'>('name');
|
const [kind, setKind] = useState<'name' | 'path' | 'session'>('name');
|
||||||
const [active, setActive] = useState(0);
|
const [active, setActive] = useState(0);
|
||||||
|
const [menuLayout, setMenuLayout] = useState({ flip: false, maxHeight: 288 });
|
||||||
const boxRef = useRef<HTMLDivElement>(null);
|
const boxRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// 当前正在编辑的那一段(多地址时取最后一段)
|
// 当前正在编辑的那一段(多地址时取最后一段)
|
||||||
@ -96,6 +97,34 @@ export default function AddressInput({
|
|||||||
return () => document.removeEventListener('mousedown', onDocClick);
|
return () => document.removeEventListener('mousedown', onDocClick);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 补全菜单根据 Visual Viewport 剩余空间向上翻转;软键盘出现后也实时重算。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || items.length === 0) return;
|
||||||
|
const update = () => {
|
||||||
|
const box = boxRef.current;
|
||||||
|
if (!box) return;
|
||||||
|
const rect = box.getBoundingClientRect();
|
||||||
|
const viewport = window.visualViewport;
|
||||||
|
const top = viewport?.offsetTop ?? 0;
|
||||||
|
const bottom = top + (viewport?.height ?? window.innerHeight);
|
||||||
|
const above = Math.max(0, rect.top - top - 8);
|
||||||
|
const below = Math.max(0, bottom - rect.bottom - 8);
|
||||||
|
const flip = below < Math.min(240, above) && above > below;
|
||||||
|
setMenuLayout({ flip, maxHeight: Math.max(96, Math.min(288, flip ? above : below)) });
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
window.addEventListener('resize', update, { passive: true });
|
||||||
|
window.addEventListener('scroll', update, { passive: true, capture: true });
|
||||||
|
window.visualViewport?.addEventListener('resize', update, { passive: true });
|
||||||
|
window.visualViewport?.addEventListener('scroll', update, { passive: true });
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', update);
|
||||||
|
window.removeEventListener('scroll', update, { capture: true });
|
||||||
|
window.visualViewport?.removeEventListener('resize', update);
|
||||||
|
window.visualViewport?.removeEventListener('scroll', update);
|
||||||
|
};
|
||||||
|
}, [open, items.length]);
|
||||||
|
|
||||||
/** 选中一个候选后拼回完整地址 */
|
/** 选中一个候选后拼回完整地址 */
|
||||||
const apply = (choice: string) => {
|
const apply = (choice: string) => {
|
||||||
let next: string;
|
let next: string;
|
||||||
@ -151,7 +180,12 @@ export default function AddressInput({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{open && items.length > 0 && (
|
{open && items.length > 0 && (
|
||||||
<div className="absolute z-20 mt-1 w-full max-h-72 overflow-y-auto bg-white border border-gray-200 rounded-md shadow-lg">
|
<div
|
||||||
|
className={`absolute z-20 w-full overflow-y-auto bg-white border border-gray-200 rounded-md shadow-lg ${
|
||||||
|
menuLayout.flip ? 'bottom-full mb-1' : 'top-full mt-1'
|
||||||
|
}`}
|
||||||
|
style={{ maxHeight: menuLayout.maxHeight }}
|
||||||
|
>
|
||||||
<div className="px-2.5 py-1 text-[10px] text-gray-400 border-b border-gray-100">
|
<div className="px-2.5 py-1 text-[10px] text-gray-400 border-b border-gray-100">
|
||||||
{hint}
|
{hint}
|
||||||
</div>
|
</div>
|
||||||
@ -182,7 +216,7 @@ export default function AddressInput({
|
|||||||
一条已经在跑的会话,而不是继续一条已有的邮件往来 */}
|
一条已经在跑的会话,而不是继续一条已有的邮件往来 */}
|
||||||
{c?.source === 'platform' && (
|
{c?.source === 'platform' && (
|
||||||
<span
|
<span
|
||||||
className="shrink-0 px-1 py-0.5 rounded bg-purple-100 text-purple-700 text-[9px]"
|
className="shrink-0 px-1 py-0.5 rounded bg-blue-100 text-blue-700 text-[9px]"
|
||||||
title="平台侧已有的会话,本站还没有对应的邮件往来"
|
title="平台侧已有的会话,本站还没有对应的邮件往来"
|
||||||
>
|
>
|
||||||
平台
|
平台
|
||||||
@ -192,7 +226,7 @@ export default function AddressInput({
|
|||||||
<span className="shrink-0 text-[10px] text-gray-400 font-sans">新建会话</span>
|
<span className="shrink-0 text-[10px] text-gray-400 font-sans">新建会话</span>
|
||||||
)}
|
)}
|
||||||
{(c?.unread ?? 0) > 0 && (
|
{(c?.unread ?? 0) > 0 && (
|
||||||
<span className="shrink-0 px-1 py-0.5 rounded bg-red-500 text-white text-[9px]">
|
<span className="shrink-0 px-1 py-0.5 rounded bg-red-600 text-white text-[9px]">
|
||||||
{c!.unread}
|
{c!.unread}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -173,7 +173,7 @@ function TabButton({ active, onClick, children }: {
|
|||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`tap flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md ${
|
className={`tap flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md ${
|
||||||
active ? 'bg-gray-900 text-white' : 'text-gray-600 hover:bg-gray-100'
|
active ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@ -195,7 +195,7 @@ function UserCard({ user, scopes, expanded, onToggle, onSaved, onReload, setErro
|
|||||||
</button>
|
</button>
|
||||||
<span className="font-mono text-sm text-gray-900 min-w-[100px]">{user.username}</span>
|
<span className="font-mono text-sm text-gray-900 min-w-[100px]">{user.username}</span>
|
||||||
<span className="tap text-[11px] text-gray-500">{user.display_name}</span>
|
<span className="tap text-[11px] text-gray-500">{user.display_name}</span>
|
||||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${user.role === 'admin' ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-600'}`}>
|
<span className={`text-[10px] px-1.5 py-0.5 rounded ${user.role === 'admin' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'}`}>
|
||||||
{user.role === 'admin' ? '管理员' : '用户'}
|
{user.role === 'admin' ? '管理员' : '用户'}
|
||||||
</span>
|
</span>
|
||||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${user.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-gray-200 text-gray-500'}`}>
|
<span className={`text-[10px] px-1.5 py-0.5 rounded ${user.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-gray-200 text-gray-500'}`}>
|
||||||
@ -317,12 +317,12 @@ function UserEditor({ user, scopes, onSaved, onReload, setError }: {
|
|||||||
<button onClick={save} disabled={busy} className="tap px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 transition-colors">
|
<button onClick={save} disabled={busy} className="tap px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 transition-colors">
|
||||||
{busy ? '保存中' : '保存更改'}
|
{busy ? '保存中' : '保存更改'}
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-1.5 ml-auto">
|
<div className="flex items-center gap-1.5 ml-auto flex-wrap">
|
||||||
<LockIcon className="w-3 h-3 text-gray-400" />
|
<LockIcon className="w-3 h-3 text-gray-400" />
|
||||||
<input type="password" value={pw} onChange={e => setPw(e.target.value)} placeholder="新密码(至少 8 位)"
|
<input type="password" value={pw} onChange={e => setPw(e.target.value)} placeholder="新密码(至少 8 位)"
|
||||||
className="w-40 text-xs border border-gray-300 rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400" />
|
className="w-40 text-xs border border-gray-300 rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400" />
|
||||||
<button onClick={resetPassword} disabled={pw.length < 8 || busy}
|
<button onClick={resetPassword} disabled={pw.length < 8 || busy}
|
||||||
className="tap inline-flex items-center gap-1 px-2 py-1 text-[11px] rounded bg-gray-700 text-white hover:bg-gray-800 disabled:opacity-40">
|
className="tap inline-flex items-center gap-1 px-2 py-1 text-[11px] rounded bg-chrome-700 text-white hover:bg-chrome-800 disabled:opacity-40">
|
||||||
<CheckIcon className="w-3 h-3" /> 重置
|
<CheckIcon className="w-3 h-3" /> 重置
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -114,32 +114,34 @@ export function AttachmentPicker({
|
|||||||
onChange={e => handleFiles(e.target.files)}
|
onChange={e => handleFiles(e.target.files)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 flex-wrap min-w-0">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={pick}
|
onClick={pick}
|
||||||
disabled={disabled || uploading !== null}
|
disabled={disabled || uploading !== null}
|
||||||
className="tap inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-40"
|
className="tap shrink-0 inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
<PaperclipIcon className="w-3.5 h-3.5" />
|
<PaperclipIcon className="w-3.5 h-3.5" />
|
||||||
添加附件
|
添加附件
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{uploading && (
|
{uploading && (
|
||||||
<span className="inline-flex items-center gap-1.5 text-[11px] text-gray-500">
|
<span className="min-w-0 flex-1 inline-flex items-center gap-1.5 text-[11px] text-gray-500">
|
||||||
<SpinnerIcon className="w-3.5 h-3.5 animate-spin" />
|
<SpinnerIcon className="w-3.5 h-3.5 animate-spin shrink-0" />
|
||||||
{uploading.name} {uploading.pct}%
|
<span className="truncate">{uploading.name}</span>
|
||||||
|
<span className="shrink-0">{uploading.pct}%</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{items.length > 0 && !uploading && (
|
{items.length > 0 && !uploading && (
|
||||||
<span className="text-[11px] text-gray-400">
|
<span className="text-[11px] text-gray-500 min-w-0 break-words">
|
||||||
{items.length} 个附件 ·{' '}
|
{items.length} 个附件 ·{' '}
|
||||||
{api.formatSize(items.reduce((sum, a) => sum + a.size, 0))}
|
{api.formatSize(items.reduce((sum, a) => sum + a.size, 0))}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="text-[11px] text-red-600">{error}</div>}
|
{error && <div className="text-[11px] text-red-600 break-words">{error}</div>}
|
||||||
|
|
||||||
{items.length > 0 && (
|
{items.length > 0 && (
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
@ -152,10 +154,11 @@ export function AttachmentPicker({
|
|||||||
<span className="text-xs text-gray-800 truncate flex-1">{a.filename}</span>
|
<span className="text-xs text-gray-800 truncate flex-1">{a.filename}</span>
|
||||||
<span className="text-[10px] text-gray-400 shrink-0">{api.formatSize(a.size)}</span>
|
<span className="text-[10px] text-gray-400 shrink-0">{api.formatSize(a.size)}</span>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => remove(a)}
|
onClick={() => remove(a)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
title="移除"
|
title="移除"
|
||||||
className="shrink-0 text-gray-400 hover:text-red-600 disabled:opacity-40"
|
className="tap shrink-0 inline-flex items-center justify-center text-gray-500 hover:text-red-600 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
<CloseIcon className="w-3.5 h-3.5" />
|
<CloseIcon className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@ -641,7 +641,7 @@ export default function CalendarEventEditor({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 px-4 py-3 border-t border-gray-200 shrink-0">
|
<div className="flex items-center gap-2 px-4 py-3 border-t border-gray-200 shrink-0 flex-wrap">
|
||||||
<button
|
<button
|
||||||
onClick={save}
|
onClick={save}
|
||||||
disabled={!canSave}
|
disabled={!canSave}
|
||||||
@ -658,9 +658,9 @@ export default function CalendarEventEditor({
|
|||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
{editing && (
|
{editing && (
|
||||||
<div className="ml-auto">
|
<div className="w-full sm:w-auto sm:ml-auto">
|
||||||
{confirmDelete ? (
|
{confirmDelete ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="text-xs text-gray-600">确定删除?</span>
|
<span className="text-xs text-gray-600">确定删除?</span>
|
||||||
<button
|
<button
|
||||||
onClick={remove}
|
onClick={remove}
|
||||||
@ -346,7 +346,7 @@ export default function CalendarView() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex-1 min-w-0 flex min-h-0">
|
<div className="flex-1 min-w-0 flex min-h-0">
|
||||||
{gridPane}
|
{gridPane}
|
||||||
<div className="w-full md:w-[400px] shrink-0 border-l border-gray-200 bg-white flex flex-col min-h-0">
|
<div className="w-full lg:w-[400px] shrink-0 border-l border-gray-200 bg-white flex flex-col min-h-0">
|
||||||
{sidePane}
|
{sidePane}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -7,6 +7,7 @@ import NarrowOnly from './NarrowOnly';
|
|||||||
import { useMailStore } from '../stores/mailStore';
|
import { useMailStore } from '../stores/mailStore';
|
||||||
import { useSessionStore } from '../stores/sessionStore';
|
import { useSessionStore } from '../stores/sessionStore';
|
||||||
import { useContactStore } from '../stores/contactStore';
|
import { useContactStore } from '../stores/contactStore';
|
||||||
|
import { useIsNarrow } from '../hooks/useIsNarrow';
|
||||||
import AddressInput from './AddressInput';
|
import AddressInput from './AddressInput';
|
||||||
import { AttachmentPicker, type PendingAttachment } from './Attachments';
|
import { AttachmentPicker, type PendingAttachment } from './Attachments';
|
||||||
import { ComposeIcon, ChevronLeftIcon } from './icons';
|
import { ComposeIcon, ChevronLeftIcon } from './icons';
|
||||||
@ -19,6 +20,7 @@ export default function ComposePage() {
|
|||||||
const fetchSent = useMailStore(s => s.fetchSent);
|
const fetchSent = useMailStore(s => s.fetchSent);
|
||||||
const fetchSessions = useSessionStore(s => s.fetchSessions);
|
const fetchSessions = useSessionStore(s => s.fetchSessions);
|
||||||
const fetchContacts = useContactStore(s => s.fetchContacts);
|
const fetchContacts = useContactStore(s => s.fetchContacts);
|
||||||
|
const isNarrow = useIsNarrow();
|
||||||
|
|
||||||
const [to, setTo] = useState(prefill?.to ?? '');
|
const [to, setTo] = useState(prefill?.to ?? '');
|
||||||
const [cc, setCc] = useState(prefill?.cc ?? '');
|
const [cc, setCc] = useState(prefill?.cc ?? '');
|
||||||
@ -132,8 +134,8 @@ export default function ComposePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 min-w-0 flex flex-col bg-white">
|
<div className="flex-1 min-w-0 min-h-0 flex flex-col overflow-y-auto lg:overflow-hidden overscroll-contain bg-white">
|
||||||
<div className="px-4 md:px-6 py-3 border-b border-gray-200 flex items-center gap-2">
|
<div className="shrink-0 sticky top-0 z-10 lg:static px-4 md:px-6 py-3 border-b border-gray-200 bg-white flex items-center gap-2">
|
||||||
{/* 窄屏下写信是盖在列表上的覆盖层,得有个退出口。
|
{/* 窄屏下写信是盖在列表上的覆盖层,得有个退出口。
|
||||||
用 cancelCompose 而不是 showList:写信态本身要一起结束,
|
用 cancelCompose 而不是 showList:写信态本身要一起结束,
|
||||||
只滑走覆盖层的话下次进列表又会弹回来 */}
|
只滑走覆盖层的话下次进列表又会弹回来 */}
|
||||||
@ -169,12 +171,12 @@ export default function ComposePage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-4 md:px-6 py-4 space-y-3 border-b border-gray-200">
|
<div className="shrink-0 px-4 md:px-6 py-4 space-y-3 border-b border-gray-200">
|
||||||
<Field label="收件人" hint="name@path.session:省略=默认会话,new=新建,别名=已有会话">
|
<Field label="收件人" hint="name@path.session:省略=默认会话,new=新建,别名=已有会话">
|
||||||
<AddressInput
|
<AddressInput
|
||||||
value={to}
|
value={to}
|
||||||
onChange={setTo}
|
onChange={setTo}
|
||||||
autoFocus
|
autoFocus={!isNarrow}
|
||||||
placeholder="deepseekharness@/program.upadtefeature"
|
placeholder="deepseekharness@/program.upadtefeature"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
@ -247,8 +249,8 @@ export default function ComposePage() {
|
|||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-h-0 px-4 md:px-6 py-3 flex flex-col">
|
<div className="shrink-0 lg:flex-1 lg:min-h-0 px-4 md:px-6 py-3 flex flex-col">
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
<div className="shrink-0 flex items-center gap-2 mb-1.5">
|
||||||
<span className="text-[11px] font-medium text-gray-500">正文(Markdown)</span>
|
<span className="text-[11px] font-medium text-gray-500">正文(Markdown)</span>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Toggle active={!preview} onClick={() => setPreview(false)}>
|
<Toggle active={!preview} onClick={() => setPreview(false)}>
|
||||||
@ -260,7 +262,7 @@ export default function ComposePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{preview ? (
|
{preview ? (
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto border border-gray-200 rounded-md p-4 prose prose-sm max-w-none">
|
<div className="markdown flex-1 min-h-[12rem] lg:min-h-0 overflow-y-auto border border-gray-200 rounded-md p-4">
|
||||||
{body.trim() ? (
|
{body.trim() ? (
|
||||||
<Markdown remarkPlugins={[remarkGfm]}>{body}</Markdown>
|
<Markdown remarkPlugins={[remarkGfm]}>{body}</Markdown>
|
||||||
) : (
|
) : (
|
||||||
@ -272,19 +274,21 @@ export default function ComposePage() {
|
|||||||
value={body}
|
value={body}
|
||||||
onChange={e => setBody(e.target.value)}
|
onChange={e => setBody(e.target.value)}
|
||||||
placeholder={'## 需求\n\n请在 /program 下推进 update feature…'}
|
placeholder={'## 需求\n\n请在 /program 下推进 update feature…'}
|
||||||
className="flex-1 min-h-0 w-full text-sm font-mono border border-gray-300 rounded-md p-4 resize-none focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
className="flex-1 min-h-[12rem] lg:min-h-0 w-full text-sm font-mono border border-gray-300 rounded-md p-4 resize-y lg:resize-none focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-4 md:px-6 pb-3">
|
{/* 窄屏底部操作栏是 sticky,会在矮视口里向上吸附;预留一栏高度,
|
||||||
|
避免它覆盖附件按钮。桌面操作栏回到普通文档流,不需要这段缓冲。 */}
|
||||||
|
<div className="shrink-0 px-4 md:px-6 pb-20 lg:pb-3">
|
||||||
<AttachmentPicker items={attachments} onChange={setAttachments} disabled={sending} />
|
<AttachmentPicker items={attachments} onChange={setAttachments} disabled={sending} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-4 md:px-6 py-3 border-t border-gray-200 flex items-center gap-3">
|
<div className="shrink-0 sticky bottom-0 z-10 lg:static px-4 md:px-6 py-3 border-t border-gray-200 bg-white flex items-center gap-3 flex-wrap">
|
||||||
{error && <span className="text-xs text-red-600">{error}</span>}
|
{error && <span className="min-w-0 text-xs text-red-600 break-words">{error}</span>}
|
||||||
{okMsg && <span className="text-xs text-green-600">{okMsg}</span>}
|
{okMsg && <span className="min-w-0 text-xs text-green-700 break-words">{okMsg}</span>}
|
||||||
<div className="flex-1" />
|
<div className="flex-1 min-w-2" />
|
||||||
<button
|
<button
|
||||||
onClick={cancelCompose}
|
onClick={cancelCompose}
|
||||||
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900"
|
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900"
|
||||||
@ -336,7 +340,7 @@ function Toggle({
|
|||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`tap text-[11px] px-2 py-0.5 rounded ${
|
className={`tap text-[11px] px-2 py-0.5 rounded ${
|
||||||
active ? 'bg-gray-900 text-white' : 'text-gray-500 hover:text-gray-800'
|
active ? 'bg-blue-600 text-white' : 'text-gray-500 hover:text-gray-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@ -58,7 +58,7 @@ export default function ContactPanel() {
|
|||||||
<div
|
<div
|
||||||
className={`w-full shrink-0 border-r border-gray-200 bg-white flex flex-col min-w-0 ${
|
className={`w-full shrink-0 border-r border-gray-200 bg-white flex flex-col min-w-0 ${
|
||||||
// 卡片要放两行摘要 + 预算条,320px 会挤;列表视图保持紧凑
|
// 卡片要放两行摘要 + 预算条,320px 会挤;列表视图保持紧凑
|
||||||
view === 'card' ? 'md:w-[400px]' : 'md:w-[320px]'
|
view === 'card' ? 'lg:w-[400px]' : 'lg:w-[320px]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="px-4 py-3 border-b border-gray-200 flex items-center gap-1">
|
<div className="px-4 py-3 border-b border-gray-200 flex items-center gap-1">
|
||||||
@ -82,7 +82,7 @@ export default function ContactPanel() {
|
|||||||
<button
|
<button
|
||||||
onClick={toggleArchivedView}
|
onClick={toggleArchivedView}
|
||||||
className={`tap text-[11px] px-1.5 py-0.5 rounded ${
|
className={`tap text-[11px] px-1.5 py-0.5 rounded ${
|
||||||
showArchived ? 'bg-gray-900 text-white' : 'text-gray-500 hover:text-gray-800'
|
showArchived ? 'bg-blue-600 text-white' : 'text-gray-500 hover:text-gray-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
归档
|
归档
|
||||||
@ -237,7 +237,7 @@ function ContactRow({
|
|||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-gray-400 font-mono truncate">{contact.path}</span>
|
<span className="text-[10px] text-gray-400 font-mono truncate">{contact.path}</span>
|
||||||
{contact.unread_count > 0 && (
|
{contact.unread_count > 0 && (
|
||||||
<span className="ml-auto shrink-0 min-w-[16px] h-4 px-1 rounded-full bg-blue-500 text-white text-[9px] font-bold flex items-center justify-center">
|
<span className="ml-auto shrink-0 min-w-[16px] h-4 px-1 rounded-full bg-blue-600 text-white text-[9px] font-bold flex items-center justify-center">
|
||||||
{contact.unread_count}
|
{contact.unread_count}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -63,7 +63,7 @@ export default function MailList() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full md:w-[320px] shrink-0 border-r border-gray-200 bg-white flex flex-col min-w-0">
|
<div className="w-full lg:w-[320px] shrink-0 border-r border-gray-200 bg-white flex flex-col min-w-0">
|
||||||
<div className="px-4 py-3 border-b border-gray-200 flex items-center gap-1">
|
<div className="px-4 py-3 border-b border-gray-200 flex items-center gap-1">
|
||||||
<h2 className="text-sm font-semibold text-gray-800">{isSent ? '发件箱' : '收件箱'}</h2>
|
<h2 className="text-sm font-semibold text-gray-800">{isSent ? '发件箱' : '收件箱'}</h2>
|
||||||
{/* 显示「会话数 · 邮件数」而不是只显示邮件数:分组之后前者才是
|
{/* 显示「会话数 · 邮件数」而不是只显示邮件数:分组之后前者才是
|
||||||
@ -178,7 +178,7 @@ function SessionGroup({
|
|||||||
|
|
||||||
<div className="flex items-center gap-1.5 mt-0.5 pl-5">
|
<div className="flex items-center gap-1.5 mt-0.5 pl-5">
|
||||||
{g.unreadCount > 0 && (
|
{g.unreadCount > 0 && (
|
||||||
<span className="shrink-0 min-w-[16px] h-4 px-1 rounded-full bg-blue-500 text-white text-[9px] font-bold flex items-center justify-center">
|
<span className="shrink-0 min-w-[16px] h-4 px-1 rounded-full bg-blue-600 text-white text-[9px] font-bold flex items-center justify-center">
|
||||||
{g.unreadCount}
|
{g.unreadCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -200,7 +200,7 @@ function SessionGroup({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="pl-3 pr-1 pb-1.5 space-y-0.5">
|
<div className="pl-5 pr-1 pb-1.5 space-y-0.5">
|
||||||
{g.mails.map(m => (
|
{g.mails.map(m => (
|
||||||
<MailItem
|
<MailItem
|
||||||
key={m.mail_id}
|
key={m.mail_id}
|
||||||
@ -312,7 +312,7 @@ function MailItem({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!compact && mail.session_alias && (
|
{!compact && mail.session_alias && (
|
||||||
<span className="text-[10px] text-blue-500 font-mono">.{mail.session_alias}</span>
|
<span className="min-w-0 flex-1 truncate text-[10px] text-blue-600 font-mono">.{mail.session_alias}</span>
|
||||||
)}
|
)}
|
||||||
{ccCount > 0 && <span className="text-[10px] text-gray-400">抄送 {ccCount}</span>}
|
{ccCount > 0 && <span className="text-[10px] text-gray-400">抄送 {ccCount}</span>}
|
||||||
{attachCount > 0 && (
|
{attachCount > 0 && (
|
||||||
@ -108,7 +108,7 @@ export default function MailView() {
|
|||||||
onThread={() => setThreadOf(currentMail.mail_id)}
|
onThread={() => setThreadOf(currentMail.mail_id)}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-4">
|
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-4">
|
||||||
<div className="prose prose-sm max-w-none">
|
<div className="markdown text-sm">
|
||||||
<Markdown remarkPlugins={[remarkGfm]}>{currentMail.body}</Markdown>
|
<Markdown remarkPlugins={[remarkGfm]}>{currentMail.body}</Markdown>
|
||||||
</div>
|
</div>
|
||||||
<AttachmentList items={currentMail.attachments ?? []} />
|
<AttachmentList items={currentMail.attachments ?? []} />
|
||||||
@ -322,7 +322,7 @@ function RenameProposalBar() {
|
|||||||
await accept();
|
await accept();
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}}
|
}}
|
||||||
className="px-2.5 py-1 rounded bg-blue-500 text-white text-xs hover:bg-blue-600 disabled:opacity-50 shrink-0"
|
className="px-2.5 py-1 rounded bg-blue-600 text-white text-xs hover:bg-blue-600 disabled:opacity-50 shrink-0"
|
||||||
>
|
>
|
||||||
{busy ? '改名中' : '接受'}
|
{busy ? '改名中' : '接受'}
|
||||||
</button>
|
</button>
|
||||||
@ -373,7 +373,7 @@ function ForwardBar({ mail, onClose }: { mail: Mail; onClose: () => void }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-t border-gray-200 bg-white px-4 md:px-6 py-3 space-y-2">
|
<div className="shrink-0 max-h-[min(65vh,calc(var(--app-height)-3rem))] overflow-y-auto overscroll-contain border-t border-gray-200 bg-white px-4 md:px-6 py-3 space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ForwardIcon className="w-3.5 h-3.5 text-gray-500" />
|
<ForwardIcon className="w-3.5 h-3.5 text-gray-500" />
|
||||||
<span className="text-[11px] font-medium text-gray-600">
|
<span className="text-[11px] font-medium text-gray-600">
|
||||||
@ -388,7 +388,7 @@ function ForwardBar({ mail, onClose }: { mail: Mail; onClose: () => void }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AddressInput value={to} onChange={setTo} autoFocus placeholder="新收件人:pi@root.new" />
|
<AddressInput value={to} onChange={setTo} placeholder="新收件人:pi@root.new" />
|
||||||
|
|
||||||
{ccOpen && (
|
{ccOpen && (
|
||||||
<AddressInput
|
<AddressInput
|
||||||
@ -406,8 +406,8 @@ function ForwardBar({ mail, onClose }: { mail: Mail; onClose: () => void }) {
|
|||||||
className="w-full h-16 text-sm border border-gray-300 rounded-md p-2.5 resize-none focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
className="w-full h-16 text-sm border border-gray-300 rounded-md p-2.5 resize-none focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{error && <span className="text-xs text-red-600">{error}</span>}
|
{error && <span className="text-xs text-red-600 min-w-0 break-words">{error}</span>}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<button onClick={onClose} className="tap px-3 py-1.5 text-xs text-gray-500 hover:text-gray-800">
|
<button onClick={onClose} className="tap px-3 py-1.5 text-xs text-gray-500 hover:text-gray-800">
|
||||||
取消
|
取消
|
||||||
@ -577,7 +577,7 @@ function ThreadCard({ mail, onForward }: { mail: Mail; onForward?: () => void })
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="prose prose-sm max-w-none">
|
<div className="markdown text-sm">
|
||||||
<Markdown remarkPlugins={[remarkGfm]}>{mail.body}</Markdown>
|
<Markdown remarkPlugins={[remarkGfm]}>{mail.body}</Markdown>
|
||||||
</div>
|
</div>
|
||||||
<AttachmentList items={mail.attachments ?? []} />
|
<AttachmentList items={mail.attachments ?? []} />
|
||||||
@ -635,7 +635,7 @@ export function PermissionPanel({ mail }: { mail: Mail }) {
|
|||||||
disabled={busy}
|
disabled={busy}
|
||||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md transition-colors disabled:opacity-40 ${
|
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md transition-colors disabled:opacity-40 ${
|
||||||
isApprove(opt)
|
isApprove(opt)
|
||||||
? 'bg-green-600 text-white hover:bg-green-700'
|
? 'bg-green-700 text-white hover:bg-green-800'
|
||||||
: 'bg-red-50 text-red-700 border border-red-200 hover:bg-red-100'
|
: 'bg-red-50 text-red-700 border border-red-200 hover:bg-red-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -738,8 +738,8 @@ function ReplyBar({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-t border-gray-200 bg-white px-4 md:px-6 py-3">
|
<div className="shrink-0 max-h-[min(65vh,calc(var(--app-height)-3rem))] overflow-y-auto overscroll-contain border-t border-gray-200 bg-white px-4 md:px-6 py-3">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||||
<p className="text-[10px] text-gray-400 font-mono min-w-0 truncate">回复 {target}</p>
|
<p className="text-[10px] text-gray-400 font-mono min-w-0 truncate">回复 {target}</p>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{(replyTo.cc_list?.length ?? 0) > 0 && (
|
{(replyTo.cc_list?.length ?? 0) > 0 && (
|
||||||
@ -60,7 +60,7 @@ export default function NarrowNav() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
className="shrink-0 border-t border-chrome-700 bg-chrome-900 flex items-stretch"
|
className="narrow-nav shrink-0 border-t border-chrome-700 bg-chrome-900 flex items-stretch"
|
||||||
// 底部安全区:iPhone 的手势条会盖住最后一排
|
// 底部安全区:iPhone 的手势条会盖住最后一排
|
||||||
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
|
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
|
||||||
>
|
>
|
||||||
@ -90,7 +90,7 @@ export default function NarrowNav() {
|
|||||||
<span
|
<span
|
||||||
className={`absolute top-1 right-[22%] min-w-[15px] h-[15px] px-1 rounded-full text-[9px] font-bold flex items-center justify-center ${
|
className={`absolute top-1 right-[22%] min-w-[15px] h-[15px] px-1 rounded-full text-[9px] font-bold flex items-center justify-center ${
|
||||||
mode === 'inbox'
|
mode === 'inbox'
|
||||||
? 'bg-red-500 text-white'
|
? 'bg-red-600 text-white'
|
||||||
: mode === 'permissions'
|
: mode === 'permissions'
|
||||||
? 'bg-orange-700 text-white'
|
? 'bg-orange-700 text-white'
|
||||||
: 'bg-chrome-600 text-chrome-100'
|
: 'bg-chrome-600 text-chrome-100'
|
||||||
@ -74,7 +74,7 @@ export default function NarrowStack({
|
|||||||
|
|
||||||
{mounted && (
|
{mounted && (
|
||||||
<div
|
<div
|
||||||
className={`absolute inset-0 z-10 flex bg-white shadow-2xl transition-transform duration-200 ease-out motion-reduce:transition-none ${
|
className={`absolute inset-0 z-10 flex bg-white border-l border-gray-200 shadow-2xl transition-transform duration-200 ease-out motion-reduce:transition-none ${
|
||||||
entered ? 'translate-x-0' : 'translate-x-full'
|
entered ? 'translate-x-0' : 'translate-x-full'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -55,7 +55,7 @@ export default function PermissionChip({ mode, enforcement, compact }: Permissio
|
|||||||
? 'bg-blue-50 text-blue-700 border-blue-200'
|
? 'bg-blue-50 text-blue-700 border-blue-200'
|
||||||
: normalized === 'full'
|
: normalized === 'full'
|
||||||
? 'bg-amber-50 text-amber-700 border-amber-200'
|
? 'bg-amber-50 text-amber-700 border-amber-200'
|
||||||
: 'bg-emerald-50 text-emerald-700 border-emerald-200';
|
: 'bg-green-50 text-green-700 border-green-200';
|
||||||
|
|
||||||
const hint = permissionModeHint(normalized, enforcement);
|
const hint = permissionModeHint(normalized, enforcement);
|
||||||
const label = MODE_LABEL[normalized] ?? normalized;
|
const label = MODE_LABEL[normalized] ?? normalized;
|
||||||
@ -54,7 +54,7 @@ export default function PermissionList() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full md:w-[340px] shrink-0 border-r border-gray-200 bg-white flex flex-col min-w-0">
|
<div className="w-full lg:w-[340px] shrink-0 border-r border-gray-200 bg-white flex flex-col min-w-0">
|
||||||
<div className="px-4 py-3 border-b border-gray-200 flex items-center gap-1">
|
<div className="px-4 py-3 border-b border-gray-200 flex items-center gap-1">
|
||||||
<h2 className="text-sm font-semibold text-gray-800">授权</h2>
|
<h2 className="text-sm font-semibold text-gray-800">授权</h2>
|
||||||
{pendingTotal > 0 ? (
|
{pendingTotal > 0 ? (
|
||||||
@ -85,7 +85,7 @@ export default function Sidebar() {
|
|||||||
<span
|
<span
|
||||||
className={`absolute top-0.5 right-1 min-w-[15px] h-[15px] px-1 rounded-full text-[9px] font-bold flex items-center justify-center ${
|
className={`absolute top-0.5 right-1 min-w-[15px] h-[15px] px-1 rounded-full text-[9px] font-bold flex items-center justify-center ${
|
||||||
mode === 'inbox'
|
mode === 'inbox'
|
||||||
? 'bg-red-500 text-white'
|
? 'bg-red-600 text-white'
|
||||||
: mode === 'permissions'
|
: mode === 'permissions'
|
||||||
? // 待决策的授权用橙色:它跟未读不是一类紧急 ——
|
? // 待决策的授权用橙色:它跟未读不是一类紧急 ——
|
||||||
// 未读是「有内容没看」,待决策是「有 Agent 卡在那儿等我」
|
// 未读是「有内容没看」,待决策是「有 Agent 卡在那儿等我」
|
||||||
@ -106,7 +106,7 @@ export default function Sidebar() {
|
|||||||
onClick={() => startCompose()}
|
onClick={() => startCompose()}
|
||||||
title="新建邮件"
|
title="新建邮件"
|
||||||
className={`w-12 h-12 rounded-lg flex flex-col items-center justify-center gap-0.5 text-white transition-colors ${
|
className={`w-12 h-12 rounded-lg flex flex-col items-center justify-center gap-0.5 text-white transition-colors ${
|
||||||
composing ? 'bg-blue-500 ring-2 ring-blue-300' : 'bg-blue-600 hover:bg-blue-500'
|
composing ? 'bg-blue-700 ring-2 ring-blue-300' : 'bg-blue-600 hover:bg-blue-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ComposeIcon />
|
<ComposeIcon />
|
||||||
@ -119,7 +119,7 @@ export default function Sidebar() {
|
|||||||
title={`${user?.display_name || user?.username}(点击管理账号)`}
|
title={`${user?.display_name || user?.username}(点击管理账号)`}
|
||||||
className={`relative w-9 h-9 rounded-full flex items-center justify-center text-[11px] font-semibold transition-colors ${
|
className={`relative w-9 h-9 rounded-full flex items-center justify-center text-[11px] font-semibold transition-colors ${
|
||||||
viewMode === 'account' && !composing
|
viewMode === 'account' && !composing
|
||||||
? 'bg-blue-500 text-white'
|
? 'bg-blue-600 text-white'
|
||||||
: 'bg-chrome-700 text-chrome-200 hover:bg-chrome-600'
|
: 'bg-chrome-700 text-chrome-200 hover:bg-chrome-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -253,7 +253,7 @@ function Node({
|
|||||||
<span className="text-xs font-mono text-gray-500 truncate">{node.to_name}</span>
|
<span className="text-xs font-mono text-gray-500 truncate">{node.to_name}</span>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{isForward && (
|
{isForward && (
|
||||||
<span className="px-1 py-0.5 rounded bg-purple-100 text-purple-700 text-[9px]">
|
<span className="px-1 py-0.5 rounded bg-blue-100 text-blue-700 text-[9px]">
|
||||||
转发
|
转发
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -55,7 +55,7 @@ export function WorkCard({
|
|||||||
<span className="text-xs font-semibold text-gray-900 truncate">{c.agent_name}</span>
|
<span className="text-xs font-semibold text-gray-900 truncate">{c.agent_name}</span>
|
||||||
<span className="text-[10px] text-gray-400 font-mono truncate">{c.path}</span>
|
<span className="text-[10px] text-gray-400 font-mono truncate">{c.path}</span>
|
||||||
{c.unread_count > 0 && (
|
{c.unread_count > 0 && (
|
||||||
<span className="ml-auto shrink-0 min-w-[16px] h-4 px-1 rounded-full bg-blue-500 text-white text-[9px] font-bold flex items-center justify-center">
|
<span className="ml-auto shrink-0 min-w-[16px] h-4 px-1 rounded-full bg-blue-600 text-white text-[9px] font-bold flex items-center justify-center">
|
||||||
{c.unread_count}
|
{c.unread_count}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -3,13 +3,14 @@ import { useEffect, useState } from 'react';
|
|||||||
/**
|
/**
|
||||||
* 窄屏断点。
|
* 窄屏断点。
|
||||||
*
|
*
|
||||||
* 三栏布局需要 60(导航)+ 320(列表)+ 约 400(详情)≈ 780px 才不挤,
|
* 三栏布局需要 60(导航)+ 320(列表)+ 至少 520(详情)≈ 900px;
|
||||||
* 因此以 768px 为界:以下只显示单栏。
|
* 还要给滚动条、长地址和触摸留余量,因此与 Tailwind 的 lg 断点统一:
|
||||||
|
* 1024px 以下(含常见 768/820px 竖屏平板)都使用单栏。
|
||||||
*
|
*
|
||||||
* 用 matchMedia 而不是监听 resize:后者每变化一像素都触发,还得自己节流;
|
* 用 matchMedia 而不是监听 resize:后者每变化一像素都触发,还得自己节流;
|
||||||
* matchMedia 只在跨过阈值时回调一次。
|
* matchMedia 只在跨过阈值时回调一次。
|
||||||
*/
|
*/
|
||||||
const NARROW_QUERY = '(max-width: 767px)';
|
export const NARROW_QUERY = '(max-width: 1023px)';
|
||||||
|
|
||||||
export function useIsNarrow(): boolean {
|
export function useIsNarrow(): boolean {
|
||||||
const [narrow, setNarrow] = useState(() =>
|
const [narrow, setNarrow] = useState(() =>
|
||||||
@ -40,7 +40,8 @@
|
|||||||
--c-gray-100: 243 244 246;
|
--c-gray-100: 243 244 246;
|
||||||
--c-gray-200: 229 231 235;
|
--c-gray-200: 229 231 235;
|
||||||
--c-gray-300: 209 213 219;
|
--c-gray-300: 209 213 219;
|
||||||
--c-gray-400: 156 163 175;
|
/* 最小字号与 placeholder 也会使用这一档;在白底上保持至少 4.5:1。 */
|
||||||
|
--c-gray-400: 107 114 128;
|
||||||
--c-gray-500: 107 114 128;
|
--c-gray-500: 107 114 128;
|
||||||
--c-gray-600: 75 85 99;
|
--c-gray-600: 75 85 99;
|
||||||
--c-gray-700: 55 65 81;
|
--c-gray-700: 55 65 81;
|
||||||
@ -340,10 +341,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
:root {
|
||||||
|
/* main.tsx 会用 Visual Viewport 覆盖;这里保证脚本执行前也有正确高度。 */
|
||||||
|
--app-height: 100vh;
|
||||||
|
}
|
||||||
|
@supports (height: 100dvh) {
|
||||||
|
:root {
|
||||||
|
--app-height: 100dvh;
|
||||||
|
}
|
||||||
|
}
|
||||||
html, body, #root {
|
html, body, #root {
|
||||||
height: 100%;
|
height: var(--app-height);
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
|
overflow: hidden;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||||
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||||
/*
|
/*
|
||||||
@ -353,6 +365,63 @@
|
|||||||
background-color: rgb(var(--c-gray-50));
|
background-color: rgb(var(--c-gray-50));
|
||||||
color: rgb(var(--c-gray-900));
|
color: rgb(var(--c-gray-900));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 统一作者样式,避免 Chromium/Electron autofill 在深色卡片上画白底。 */
|
||||||
|
input:not([type='checkbox']):not([type='radio']):not([type='file']),
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
background-color: rgb(var(--c-white));
|
||||||
|
color: rgb(var(--c-gray-900));
|
||||||
|
}
|
||||||
|
input::placeholder,
|
||||||
|
textarea::placeholder {
|
||||||
|
color: rgb(var(--c-gray-400));
|
||||||
|
}
|
||||||
|
input:-webkit-autofill,
|
||||||
|
input:-webkit-autofill:hover,
|
||||||
|
input:-webkit-autofill:focus {
|
||||||
|
-webkit-text-fill-color: rgb(var(--c-gray-900));
|
||||||
|
box-shadow: 0 0 0 1000px rgb(var(--c-white)) inset;
|
||||||
|
transition: background-color 9999s ease-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
/* react-markdown 的本地排版层;不依赖固定色板的 typography 插件。 */
|
||||||
|
.markdown {
|
||||||
|
color: rgb(var(--c-gray-800));
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.markdown > * + * { margin-top: 0.75rem; }
|
||||||
|
.markdown h1 { font-size: 1.5rem; line-height: 2rem; font-weight: 700; }
|
||||||
|
.markdown h2 { font-size: 1.25rem; line-height: 1.75rem; font-weight: 700; }
|
||||||
|
.markdown h3 { font-size: 1.05rem; line-height: 1.5rem; font-weight: 600; }
|
||||||
|
.markdown ul { list-style: disc; padding-left: 1.35rem; }
|
||||||
|
.markdown ol { list-style: decimal; padding-left: 1.35rem; }
|
||||||
|
.markdown li + li { margin-top: 0.25rem; }
|
||||||
|
.markdown a { color: rgb(var(--c-blue-600)); text-decoration: underline; }
|
||||||
|
.markdown blockquote {
|
||||||
|
border-left: 3px solid rgb(var(--c-gray-300));
|
||||||
|
padding-left: 0.75rem;
|
||||||
|
color: rgb(var(--c-gray-600));
|
||||||
|
}
|
||||||
|
.markdown code {
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: rgb(var(--c-gray-100));
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
.markdown pre {
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid rgb(var(--c-gray-200));
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: rgb(var(--c-gray-100));
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
.markdown pre code { background: transparent; padding: 0; }
|
||||||
|
.markdown table { width: 100%; border-collapse: collapse; display: block; overflow-x: auto; }
|
||||||
|
.markdown th, .markdown td { border: 1px solid rgb(var(--c-gray-200)); padding: 0.4rem 0.55rem; }
|
||||||
|
.markdown th { background: rgb(var(--c-gray-100)); text-align: left; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
@ -368,7 +437,20 @@
|
|||||||
* 只在窄屏生效:桌面用鼠标,精度足够;而扩大后的命中区在密排的工具栏里
|
* 只在窄屏生效:桌面用鼠标,精度足够;而扩大后的命中区在密排的工具栏里
|
||||||
* 会互相重叠,点一个可能命中隔壁那个。
|
* 会互相重叠,点一个可能命中隔壁那个。
|
||||||
*/
|
*/
|
||||||
@media (max-width: 767px) {
|
/* 与 useIsNarrow / Tailwind lg 统一:竖屏平板也需要触摸命中区。 */
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.safe-frame {
|
||||||
|
padding-top: env(safe-area-inset-top);
|
||||||
|
padding-left: env(safe-area-inset-left);
|
||||||
|
padding-right: env(safe-area-inset-right);
|
||||||
|
}
|
||||||
|
.narrow-nav {
|
||||||
|
padding-left: env(safe-area-inset-left);
|
||||||
|
padding-right: env(safe-area-inset-right);
|
||||||
|
}
|
||||||
|
.form-editing .narrow-nav {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.tap {
|
.tap {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
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;
|
screen.getByRole('button', { name: new RegExp(name) }).className;
|
||||||
|
|
||||||
// 颜色是唯一的视觉提示:点错一次就放行了一个危险操作
|
// 颜色是唯一的视觉提示:点错一次就放行了一个危险操作
|
||||||
expect(cls('允许')).toContain('bg-green-600');
|
expect(cls('允许')).toContain('bg-green-700');
|
||||||
expect(cls('approve')).toContain('bg-green-600');
|
expect(cls('approve')).toContain('bg-green-700');
|
||||||
expect(cls('拒绝')).toContain('text-red-700');
|
expect(cls('拒绝')).toContain('text-red-700');
|
||||||
// 不在同意词表里的一律按「否」处理 —— 宁可让人多看一眼
|
// 不在同意词表里的一律按「否」处理 —— 宁可让人多看一眼
|
||||||
expect(cls('算了')).toContain('text-red-700');
|
expect(cls('算了')).toContain('text-red-700');
|
||||||
@ -13,7 +13,7 @@
|
|||||||
* 只有真实渲染才能验的事。
|
* 只有真实渲染才能验的事。
|
||||||
*
|
*
|
||||||
* 用法:ADMIN_PW=<密码> ADMIN_USER=jianf AGENTMAIL_URL=http://127.0.0.1:8180 \
|
* 用法:ADMIN_PW=<密码> ADMIN_USER=jianf AGENTMAIL_URL=http://127.0.0.1:8180 \
|
||||||
* node web/test/manual/inbox-group-verify.mjs
|
* node client/electron/test/manual/inbox-group-verify.mjs
|
||||||
*/
|
*/
|
||||||
import { openApp, WIDE } from './narrow-probe-helper.mjs';
|
import { openApp, WIDE } from './narrow-probe-helper.mjs';
|
||||||
|
|
||||||
@ -9,8 +9,8 @@
|
|||||||
* SVG 而不是导航按钮)只有这样才能发现。
|
* SVG 而不是导航按钮)只有这样才能发现。
|
||||||
*
|
*
|
||||||
* 用法:
|
* 用法:
|
||||||
* ADMIN_PW=<密码> node web/test/manual/narrow-verify.mjs
|
* ADMIN_PW=<密码> node client/electron/test/manual/narrow-verify.mjs
|
||||||
* ADMIN_PW=<密码> node web/test/manual/wide-regression.mjs
|
* ADMIN_PW=<密码> node client/electron/test/manual/wide-regression.mjs
|
||||||
*
|
*
|
||||||
* 环境变量:
|
* 环境变量:
|
||||||
* ADMIN_PW 必填,管理员密码
|
* ADMIN_PW 必填,管理员密码
|
||||||
@ -20,9 +20,22 @@
|
|||||||
*/
|
*/
|
||||||
const PLAYWRIGHT = process.env.PLAYWRIGHT || '/usr/lib/node_modules/playwright/index.mjs';
|
const PLAYWRIGHT = process.env.PLAYWRIGHT || '/usr/lib/node_modules/playwright/index.mjs';
|
||||||
const { chromium } = await import(PLAYWRIGHT);
|
const { chromium } = await import(PLAYWRIGHT);
|
||||||
|
const { readFile } = await import('node:fs/promises');
|
||||||
|
const { extname, join } = await import('node:path');
|
||||||
|
|
||||||
const CDP = process.env.CDP_URL || 'http://127.0.0.1:9222';
|
const CDP = process.env.CDP_URL || 'http://127.0.0.1:9222';
|
||||||
const APP = (process.env.AGENTMAIL_URL || 'https://mail.jianfgit.xyz').replace(/\/$/, '');
|
const APP = (process.env.AGENTMAIL_URL || 'https://mail.jianfgit.xyz').replace(/\/$/, '');
|
||||||
|
const DIST = process.env.AGENTMAIL_DIST?.replace(/\/$/, '');
|
||||||
|
const contentTypes = {
|
||||||
|
'.css': 'text/css; charset=utf-8',
|
||||||
|
'.html': 'text/html; charset=utf-8',
|
||||||
|
'.js': 'text/javascript; charset=utf-8',
|
||||||
|
'.json': 'application/json; charset=utf-8',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.woff2': 'font/woff2'
|
||||||
|
};
|
||||||
|
|
||||||
export const PHONE = { width: 390, height: 844 }; // iPhone 14 Pro
|
export const PHONE = { width: 390, height: 844 }; // iPhone 14 Pro
|
||||||
export const SMALL = { width: 320, height: 568 }; // iPhone SE 1 代
|
export const SMALL = { width: 320, height: 568 }; // iPhone SE 1 代
|
||||||
@ -44,6 +57,35 @@ export async function openApp(viewport = PHONE) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 在不替换、不重启现有 Gateway 的前提下验证本次构建:仅把页面壳和静态资源
|
||||||
|
// 从 dist 注入当前标签页,API/SSE 仍由 APP 指向的真实 Gateway 提供。
|
||||||
|
if (DIST) {
|
||||||
|
const appOrigin = new URL(APP).origin;
|
||||||
|
await page.route(`${appOrigin}/**`, async route => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
if (url.pathname.startsWith('/api/')) {
|
||||||
|
await route.continue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const relative = url.pathname === '/' ? 'index.html' : url.pathname.replace(/^\/+/, '');
|
||||||
|
const file = join(DIST, relative);
|
||||||
|
if (!file.startsWith(DIST + '/') && file !== join(DIST, 'index.html')) {
|
||||||
|
await route.abort('blockedbyclient');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const body = await readFile(file);
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
body,
|
||||||
|
contentType: contentTypes[extname(file)] || 'application/octet-stream'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
await route.continue();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 不能用 networkidle:SSE 是一条永不结束的长连接,networkidle 永远不触发
|
// 不能用 networkidle:SSE 是一条永不结束的长连接,networkidle 永远不触发
|
||||||
await page.goto(APP + '/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
await page.goto(APP + '/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||||
await page.waitForTimeout(2500);
|
await page.waitForTimeout(2500);
|
||||||
@ -5,7 +5,7 @@
|
|||||||
* 抽屉盖住底部导航、工具按钮只有 16px 高、看不见却按得动的「归档」、
|
* 抽屉盖住底部导航、工具按钮只有 16px 高、看不见却按得动的「归档」、
|
||||||
* 对话树缩进把卡片压成竖条、对话树没有返回出口。
|
* 对话树缩进把卡片压成竖条、对话树没有返回出口。
|
||||||
*
|
*
|
||||||
* 用法:ADMIN_PW=<密码> node web/test/manual/narrow-verify.mjs
|
* 用法:ADMIN_PW=<密码> node client/electron/test/manual/narrow-verify.mjs
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
openApp,
|
openApp,
|
||||||
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 \
|
* ADMIN_USER=jianf ADMIN_PW=... AGENTMAIL_URL=http://127.0.0.1:8180 \
|
||||||
* node web/test/manual/theme-verify.mjs
|
* node client/electron/test/manual/theme-verify.mjs
|
||||||
*/
|
*/
|
||||||
import { openApp, WIDE } from './narrow-probe-helper.mjs';
|
import { openApp, WIDE } from './narrow-probe-helper.mjs';
|
||||||
|
|
||||||
@ -88,7 +88,10 @@ try {
|
|||||||
const r = el.getBoundingClientRect();
|
const r = el.getBoundingClientRect();
|
||||||
if (r.width < 4 || r.height < 4) continue;
|
if (r.width < 4 || r.height < 4) continue;
|
||||||
const cs = getComputedStyle(el);
|
const cs = getComputedStyle(el);
|
||||||
if (cs.visibility === 'hidden' || cs.opacity === '0') continue;
|
if (cs.visibility === 'hidden' || Number(cs.opacity) < 0.75) continue;
|
||||||
|
if (el.matches(':disabled, [aria-disabled="true"]')) continue;
|
||||||
|
// 方向箭头、分隔点与关闭符号是装饰/图标,不按正文文字审计。
|
||||||
|
if (/^[→·×↑↓]+$/.test(text)) continue;
|
||||||
const fg = parse(cs.color);
|
const fg = parse(cs.color);
|
||||||
const bg = bgOf(el);
|
const bg = bgOf(el);
|
||||||
if (!fg || !bg) continue;
|
if (!fg || !bg) continue;
|
||||||
@ -112,10 +115,9 @@ try {
|
|||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 允许少量刻意的低对比装饰(占位符、禁用态)
|
// 禁用态与纯图标已在页面端过滤,其余可见文字都应达到自身 AA 阈值。
|
||||||
const severe = bad.filter(x => x.ratio < 2.5);
|
check(`${mode}: 可见文字全部达到 WCAG AA`, bad.length === 0,
|
||||||
check(`${mode}: 无严重低对比文本(< 2.5:1)`, severe.length === 0,
|
bad.slice(0, 4).map(x => `"${x.text}" ${x.ratio}/${x.need}`).join(' | '));
|
||||||
severe.slice(0, 4).map(x => `"${x.text}" ${x.ratio}`).join(' | '));
|
|
||||||
if (bad.length) {
|
if (bad.length) {
|
||||||
console.log(` (${bad.length} 处低于 AA 阈值,最差 ${Math.min(...bad.map(x => x.ratio))}:1)`);
|
console.log(` (${bad.length} 处低于 AA 阈值,最差 ${Math.min(...bad.map(x => x.ratio))}:1)`);
|
||||||
// 逐条列出来而不只报个数:不知道是哪一处就没法修
|
// 逐条列出来而不只报个数:不知道是哪一处就没法修
|
||||||
@ -124,11 +126,6 @@ try {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 白底白字的典型形态:前景与背景几乎相同
|
|
||||||
const invisible = bad.filter(x => x.ratio < 1.3);
|
|
||||||
check(`${mode}: 没有不可见文本(< 1.3:1)`, invisible.length === 0,
|
|
||||||
invisible.slice(0, 3).map(x => `"${x.text}"`).join(' | '));
|
|
||||||
|
|
||||||
await page.screenshot({ path: `/tmp/theme-${mode}.png`, fullPage: false });
|
await page.screenshot({ path: `/tmp/theme-${mode}.png`, fullPage: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -33,7 +33,7 @@
|
|||||||
*
|
*
|
||||||
* 用法:
|
* 用法:
|
||||||
* ADMIN_PW=<密码> ADMIN_USER=jianf AGENTMAIL_URL=http://127.0.0.1:8180 \
|
* ADMIN_PW=<密码> ADMIN_USER=jianf AGENTMAIL_URL=http://127.0.0.1:8180 \
|
||||||
* node web/test/manual/ui-sweep.mjs
|
* node client/electron/test/manual/ui-sweep.mjs
|
||||||
*/
|
*/
|
||||||
import { openApp, WIDE } from './narrow-probe-helper.mjs';
|
import { openApp, WIDE } from './narrow-probe-helper.mjs';
|
||||||
|
|
||||||
@ -5,7 +5,7 @@
|
|||||||
* - `.tap` 的伪元素命中区(桌面密排工具栏里会互相重叠)
|
* - `.tap` 的伪元素命中区(桌面密排工具栏里会互相重叠)
|
||||||
* - `BackButton`(宽屏列表与详情并排,返回没有意义)
|
* - `BackButton`(宽屏列表与详情并排,返回没有意义)
|
||||||
*
|
*
|
||||||
* 用法:ADMIN_PW=<密码> node web/test/manual/wide-regression.mjs
|
* 用法:ADMIN_PW=<密码> node client/electron/test/manual/wide-regression.mjs
|
||||||
*/
|
*/
|
||||||
import { openApp, WIDE } from './narrow-probe-helper.mjs';
|
import { openApp, WIDE } from './narrow-probe-helper.mjs';
|
||||||
|
|
||||||
@ -36,23 +36,20 @@ check(
|
|||||||
stack.includes('motion-reduce:transition-none')
|
stack.includes('motion-reduce:transition-none')
|
||||||
);
|
);
|
||||||
|
|
||||||
// 2) 固定宽度的中间栏在窄屏必须让位。
|
// 2) 手机与竖屏平板统一用单栏;三栏只在 Tailwind lg(1024px)启用。
|
||||||
// 断言的是「w-full + md: 前缀的固定宽度」这个形态,不是某个具体像素值 ——
|
const narrowHook = read('../src/hooks/useIsNarrow.ts');
|
||||||
// ContactPanel 的卡片视图用 400px,列表视图用 320px。
|
check('JS 单栏断点与 lg 一致', narrowHook.includes('(max-width: 1023px)'));
|
||||||
for (const f of ['MailList', 'PermissionList', 'ContactPanel', 'CalendarView']) {
|
for (const f of ['MailList', 'PermissionList', 'ContactPanel', 'CalendarView']) {
|
||||||
const src = read(`../src/components/${f}.tsx`);
|
const src = read(`../src/components/${f}.tsx`);
|
||||||
const narrowFullWidth = src.includes('w-full');
|
const narrowFullWidth = src.includes('w-full');
|
||||||
// 固定宽度只能出现在 md: 断点后面;裸 w-[NNNpx] 会在 375px 屏上挤掉详情。
|
|
||||||
// 只看 >=200px 的:min-w-[16px] 之类的徽标尺寸与布局无关
|
|
||||||
// (前置 (?<![-\w]) 排除 min-w- / max-w-,它们是约束不是宽度)。
|
|
||||||
const bareFixed = (src.match(/(?<![-\w])w-\[(\d+)px\]/g) || []).filter(m => {
|
const bareFixed = (src.match(/(?<![-\w])w-\[(\d+)px\]/g) || []).filter(m => {
|
||||||
const px = Number(m.match(/\d+/)[0]);
|
const px = Number(m.match(/\d+/)?.[0] || 0);
|
||||||
return px >= 200 && !src.includes('md:' + m);
|
return px >= 200 && !src.includes('lg:' + m);
|
||||||
});
|
});
|
||||||
check(
|
check(
|
||||||
`${f} 中间栏窄屏全宽,固定宽度仅在 md: 之后`,
|
`${f} 平板单栏全宽,固定宽度仅在 lg 之后`,
|
||||||
narrowFullWidth && bareFixed.length === 0,
|
narrowFullWidth && bareFixed.length === 0 && !/md:w-\[/.test(src),
|
||||||
bareFixed.length ? `裸固定宽度:${bareFixed.join(', ')}` : '缺少 w-full'
|
bareFixed.length ? `裸固定宽度:${bareFixed.join(', ')}` : '存在 md 固定栏或缺少 w-full'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,9 +99,9 @@ check(
|
|||||||
// .tap 用居中的透明伪元素扩大命中区,视觉尺寸不变。
|
// .tap 用居中的透明伪元素扩大命中区,视觉尺寸不变。
|
||||||
const css = read('../src/index.css');
|
const css = read('../src/index.css');
|
||||||
check(
|
check(
|
||||||
'.tap 提供 44px 触摸命中区且只在窄屏生效',
|
'.tap 提供 44px 触摸命中区且覆盖手机与竖屏平板',
|
||||||
/\.tap::after/.test(css) && css.includes('min-width: 44px') &&
|
/\.tap::after/.test(css) && css.includes('min-width: 44px') &&
|
||||||
css.includes('min-height: 44px') && /max-width:\s*767px/.test(css)
|
css.includes('min-height: 44px') && /max-width:\s*1023px/.test(css)
|
||||||
);
|
);
|
||||||
// 详情页那排工具按钮是实测最小的一组(「抄送」只有 20x15)
|
// 详情页那排工具按钮是实测最小的一组(「抄送」只有 20x15)
|
||||||
const viewSrc = read('../src/components/MailView.tsx');
|
const viewSrc = read('../src/components/MailView.tsx');
|
||||||
@ -159,6 +156,23 @@ for (const f of ['LoginPage', 'SetupPage']) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5.8) 写信页不能靠 flex 把正文压成一行。
|
||||||
|
// 软键盘出现时可见高度骤减:顶部字段和底部附件/按钮都是固定内容,原先唯一
|
||||||
|
// 可收缩的正文区只有 min-h-0,于是会被压到接近 0。窄屏改为整页可滚,正文
|
||||||
|
// 保留明确的最小高度;宽屏仍使用 flex 填满剩余空间。
|
||||||
|
check(
|
||||||
|
'写信页窄屏整页可滚,正文有明确最小高度',
|
||||||
|
compose.includes('overflow-y-auto lg:overflow-hidden') &&
|
||||||
|
compose.includes('min-h-[12rem]') &&
|
||||||
|
compose.includes('lg:flex-1')
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'写信页附件与操作栏不参与正文压缩',
|
||||||
|
compose.includes('shrink-0 px-4 md:px-6 pb-20 lg:pb-3') &&
|
||||||
|
compose.includes('sticky bottom-0')
|
||||||
|
);
|
||||||
|
check('根视口使用 100dvh 跟随软键盘', css.includes('@supports (height: 100dvh)'));
|
||||||
|
|
||||||
// 6) 横向内边距在窄屏收窄(px-6 在 375px 屏上白吃 48px)
|
// 6) 横向内边距在窄屏收窄(px-6 在 375px 屏上白吃 48px)
|
||||||
const wide = ['MailView', 'ComposePage', 'ThreadView', 'AccountPage', 'AdminUsersPage'];
|
const wide = ['MailView', 'ComposePage', 'ThreadView', 'AccountPage', 'AdminUsersPage'];
|
||||||
for (const f of wide) {
|
for (const f of wide) {
|
||||||
@ -310,9 +310,9 @@ const sRgb = name => {
|
|||||||
};
|
};
|
||||||
// 代码里真正出现过的「实心底 + 白字」组合
|
// 代码里真正出现过的「实心底 + 白字」组合
|
||||||
const solidPairs = [
|
const solidPairs = [
|
||||||
['blue-600'], ['blue-700'], ['blue-500'],
|
['blue-600'], ['blue-700'],
|
||||||
['red-500'], ['red-600'], ['red-700'],
|
['red-600'], ['red-700'],
|
||||||
['green-600'], ['green-700'], ['orange-700']
|
['green-700'], ['orange-700']
|
||||||
];
|
];
|
||||||
const onAccentLight = rgbOf(lightBlock, 'on-accent') ||
|
const onAccentLight = rgbOf(lightBlock, 'on-accent') ||
|
||||||
(lightBlock.match(/--c-on-accent:\s*(\d+)\s+(\d+)\s+(\d+)/) || []).slice(1).map(Number);
|
(lightBlock.match(/--c-on-accent:\s*(\d+)\s+(\d+)\s+(\d+)/) || []).slice(1).map(Number);
|
||||||
@ -320,14 +320,12 @@ const weakButtons = [];
|
|||||||
for (const [name] of solidPairs) {
|
for (const [name] of solidPairs) {
|
||||||
const bg = sRgb(name);
|
const bg = sRgb(name);
|
||||||
if (!bg) { weakButtons.push(`${name}:缺失`); continue; }
|
if (!bg) { weakButtons.push(`${name}:缺失`); continue; }
|
||||||
// 3:1 是 WCAG 对大号/粗体文字的下限。这些按钮文字是 11–14px 的 font-medium,
|
// 这些按钮文字只有 9–14px,按普通文字执行 WCAG AA 4.5:1。
|
||||||
// 严格说该要 4.5 —— 但 Tailwind 官方 600 档普遍在 3–4.5 之间(green-600 = 3.05),
|
|
||||||
// 收紧到 4.5 就得偏离官方色值。取 3.0 作为门槛并把偏低的记在这里。
|
|
||||||
const r = contrast(onAccentLight, bg);
|
const r = contrast(onAccentLight, bg);
|
||||||
if (r < 3.0) weakButtons.push(`${name}:${r.toFixed(2)}`);
|
if (r < 4.5) weakButtons.push(`${name}:${r.toFixed(2)}`);
|
||||||
}
|
}
|
||||||
check(
|
check(
|
||||||
'白字在实心按钮底上达到 3:1',
|
'白字在实心按钮底上达到 4.5:1',
|
||||||
weakButtons.length === 0,
|
weakButtons.length === 0,
|
||||||
weakButtons.join(' ')
|
weakButtons.join(' ')
|
||||||
);
|
);
|
||||||
@ -360,5 +358,38 @@ for (const f of chromeFiles) {
|
|||||||
}
|
}
|
||||||
check('框架组件已全部改用 chrome 色阶', slateLeft.length === 0, slateLeft.join(' | '));
|
check('框架组件已全部改用 chrome 色阶', slateLeft.length === 0, slateLeft.join(' | '));
|
||||||
|
|
||||||
|
// 27) 可逆灰阶不能作为白字实心按钮底。gray-700/800/900 在深色模式下会
|
||||||
|
// 被反转成近白色,`bg-gray-900 text-white` 因而只剩约 1:1。
|
||||||
|
const componentSources = readdirSync(compDir)
|
||||||
|
.filter(x => x.endsWith('.tsx'))
|
||||||
|
.map(f => [f, readFileSync(join(compDir, f), 'utf8')]);
|
||||||
|
const graySolid = componentSources.flatMap(([f, src]) =>
|
||||||
|
(src.match(/(?:bg-gray-(?:700|800|900)[^'"\n]*text-white|text-white[^'"\n]*bg-gray-(?:700|800|900))/g) || [])
|
||||||
|
.map(hit => `${f}:${hit}`)
|
||||||
|
);
|
||||||
|
check('白字实心控件不使用可逆 gray 底色', graySolid.length === 0, graySolid.slice(0, 4).join(' | '));
|
||||||
|
|
||||||
|
// 28) 未映射色族会绕过 CSS 变量主题,浅色值会原样落进深色页面。
|
||||||
|
const unmapped = componentSources.flatMap(([f, src]) =>
|
||||||
|
(src.match(/(?:bg|text|border)-(?:emerald|purple)-\d+/g) || []).map(hit => `${f}:${hit}`)
|
||||||
|
);
|
||||||
|
check('组件未使用未映射的 emerald/purple 色族', unmapped.length === 0, unmapped.slice(0, 6).join(' | '));
|
||||||
|
|
||||||
|
// 29) 本地 markdown 层替代未安装 typography 插件时无效的 prose 类。
|
||||||
|
const inertProse = componentSources.flatMap(([f, src]) =>
|
||||||
|
(src.match(/\bprose(?:-sm)?\b/g) || []).map(hit => `${f}:${hit}`)
|
||||||
|
);
|
||||||
|
check('Markdown 内容使用本地 .markdown 排版层', inertProse.length === 0, inertProse.slice(0, 4).join(' | '));
|
||||||
|
|
||||||
|
// 30) gray-400 承担 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} 失败` : ''}`);
|
console.log(`\n主题:${pass} 通过${fail ? `,${fail} 失败` : ''}`);
|
||||||
process.exit(fail ? 1 : 0);
|
process.exit(fail ? 1 : 0);
|
||||||
@ -12,6 +12,10 @@ import react from '@vitejs/plugin-react';
|
|||||||
* 它们不需要 DOM,套一层 vitest 只是变慢。
|
* 它们不需要 DOM,套一层 vitest 只是变慢。
|
||||||
*/
|
*/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
// 测试必须使用含 act() 的 React 开发构建;宿主进程可能继承 NODE_ENV=production。
|
||||||
|
define: {
|
||||||
|
'process.env.NODE_ENV': JSON.stringify('test')
|
||||||
|
},
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
test: {
|
test: {
|
||||||
include: ['test/components/**/*.test.tsx'],
|
include: ['test/components/**/*.test.tsx'],
|
||||||
12
client/harmony/.gitignore
vendored
Normal file
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