feat: 配额下沉到会话 + 窄屏覆盖式布局 + 工作列表卡片视图

## 配额重构:废除 Agent 终身额度

原实现在 agents 上放一个 max_rounds/used_rounds 计数器,used_rounds 单调递增、
永不重置 —— 跑满就要管理员手工重置才能再干活。那是把一次性资源模型套在长期
在线的服务上,且并行任务互相抢额度。

改为:
- 唯一被强制的预算是【会话】的往返预算(sessions.max_rounds/used_rounds),
  写信时给、对话页里随时改 —— 配额的语义是「这件事值得多少个来回」,
  那是任务的属性而不是 Agent 的属性
- agents.default_rounds 只作为「派给这个 Agent 的新任务」的默认值(默认 20)
- agents.used_rounds 降级为纯统计
- 新建会话速率限制(1h/20 条)堵住用 .new 开一串新会话绕过预算;
  人类不受限(agentLimiterKey 返回空串即不计量)

## 窄屏适配(用户反馈「窄屏基本不可用」)

原先只有三栏并排:60(导航)+320(列表)+详情,375px 屏上详情被挤到 0。

第一版做成「一次只显示一栏」,用户纠正应当是新页面覆盖老页面并带动画,
于是重做为覆盖式:
- NarrowStack:底层列表始终挂载,详情绝对定位盖在上面。两个好处 ——
  列表滚动位置与选中态天然保留;退出动画有东西可播(直接卸载再渲染另一个
  组件的话,没有任何一帧能让旧页面往右滑出去)
- 因此必须区分「逻辑上是否打开」与「是否还在 DOM 里」:关闭时先播 200ms
  滑出,动画结束才卸载
- 入场用双层 requestAnimationFrame:必须让浏览器至少绘制一帧「在右侧之外」
  的状态,否则挂载与 translate-x-0 在同一帧内完成,transition 不触发
- 窄屏专属控件用 useIsNarrow() 条件渲染而非 md:hidden —— 后者只是视觉隐藏,
  宽屏用户按 Tab 会聚焦到看不见的返回按钮
- 底部导航 + 抽屉侧栏 + env(safe-area-inset-bottom)

## 工作列表卡片视图(Phase 7.1 最后一项)

中间栏可切列表/卡片。列表答「跟谁在聊」,卡片答「在聊什么、进展如何」:
主题 + 最新一封的发件人与摘要 + 往返预算徽标。

- 两种视图共用同一份数据与同一套动作;归档确认框也共用 —— 归档是破坏性操作,
  换个视图就换套确认 UI 只会让人对「自己点了什么」更没底
- 预算徽标在「不限」时不显示(对每张卡片都成立的「0/0」是纯噪声)
- 数据一次取回,不让卡片为每条会话再打一次库

## 修掉的缺陷

- GET /me/sessions 一直 500:ListSessionsFor 的 SELECT 加了预算两列却没加进
  Scan,列数不匹配。联系人栏一条数据都拉不到,而错误只是「Failed to list sessions」
- GET /sessions/{id} 忘了填充附件:前端会话视图走的是这个端点,于是 Agent
  回信里的附件在 UI 上完全不存在(另一个端点填了但没人调用)
- 插件曾完全没在加载:为了可测在 index.js 里 export 了辅助函数与一个 Map,
  而 opencode 把入口模块的每一个导出都当成插件工厂逐个检查,多导出一个 Map
  就 "Plugin export is not a function",插件静默失效、邮件全投不进去。
  逻辑挪到 lib/relay-dedup.js,并加断言钉住「入口只有 default 导出」
- 同一件事发两封邮件:模型带附件主动回信后,session.idle 又把它最后那段话
  自动转了一遍(生产实测 311 与 342 字节各一封)。explicitSends 记录本轮
  主动发信,自动转发据此让位;relay_key 幂等管不了这个 —— 那个键保证的是
  「同一条消息不转两次」
- SQLite 时间戳只有秒精度:同秒插入的多封邮件排序不确定(实测同秒插 5 封,
  顺序由随机 UUID 决定)。「会话里最早那封」(决定联系人身份)与「最后那封」
  (决定最新进展)都会取错。NOW() 升到微秒 + mails 的 INSERT 显式传它
  (改 schema 默认值只对新库生效,SQLite 没有 ALTER COLUMN)+ 所有
  ORDER BY created_at 补 mail_id 兜底
- fillAttachments 从逐封查询改成一次 IN(...):原来是 N+1,200 封的会话打开
  要打 200 次库
- repo 层 5 处 rows.Next() 循环补 rows.Err():没有它,读到一半连接断掉会
  静默返回部分结果,UI 上表现为「邮件凭空少了几封」
- go:embed 占位页改名 placeholder.html:叫 index.html 会被 Vite 产物覆盖并
  提交进去,而它引用的 assets/ 是被忽略的 —— 新克隆打开是白屏

## 回复/转发栏

- 两处都加抄送(可折叠);原邮件带抄送时多一个「回复全部」,回填用
  cc_list[].raw 而非重拼 name@path(后者会丢掉会话段)
- 会话视图每张卡片加转发入口:转发之前只存在于单封邮件视图,而人多数时间
  待在会话视图里,等于功能在 UI 上找不到
- ReplyBar 的错误从 console.error 改为显示出来:预算耗尽、地址不存在、
  速率限制都走这条路,之前点发送毫无反应

## 测试

- repo: 列顺序(三个 SQL 分支)、卡片字段、previewRunes 边界、时间戳亚秒精度、
  批量附件查询、速率限制(80 goroutine 断言恰好 20 条通过)
- web: 窄屏布局 16 条结构性断言(覆盖而非分栏、延迟卸载、双层 rAF、
  条件渲染而非 md:hidden)
- 插件: 自动转发去重 17 条(含「入口只有 default 导出」不变量)
- install.sh 把插件测试也纳入部署前门禁
This commit is contained in:
2026-09-02 14:16:46 +08:00
parent 00ba69d899
commit 07e6b789b2
61 changed files with 3107 additions and 595 deletions

View File

@ -8,7 +8,7 @@
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "node test/markdown-xss.test.mjs"
"test": "node test/markdown-xss.test.mjs && node test/narrow-layout.test.mjs"
},
"dependencies": {
"react": "^18.3.1",

View File

@ -14,6 +14,9 @@ import LoginPage from './components/LoginPage';
import SetupPage from './components/SetupPage';
import AccountPage from './components/AccountPage';
import AdminUsersPage from './components/AdminUsersPage';
import NarrowNav from './components/NarrowNav';
import NarrowStack from './components/NarrowStack';
import { useIsNarrow } from './hooks/useIsNarrow';
export default function App() {
const phase = useAuthStore(s => s.phase);
@ -23,6 +26,10 @@ export default function App() {
const viewMode = useUIStore(s => s.viewMode);
const composing = useUIStore(s => s.composing);
const resetUI = useUIStore(s => s.reset);
const narrowPane = useUIStore(s => s.narrowPane);
const navOpen = useUIStore(s => s.navOpen);
const closeNav = useUIStore(s => s.closeNav);
const narrow = useIsNarrow();
const fetchInbox = useMailStore(s => s.fetchInbox);
const fetchSent = useMailStore(s => s.fetchSent);
@ -105,25 +112,84 @@ export default function App() {
return <AnonymousRoute onBootDone={bootstrap} />;
}
// 已登录主界面
// 主区域(右栏):写信 / 账号 / 管理 / 邮件详情
const main = composing ? (
<ComposePage />
) : viewMode === 'account' ? (
<AccountPage />
) : viewMode === 'admin' && user?.role === 'admin' ? (
<AdminUsersPage />
) : (
<MailView />
);
// 列表(中栏):仅收发件箱与联系人视图有
const list =
viewMode === 'contacts' ? (
<ContactPanel />
) : viewMode === 'inbox' || viewMode === 'sent' ? (
<MailList />
) : null;
// 账号/管理页没有列表栏,窄屏下要直接显示主区域,
// 否则会出现一片空白(列表为 null 而 narrowPane 还停在 'list'
const hasList = list !== null;
// ---- 窄屏:详情页从右侧滑入盖住列表,不分栏 ----
if (narrow) {
// 没有列表栏的视图(账号/管理/写信)直接铺满,不需要覆盖层:
// 它们本来就是单页,套一层滑动只会让「进入账号页」也带动画,很怪
if (!hasList) {
return (
<NarrowShell navOpen={navOpen} onCloseNav={closeNav}>
<div className="flex-1 min-h-0 flex">{main}</div>
</NarrowShell>
);
}
return (
<NarrowShell navOpen={navOpen} onCloseNav={closeNav}>
<NarrowStack base={list} overlay={main} open={narrowPane === 'detail'} />
</NarrowShell>
);
}
// ---- 宽屏:三栏并排 ----
return (
<div className="h-full flex bg-gray-50">
<Sidebar />
{viewMode === 'contacts' ? (
<ContactPanel />
) : viewMode === 'inbox' || viewMode === 'sent' ? (
<MailList />
) : null}
{composing ? (
<ComposePage />
) : viewMode === 'account' ? (
<AccountPage />
) : viewMode === 'admin' && user?.role === 'admin' ? (
<AdminUsersPage />
) : viewMode === 'inbox' || viewMode === 'sent' || viewMode === 'contacts' ? (
<MailView />
) : (
<MailView />
{list}
{main}
</div>
);
}
/**
* 窄屏外壳:内容区 + 底部导航 + 抽屉式侧栏。
*
* 侧栏在窄屏下是抽屉而不是常驻60px 竖条在手机上白占一成宽度,
* 而底部导航已经覆盖了日常切换,抽屉只留给不常用的入口。
*/
function NarrowShell({
children,
navOpen,
onCloseNav
}: {
children: React.ReactNode;
navOpen: boolean;
onCloseNav: () => void;
}) {
return (
<div className="h-full flex flex-col bg-gray-50 overflow-hidden">
{children}
<NarrowNav />
{navOpen && (
<>
{/* 遮罩:点空白处收起,这是移动端的通用预期 */}
<div className="fixed inset-0 bg-black/40 z-40" onClick={onCloseNav} aria-hidden="true" />
<div className="fixed left-0 top-0 bottom-0 z-50">
<Sidebar />
</div>
</>
)}
</div>
);

View File

@ -384,25 +384,33 @@ export async function forwardMail(id: string, payload: ForwardPayload) {
// ---------- 配额 ----------
export interface Quota {
/**
* Agent 的新任务默认预算与累计统计。
*
* 没有「剩余额度」字段 —— 额度属于具体任务(会话),见 SessionBudget。
* 这里只有「派给它的新任务默认几个来回」与「一共发了多少信」。
*/
export interface AgentStats {
agent_name: string;
max_rounds: number;
used_rounds: number;
/** 不限额时为 -1 */
remaining: number;
unlimited: boolean;
/** 派给该 Agent 的新任务默认多少个来回0 = 不限) */
default_rounds: number;
/** 累计发信数,纯统计,不拦请求 */
sent_total: number;
/** 参与的未归档会话数,配合默认值判断设多少合适 */
active_sessions: number;
}
export async function adminListQuotas() {
return request<{ quotas: Quota[] }>('GET', '/admin/quotas');
export async function adminListAgentStats() {
return request<{ quotas: AgentStats[] }>('GET', '/admin/quotas');
}
/** 设上限0 = 不限)或把已用次数归零 */
export async function adminSetQuota(
agentName: string,
payload: { max_rounds?: number; reset?: boolean }
) {
return request<{ quota: Quota }>('PUT', `/admin/quotas/${encodeURIComponent(agentName)}`, payload);
/** 改该 Agent 的新任务默认预算0 = 不限)。 */
export async function adminSetDefaultRounds(agentName: string, defaultRounds: number) {
return request<{ quota: AgentStats }>(
'PUT',
`/admin/quotas/${encodeURIComponent(agentName)}`,
{ default_rounds: defaultRounds }
);
}
// ---------- Sessions ----------

View File

@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
import { useAuthStore } from '../stores/authStore';
import * as api from '../api/client';
import NavToggle from './NavToggle';
import { LockIcon } from './icons';
import KeyPanel from './KeyPanel';
@ -85,11 +86,12 @@ export default function AccountPage() {
return (
<div className="flex-1 min-w-0 flex flex-col bg-white">
<div className="px-6 py-3 border-b border-gray-200">
<div className="px-4 md:px-6 py-3 border-b border-gray-200 flex items-center gap-2">
<NavToggle />
<h2 className="text-sm font-semibold text-gray-900"></h2>
</div>
<div className="max-w-lg px-6 py-6 space-y-6">
<div className="max-w-lg px-4 md:px-6 py-6 space-y-6">
{/* 基本信息 */}
<section>
<h3 className="text-xs font-medium text-gray-500 mb-3"></h3>

View File

@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import * as api from '../api/client';
import NavToggle from './NavToggle';
import type { AdminScopes, User } from '../types';
import { CheckIcon, LockIcon, UsersIcon, ChevronRightIcon, KeyIcon, BotIcon } from './icons';
import KeyPanel from './KeyPanel';
@ -95,7 +96,8 @@ export default function AdminUsersPage() {
return (
<div className="flex-1 min-w-0 flex flex-col bg-white">
<div className="px-6 py-3 border-b border-gray-200 flex items-center gap-1">
<div className="px-4 md:px-6 py-3 border-b border-gray-200 flex items-center gap-1 flex-wrap">
<NavToggle />
<TabButton active={tab === 'users'} onClick={() => setTab('users')}>
<UsersIcon className="w-4 h-4" />
@ -107,7 +109,7 @@ export default function AdminUsersPage() {
</TabButton>
<TabButton active={tab === 'quotas'} onClick={() => setTab('quotas')}>
<BotIcon className="w-4 h-4" />
</TabButton>
<div className="flex-1" />
{notice && <span className="text-xs text-green-600">{notice}</span>}
@ -121,11 +123,11 @@ export default function AdminUsersPage() {
{error && <p className="mx-6 mt-3 text-xs text-red-600 bg-red-50 border border-red-100 rounded-md px-2.5 py-1.5">{error}</p>}
{tab === 'quotas' ? (
<div className="flex-1 overflow-y-auto px-6 py-4">
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-4">
<QuotaPanel />
</div>
) : tab === 'keys' ? (
<div className="flex-1 overflow-y-auto px-6 py-4">
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-4">
<KeyPanel
variant="agent"
keys={keys}
@ -142,7 +144,7 @@ export default function AdminUsersPage() {
<>
{creating && <CreateUserForm scopes={scopes} onDone={() => { setCreating(false); flash('用户已创建'); load(); }} onError={setError} />}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-2">
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-4 space-y-2">
{users.map(u => (
<UserCard key={u.user_id} user={u} scopes={scopes}
expanded={editing === u.user_id}
@ -180,7 +182,7 @@ function UserCard({ user, scopes, expanded, onToggle, onSaved, onReload, setErro
}) {
return (
<div className="rounded-lg border border-gray-200">
<div className="px-4 py-2.5 flex items-center gap-3">
<div className="px-4 py-2.5 flex items-center gap-x-3 gap-y-1 flex-wrap">
<button onClick={onToggle} className="flex items-center gap-1 text-xs text-gray-400 hover:text-gray-600">
<ChevronRightIcon className={`w-3 h-3 transition-transform ${expanded ? 'rotate-90' : ''}`} />
</button>
@ -247,7 +249,7 @@ function UserEditor({ user, scopes, onSaved, onReload, setError }: {
return (
<div className="border-t border-gray-100 bg-gray-50 px-4 py-3 space-y-4">
<div className="grid grid-cols-3 gap-3 text-sm">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
<div>
<label className="block text-[11px] font-medium text-gray-500 mb-1"></label>
<input value={displayName} onChange={e => setDisplayName(e.target.value)}
@ -304,7 +306,7 @@ function UserEditor({ user, scopes, onSaved, onReload, setError }: {
</>
)}
<div className="flex items-center gap-3">
<div className="flex items-center gap-x-3 gap-y-1 flex-wrap">
<button onClick={save} disabled={busy} className="px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 transition-colors">
{busy ? '保存中' : '保存更改'}
</button>
@ -353,7 +355,7 @@ function CreateUserForm({ scopes, onDone, onError }: {
return (
<div className="mx-6 mt-3 p-4 rounded-lg border border-gray-200 bg-gray-50 space-y-3">
<div className="grid grid-cols-4 gap-3">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<Field label="用户名" hint="小写字母数字 . _ -">
<input value={username} onChange={e => setUsername(e.target.value)} placeholder="alice" spellCheck={false}
className="w-full text-sm font-mono border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400" />

View File

@ -0,0 +1,30 @@
import { useIsNarrow } from '../hooks/useIsNarrow';
import { useUIStore } from '../stores/uiStore';
import { ChevronLeftIcon } from './icons';
/**
* 窄屏返回按钮。
*
* 只在窄屏出现:宽屏是列表与详情并排,没有「返回」这个概念 ——
* 放一个按钮在那里,点了什么也不会发生。
*
* 覆盖式布局下返回 = 让覆盖层滑出去narrowPane 回到 list
* 而不是卸载详情组件:底层列表一直挂载着,滚动位置与选中态都还在。
*/
export default function BackButton({ label = '返回' }: { label?: string }) {
const narrow = useIsNarrow();
const showList = useUIStore(s => s.showList);
if (!narrow) return null;
return (
<button
onClick={showList}
className="shrink-0 -ml-1 mr-1 inline-flex items-center gap-0.5 py-1 pr-1.5 pl-0.5 rounded text-gray-500 active:bg-gray-100"
aria-label={label}
>
<ChevronLeftIcon className="w-4 h-4" />
<span className="text-xs">{label}</span>
</button>
);
}

View File

@ -3,12 +3,13 @@ import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import * as api from '../api/client';
import { useUIStore } from '../stores/uiStore';
import NarrowOnly from './NarrowOnly';
import { useMailStore } from '../stores/mailStore';
import { useSessionStore } from '../stores/sessionStore';
import { useContactStore } from '../stores/contactStore';
import AddressInput from './AddressInput';
import { AttachmentPicker, type PendingAttachment } from './Attachments';
import { ComposeIcon } from './icons';
import { ComposeIcon, ChevronLeftIcon } from './icons';
/** 完整的写邮件页面,占据右侧整个区域 */
export default function ComposePage() {
@ -28,6 +29,8 @@ export default function ComposePage() {
// 配额的语义是「这件事值得多少个来回」—— 那是任务的属性,所以在派活这一刻给,
// 而不是事后到管理员页面去调某个 Agent 的全局配额。
const [maxRounds, setMaxRounds] = useState('');
// 收件 Agent 的默认预算null = 还没查到(未注册的收件人也是 null
const [agentDefault, setAgentDefault] = useState<number | null>(null);
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
const [preview, setPreview] = useState(false);
const [sending, setSending] = useState(false);
@ -39,6 +42,34 @@ export default function ComposePage() {
setCc(prefill?.cc ?? '');
}, [prefill]);
// 三维地址的 name 位 = 收件 Agent 名
const toName = to.trim().split('@')[0].trim();
// 收件人变了就重查该 Agent 的默认预算。
// 只在新建会话时需要(续谈沿用会话已有预算),所以别的情况不打接口。
const isNewTarget = /\.new\s*$/.test(to.trim());
useEffect(() => {
if (!isNewTarget || toName === '') {
setAgentDefault(null);
return;
}
let alive = true;
api
.listAgents()
.then(r => {
if (!alive) return;
const hit = r.agents?.find(a => a.agent_name === toName);
setAgentDefault(hit?.default_rounds ?? null);
})
.catch(() => {
// 查不到就不显示提示,不该因此打断写信
if (alive) setAgentDefault(null);
});
return () => {
alive = false;
};
}, [toName, isNewTarget]);
// 会话别名只在新建会话(地址以 .new 结尾)时有意义;
// 命中已有会话或走默认会话时后端会忽略该字段。
const isNewSession = /\.new\s*$/.test(to.trim());
@ -49,6 +80,10 @@ export default function ComposePage() {
? '"new" 是寻址保留字'
: null;
// 输入框的 placeholder人在派活时该看得到「不填会是多少」
const defaultRoundsHint =
agentDefault === null ? '默认' : agentDefault === 0 ? '不限' : `默认 ${agentDefault}`;
const roundsError =
maxRounds.trim() !== '' && !/^\d+$/.test(maxRounds.trim())
? '预算必须是非负整数0 = 不限)'
@ -97,8 +132,20 @@ export default function ComposePage() {
return (
<div className="flex-1 min-w-0 flex flex-col bg-white">
<div className="px-6 py-3 border-b border-gray-200 flex items-center gap-2">
<ComposeIcon className="w-4 h-4 text-blue-600" />
<div className="px-4 md:px-6 py-3 border-b border-gray-200 flex items-center gap-2">
{/* 窄屏下写信是盖在列表上的覆盖层,得有个退出口。
用 cancelCompose 而不是 showList写信态本身要一起结束
只滑走覆盖层的话下次进列表又会弹回来 */}
<NarrowOnly>
<button
onClick={cancelCompose}
className="-ml-1 inline-flex items-center gap-0.5 py-1 pr-1 text-gray-500 active:bg-gray-100 rounded"
aria-label="返回"
>
<ChevronLeftIcon className="w-4 h-4" />
</button>
</NarrowOnly>
<ComposeIcon className="w-4 h-4 text-blue-600 shrink-0" />
<h2 className="text-sm font-semibold text-gray-900"></h2>
<div className="flex-1" />
<button
@ -120,7 +167,7 @@ export default function ComposePage() {
</button>
</div>
<div className="px-6 py-4 space-y-3 border-b border-gray-200">
<div className="px-4 md:px-6 py-4 space-y-3 border-b border-gray-200">
<Field label="收件人" hint="name@path.session省略=默认会话new=新建,别名=已有会话">
<AddressInput
value={to}
@ -143,16 +190,13 @@ export default function ComposePage() {
)}
{isNewSession && (
<Field
label="往返预算"
hint="可选;留空或 0 = 不限。之后可在对话页随时调整"
>
<Field label="往返预算" hint="留空 = 用该 Agent 的默认值;之后可在对话页随时调整">
<div className="flex items-center gap-2">
<input
value={maxRounds}
onChange={e => setMaxRounds(e.target.value)}
inputMode="numeric"
placeholder="不限"
placeholder={defaultRoundsHint}
className="w-24 text-sm border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
<span className="text-[11px] text-gray-400">
@ -177,7 +221,7 @@ export default function ComposePage() {
</Field>
</div>
<div className="flex-1 min-h-0 px-6 py-3 flex flex-col">
<div className="flex-1 min-h-0 px-4 md:px-6 py-3 flex flex-col">
<div className="flex items-center gap-2 mb-1.5">
<span className="text-[11px] font-medium text-gray-500">Markdown</span>
<div className="flex-1" />
@ -207,11 +251,11 @@ export default function ComposePage() {
)}
</div>
<div className="px-6 pb-3">
<div className="px-4 md:px-6 pb-3">
<AttachmentPicker items={attachments} onChange={setAttachments} disabled={sending} />
</div>
<div className="px-6 py-3 border-t border-gray-200 flex items-center gap-3">
<div className="px-4 md:px-6 py-3 border-t border-gray-200 flex items-center gap-3">
{error && <span className="text-xs text-red-600">{error}</span>}
{okMsg && <span className="text-xs text-green-600">{okMsg}</span>}
<div className="flex-1" />

View File

@ -4,7 +4,17 @@ import { useSessionStore } from '../stores/sessionStore';
import { useUIStore } from '../stores/uiStore';
import { useMailStore } from '../stores/mailStore';
import type { Contact } from '../types';
import { ArchiveIcon, ComposeIcon, CheckIcon, CloseIcon, ChevronRightIcon } from './icons';
import {
ArchiveIcon,
ComposeIcon,
CheckIcon,
CloseIcon,
ChevronRightIcon,
ListViewIcon,
CardViewIcon
} from './icons';
import NavToggle from './NavToggle';
import { WorkCard } from './WorkCard';
/**
* 左侧联系人面板:列出所有 name@path.session支持
@ -24,12 +34,15 @@ export default function ContactPanel() {
const requestArchive = useContactStore(s => s.requestArchive);
const cancelArchive = useContactStore(s => s.cancelArchive);
const archive = useContactStore(s => s.archive);
const view = useContactStore(s => s.view);
const setView = useContactStore(s => s.setView);
const selectSession = useSessionStore(s => s.selectSession);
const currentSession = useSessionStore(s => s.currentSession);
const clearCurrentMail = useMailStore(s => s.clearCurrentMail);
const startCompose = useUIStore(s => s.startCompose);
const cancelCompose = useUIStore(s => s.cancelCompose);
const showDetail = useUIStore(s => s.showDetail);
useEffect(() => {
fetchContacts();
@ -39,14 +52,35 @@ export default function ContactPanel() {
cancelCompose();
clearCurrentMail();
selectSession(c.session_id);
showDetail(); // 窄屏下切到会话内容栏
};
return (
<div className="w-[320px] shrink-0 border-r border-gray-200 bg-white flex flex-col">
<div className="px-4 py-3 border-b border-gray-200 flex items-center">
<h2 className="text-sm font-semibold text-gray-800"></h2>
<div
className={`w-full shrink-0 border-r border-gray-200 bg-white flex flex-col min-w-0 ${
// 卡片要放两行摘要 + 预算条320px 会挤;列表视图保持紧凑
view === 'card' ? 'md:w-[400px]' : 'md:w-[320px]'
}`}
>
<div className="px-4 py-3 border-b border-gray-200 flex items-center gap-1">
<NavToggle />
<h2 className="text-sm font-semibold text-gray-800">
{view === 'card' ? '工作列表' : '联系人'}
</h2>
<span className="ml-2 text-xs text-gray-400">{contacts.length}</span>
<div className="flex-1" />
{/* 视图切换:列表答「跟谁在聊」,卡片答「在聊什么、进展如何」 */}
<button
onClick={() => setView(view === 'list' ? 'card' : 'list')}
title={view === 'list' ? '切换到卡片视图' : '切换到列表视图'}
className="p-1 rounded text-gray-400 hover:text-gray-700 hover:bg-gray-100"
>
{view === 'list' ? (
<CardViewIcon className="w-3.5 h-3.5" />
) : (
<ListViewIcon className="w-3.5 h-3.5" />
)}
</button>
<button
onClick={toggleArchivedView}
className={`text-[11px] px-1.5 py-0.5 rounded ${
@ -64,23 +98,42 @@ export default function ContactPanel() {
<p className="text-xs text-gray-400 text-center py-6"></p>
)}
{contacts.map(c => (
<ContactRow
key={c.session_id}
contact={c}
active={currentSession?.session_id === c.session_id}
confirming={pendingArchive === c.address}
onOpen={() => open(c)}
onCompose={() => startCompose({ to: c.address })}
onRequestArchive={() => requestArchive(c.address)}
onCancelArchive={cancelArchive}
onConfirmArchive={() => archive(c)}
/>
))}
{contacts.map(c =>
// 归档确认态两种视图共用同一个确认框:那是个破坏性操作,
// 换个视图就换套确认 UI 只会让人对「点了什么」更没底
pendingArchive === c.address ? (
<ArchiveConfirm
key={c.session_id}
contact={c}
onCancel={cancelArchive}
onConfirm={() => archive(c)}
/>
) : view === 'card' ? (
<WorkCard
key={c.session_id}
contact={c}
active={currentSession?.session_id === c.session_id}
onOpen={() => open(c)}
onCompose={() => startCompose({ to: c.address })}
onArchive={() => requestArchive(c.address)}
/>
) : (
<ContactRow
key={c.session_id}
contact={c}
active={currentSession?.session_id === c.session_id}
onOpen={() => open(c)}
onCompose={() => startCompose({ to: c.address })}
onRequestArchive={() => requestArchive(c.address)}
/>
)
)}
{!loading && contacts.length === 0 && (
<p className="text-xs text-gray-400 text-center py-6">
{view === 'card'
? '暂无进行中的工作,发一封邮件即可开始'
: '暂无联系人,发一封邮件即可建立'}
</p>
)}
@ -110,24 +163,61 @@ export default function ContactPanel() {
);
}
/**
* 归档确认框。
*
* 列表视图与卡片视图共用:归档是破坏性操作,换个视图就换套确认 UI
* 只会让人对「自己点了什么」更没底。
*/
function ArchiveConfirm({
contact,
onCancel,
onConfirm
}: {
contact: Contact;
onCancel: () => void;
onConfirm: () => void;
}) {
return (
<div className="px-3 py-2.5 rounded-lg border border-red-200 bg-red-50">
<p className="text-xs text-gray-800">
<span className="font-mono">{contact.address}</span>
</p>
<p className="text-[10px] text-gray-500 mt-0.5">
Agent session
</p>
<div className="flex gap-2 mt-2">
<button
onClick={onConfirm}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md bg-red-600 text-white text-[11px] font-medium hover:bg-red-700"
>
<CheckIcon className="w-3 h-3" />
</button>
<button
onClick={onCancel}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md border border-gray-300 text-gray-600 text-[11px] hover:bg-white"
>
<CloseIcon className="w-3 h-3" />
</button>
</div>
</div>
);
}
function ContactRow({
contact,
active,
confirming,
onOpen,
onCompose,
onRequestArchive,
onCancelArchive,
onConfirmArchive
onRequestArchive
}: {
contact: Contact;
active: boolean;
confirming: boolean;
onOpen: () => void;
onCompose: () => void;
onRequestArchive: () => void;
onCancelArchive: () => void;
onConfirmArchive: () => void;
}) {
const time = new Date(contact.last_activity).toLocaleString('zh-CN', {
month: '2-digit',
@ -136,35 +226,6 @@ function ContactRow({
minute: '2-digit'
});
if (confirming) {
return (
<div className="px-3 py-2.5 rounded-lg border border-red-200 bg-red-50">
<p className="text-xs text-gray-800">
<span className="font-mono">{contact.address}</span>
</p>
<p className="text-[10px] text-gray-500 mt-0.5">
Agent session
</p>
<div className="flex gap-2 mt-2">
<button
onClick={onConfirmArchive}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md bg-red-600 text-white text-[11px] font-medium hover:bg-red-700"
>
<CheckIcon className="w-3 h-3" />
</button>
<button
onClick={onCancelArchive}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md border border-gray-300 text-gray-600 text-[11px] hover:bg-white"
>
<CloseIcon className="w-3 h-3" />
</button>
</div>
</div>
);
}
return (
<div
className={`group px-3 py-2.5 rounded-lg border transition-colors ${

View File

@ -109,7 +109,7 @@ function CreateForm({ variant, busy, onSubmit }: CreateFormProps) {
return (
<div className="border border-gray-200 rounded-md p-3 space-y-2.5 bg-gray-50">
<div className="grid grid-cols-3 gap-2">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
{(Object.keys(KEY_TYPE_LABEL) as api.KeyType[]).map(t => (
<button
key={t}
@ -237,7 +237,7 @@ export default function KeyPanel({
const st = keyState(k);
const agentKey = variant === 'agent' ? (k as api.AgentKey) : null;
return (
<div key={k.key_id} className="px-3 py-2.5 flex items-center gap-3">
<div key={k.key_id} className="px-3 py-2.5 flex items-center gap-x-3 gap-y-1 flex-wrap">
<code className="text-[11px] font-mono text-gray-700 w-24 shrink-0">
{k.token_hint}
</code>

View File

@ -4,10 +4,12 @@ import { useSessionStore } from '../stores/sessionStore';
import { useUIStore } from '../stores/uiStore';
import type { Mail } from '../types';
import { ShieldIcon, PaperclipIcon } from './icons';
import NavToggle from './NavToggle';
export default function MailList() {
const viewMode = useUIStore(s => s.viewMode);
const cancelCompose = useUIStore(s => s.cancelCompose);
const showDetail = useUIStore(s => s.showDetail);
const inbox = useMailStore(s => s.inbox);
const sent = useMailStore(s => s.sent);
@ -29,11 +31,16 @@ export default function MailList() {
clearSession();
cancelCompose();
selectMail(m);
// 窄屏下列表与详情共用一栏,选中后要切过去;
// 宽屏下这个状态不影响渲染(两栏并排),但仍然维护 ——
// 否则从窄屏拖宽再拖回来,用户会发现自己回到了列表,刚打开的邮件不见了
showDetail();
};
return (
<div className="w-[320px] shrink-0 border-r border-gray-200 bg-white flex flex-col">
<div className="px-4 py-3 border-b border-gray-200 flex items-center">
<div className="w-full md: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">
<NavToggle />
<h2 className="text-sm font-semibold text-gray-800">{isSent ? '发件箱' : '收件箱'}</h2>
<span className="ml-2 text-xs text-gray-400">{list.length}</span>
</div>

View File

@ -10,6 +10,7 @@ import { MailIcon, ShieldIcon, PersonIcon, BotIcon, CheckIcon, CloseIcon, Forwar
import AddressInput from './AddressInput';
import { AttachmentList, AttachmentPicker, type PendingAttachment } from './Attachments';
import ThreadView from './ThreadView';
import BackButton from './BackButton';
export default function MailView() {
const currentMail = useMailStore(s => s.currentMail);
@ -31,8 +32,9 @@ export default function MailView() {
const last = currentSessionMails[currentSessionMails.length - 1];
return (
<div className="flex-1 min-w-0 flex flex-col bg-gray-50">
<div className="px-6 py-3 border-b border-gray-200 bg-white">
<div className="flex items-center gap-2">
<div className="px-4 md:px-6 py-3 border-b border-gray-200 bg-white">
<div className="flex items-center gap-2 flex-wrap">
<BackButton label="会话" />
<span className="text-sm font-semibold text-gray-900 font-mono">
{currentSession.session_alias
? `.${currentSession.session_alias}`
@ -48,12 +50,18 @@ export default function MailView() {
<p className="text-xs text-gray-500 mt-0.5">{currentSession.subject}</p>
</div>
<RenameProposalBar />
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-3">
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-4 space-y-3">
{currentSessionMails.map(m => (
<ThreadCard key={m.mail_id} mail={m} />
<ThreadCard key={m.mail_id} mail={m} onForward={() => setForwarding(m)} />
))}
</div>
<ReplyBar replyTo={last} />
{/* 会话视图原先只有回复,转发入口只存在于单封邮件视图 ——
而人多数时间待在会话视图里,等于转发功能在 UI 上找不到 */}
{forwarding ? (
<ForwardBar mail={forwarding} onClose={() => setForwarding(null)} />
) : (
<ReplyBar replyTo={last} />
)}
</div>
);
}
@ -81,7 +89,7 @@ export default function MailView() {
onForward={() => setForwarding(currentMail)}
onThread={() => setThreadOf(currentMail.mail_id)}
/>
<div className="flex-1 overflow-y-auto 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">
<Markdown remarkPlugins={[remarkGfm]}>{currentMail.body}</Markdown>
</div>
@ -207,7 +215,7 @@ function RenameProposalBar() {
const from = current?.session_alias ? `.${current.session_alias}` : '(未命名)';
return (
<div className="px-6 py-2.5 bg-blue-50 border-b border-blue-100">
<div className="px-4 md:px-6 py-2.5 bg-blue-50 border-b border-blue-100">
<div className="flex items-start gap-2">
<TagIcon className="w-3.5 h-3.5 text-blue-500 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
@ -251,6 +259,8 @@ function RenameProposalBar() {
*/
function ForwardBar({ mail, onClose }: { mail: Mail; onClose: () => void }) {
const [to, setTo] = useState('');
const [cc, setCc] = useState('');
const [ccOpen, setCcOpen] = useState(false);
const [comment, setComment] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -264,7 +274,11 @@ function ForwardBar({ mail, onClose }: { mail: Mail; onClose: () => void }) {
setBusy(true);
setError(null);
try {
await api.forwardMail(mail.mail_id, { to: to.trim(), comment: comment.trim() });
await api.forwardMail(mail.mail_id, {
to: to.trim(),
cc: cc.trim(),
comment: comment.trim()
});
await Promise.all([fetchInbox('all'), fetchSent(), fetchSessions(), fetchContacts()]);
onClose();
} catch (err) {
@ -275,22 +289,36 @@ function ForwardBar({ mail, onClose }: { mail: Mail; onClose: () => void }) {
};
return (
<div className="border-t border-gray-200 bg-white px-6 py-3 space-y-2">
<div className="border-t border-gray-200 bg-white px-4 md:px-6 py-3 space-y-2">
<div className="flex items-center gap-2">
<ForwardIcon className="w-3.5 h-3.5 text-gray-500" />
<span className="text-[11px] font-medium text-gray-600">
{mail.subject}
</span>
<div className="flex-1" />
<span className="text-[10px] text-gray-400"></span>
<button
onClick={() => setCcOpen(o => !o)}
className={`text-[10px] ${ccOpen ? 'text-blue-600' : 'text-gray-500 hover:text-blue-600'}`}
>
{ccOpen ? '收起抄送' : '抄送'}
</button>
</div>
<AddressInput value={to} onChange={setTo} autoFocus placeholder="新收件人pi@root.new" />
{ccOpen && (
<AddressInput
value={cc}
onChange={setCc}
allowMultiple
placeholder="抄送:逗号分隔,可多个"
/>
)}
<textarea
value={comment}
onChange={e => setComment(e.target.value)}
placeholder="转发说明(可选,置于引用原文之前)"
placeholder="转发说明(可选,置于引用原文之前;原文将以引用块附在下方"
className="w-full h-16 text-sm border border-gray-300 rounded-md p-2.5 resize-none focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
@ -330,9 +358,10 @@ function Header({
const to = `${mail.to_name}${mail.to_workspace ? '@' + mail.to_workspace : ''}`;
return (
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex items-center gap-2 mb-1.5">
<h2 className="text-sm font-semibold text-gray-900">{mail.subject}</h2>
<div className="px-4 md:px-6 py-3 md:py-4 border-b border-gray-200">
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
<BackButton />
<h2 className="text-sm font-semibold text-gray-900 min-w-0 break-words">{mail.subject}</h2>
{mail.status === 'unread' && (
<span className="px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 text-[10px] font-medium">
@ -388,7 +417,7 @@ function Row({ label, children }: { label: string; children: React.ReactNode })
);
}
function ThreadCard({ mail }: { mail: Mail }) {
function ThreadCard({ mail, onForward }: { mail: Mail; onForward?: () => void }) {
const isHuman = mail.from_name === 'human';
const isPermission = mail.mail_type === 'permission_request';
const time = new Date(mail.created_at).toLocaleString('zh-CN');
@ -418,10 +447,24 @@ function ThreadCard({ mail }: { mail: Mail }) {
</span>
)}
{mail.cc_list?.length > 0 && (
<span className="text-[10px] text-gray-400"> {mail.cc_list.length}</span>
<span
className="text-[10px] text-gray-400"
title={mail.cc_list.map(c => c.raw || c.name).join(', ')}
>
{mail.cc_list.length}
</span>
)}
<div className="flex-1" />
<span className="text-[10px] text-gray-400">{time}</span>
{onForward && (
<button
onClick={onForward}
title="转发这封"
className="text-gray-400 hover:text-blue-600"
>
<ForwardIcon className="w-3 h-3" />
</button>
)}
</div>
<div className="prose prose-sm max-w-none">
<Markdown remarkPlugins={[remarkGfm]}>{mail.body}</Markdown>
@ -499,8 +542,13 @@ function PermissionPanel({ mail }: { mail: Mail }) {
function ReplyBar({ replyTo }: { replyTo?: Mail }) {
const [body, setBody] = useState('');
const [cc, setCc] = useState('');
// 抄送默认收起:多数回复不需要它,常驻一行输入框只会挤掉正文空间。
// 原邮件带抄送时自动展开并预填 —— 「回复全部」是人在这种场景下的默认预期
const [ccOpen, setCcOpen] = useState(false);
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchInbox = useMailStore(s => s.fetchInbox);
const fetchSent = useMailStore(s => s.fetchSent);
const fetchSessions = useSessionStore(s => s.fetchSessions);
@ -519,25 +567,78 @@ function ReplyBar({ replyTo }: { replyTo?: Mail }) {
const send = async () => {
if (!body.trim()) return;
setBusy(true);
setError(null);
try {
await api.sendMail(target, `Re: ${replyTo.subject}`, body, {
reply_to: replyTo.mail_id,
cc: cc.trim(),
attachment_ids: attachments.map(a => a.id)
});
setBody('');
setCc('');
setCcOpen(false);
setAttachments([]);
await Promise.all([fetchInbox('all'), fetchSent(), fetchSessions(), fetchContacts()]);
if (replyTo.session_id) selectSession(replyTo.session_id);
} catch (err) {
console.error(err);
// 必须显示出来:预算耗尽、地址不存在、速率限制都会走到这里,
// 原先只 console.error用户点了发送什么反应都没有
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
/**
* 「回复全部」:把原邮件的其他参与方填进抄送。
*
* cc_list 是结构化的 Address后端解析过三维寻址取 raw 回填 ——
* 那是用户当初写下的原文,重新拼 name@path 会丢掉会话段。
*/
const replyAll = () => {
const others = [
`${replyTo.from_name}${replyTo.from_workspace ? '@' + replyTo.from_workspace : ''}`,
`${replyTo.to_name}${replyTo.to_workspace ? '@' + replyTo.to_workspace : ''}`,
...(replyTo.cc_list ?? []).map(c => c.raw || c.name)
]
// 去掉自己与主收件人:前者收不到自己的信没意义,后者已经在 to 里
.filter(a => a && !a.startsWith('human') && !a.startsWith(peerName))
// 同一个人可能既在 to 又在 cc 里
.filter((a, i, arr) => arr.indexOf(a) === i);
setCc(others.join(', '));
setCcOpen(true);
};
return (
<div className="border-t border-gray-200 bg-white px-6 py-3">
<p className="text-[10px] text-gray-400 mb-1 font-mono"> {target}</p>
<div className="border-t border-gray-200 bg-white px-4 md:px-6 py-3">
<div className="flex items-center gap-2 mb-1">
<p className="text-[10px] text-gray-400 font-mono min-w-0 truncate"> {target}</p>
<div className="flex-1" />
{(replyTo.cc_list?.length ?? 0) > 0 && (
<button
onClick={replyAll}
className="text-[10px] text-gray-500 hover:text-blue-600"
>
</button>
)}
<button
onClick={() => setCcOpen(o => !o)}
className={`text-[10px] ${ccOpen ? 'text-blue-600' : 'text-gray-500 hover:text-blue-600'}`}
>
{ccOpen ? '收起抄送' : '抄送'}
</button>
</div>
{ccOpen && (
<div className="mb-2">
<AddressInput
value={cc}
onChange={setCc}
allowMultiple
placeholder="抄送:逗号分隔,可多个(如 pi@root, ops@root"
/>
</div>
)}
<textarea
value={body}
onChange={e => setBody(e.target.value)}
@ -547,9 +648,14 @@ function ReplyBar({ replyTo }: { replyTo?: Mail }) {
<div className="mt-2">
<AttachmentPicker items={attachments} onChange={setAttachments} disabled={busy} />
</div>
<div className="flex justify-end gap-2 mt-2">
<div className="flex items-center gap-2 mt-2 flex-wrap">
{error && <span className="text-xs text-red-600 min-w-0 break-words">{error}</span>}
<div className="flex-1" />
<button
onClick={() => setBody('')}
onClick={() => {
setBody('');
setError(null);
}}
className="px-3 py-1.5 text-xs text-gray-500 hover:text-gray-800"
>

View File

@ -0,0 +1,103 @@
import { useUIStore, type ViewMode } from '../stores/uiStore';
import { useMailStore } from '../stores/mailStore';
import { useContactStore } from '../stores/contactStore';
import { useAuthStore } from '../stores/authStore';
import { InboxIcon, SentIcon, ContactsIcon, ComposeIcon, UsersIcon, PersonIcon } from './icons';
/**
* 窄屏底部导航。
*
* 移动端把主导航放底部而不是顶部:拇指够得到。
* 宽屏用的是左侧竖条Sidebar两者共用 uiStore 的 viewMode
* 所以从窄拖到宽不会丢失当前位置。
*
* 这里只放最常用的几项 + 一个「更多」入口(打开抽屉式 Sidebar——
* 底部塞满图标会挤成一排看不懂的小方块。
*/
const items: {
short: string;
mode: ViewMode;
Icon: (p: { className?: string }) => JSX.Element;
adminOnly?: boolean;
}[] = [
{ short: '收件', mode: 'inbox', Icon: InboxIcon },
{ short: '发件', mode: 'sent', Icon: SentIcon },
{ short: '联系人', mode: 'contacts', Icon: ContactsIcon },
{ short: '管理', mode: 'admin', Icon: UsersIcon, adminOnly: true }
];
export default function NarrowNav() {
const viewMode = useUIStore(s => s.viewMode);
const setViewMode = useUIStore(s => s.setViewMode);
const composing = useUIStore(s => s.composing);
const startCompose = useUIStore(s => s.startCompose);
const narrowPane = useUIStore(s => s.narrowPane);
const inbox = useMailStore(s => s.inbox);
const unread = inbox.filter(m => m.status === 'unread').length;
const contacts = useContactStore(s => s.contacts);
const user = useAuthStore(s => s.user);
const isAdmin = user?.role === 'admin';
const visible = items.filter(n => !n.adminOnly || isAdmin);
return (
<nav
className="shrink-0 border-t border-slate-700 bg-slate-900 flex items-stretch"
// 底部安全区iPhone 的手势条会盖住最后一排
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
>
{visible.map(({ short, mode, Icon }) => {
// 详情栏打开时不高亮任何导航项:此刻用户看的是某封邮件,
// 高亮「收件」会让人以为点它能回到列表(其实是同一项)
const active = viewMode === mode && !composing && narrowPane === 'list';
const badge = mode === 'inbox' ? unread : mode === 'contacts' ? contacts.length : 0;
return (
<button
key={mode}
onClick={() => setViewMode(mode)}
className={`relative flex-1 py-2 flex flex-col items-center justify-center gap-0.5 transition-colors ${
active ? 'text-white' : 'text-slate-400 active:bg-slate-800'
}`}
>
<Icon />
<span className="text-[10px] leading-none">{short}</span>
{badge > 0 && (
<span
className={`absolute top-1 right-[22%] min-w-[15px] h-[15px] px-1 rounded-full text-[9px] font-bold flex items-center justify-center ${
mode === 'inbox' ? 'bg-red-500 text-white' : 'bg-slate-600 text-slate-100'
}`}
>
{badge > 99 ? '99+' : badge}
</span>
)}
{active && <span className="absolute top-0 left-1/4 right-1/4 h-0.5 bg-blue-400" />}
</button>
);
})}
<button
onClick={() => startCompose()}
className={`flex-1 py-2 flex flex-col items-center justify-center gap-0.5 ${
composing ? 'text-blue-300' : 'text-blue-400 active:bg-slate-800'
}`}
>
<ComposeIcon />
<span className="text-[10px] leading-none"></span>
</button>
<button
onClick={() => setViewMode('account')}
className={`flex-1 py-2 flex flex-col items-center justify-center gap-0.5 ${
viewMode === 'account' && !composing
? 'text-white'
: 'text-slate-400 active:bg-slate-800'
}`}
>
<PersonIcon />
<span className="text-[10px] leading-none"></span>
</button>
</nav>
);
}

View File

@ -0,0 +1,11 @@
import { useIsNarrow } from '../hooks/useIsNarrow';
/**
* 只在窄屏渲染子元素。
*
* 不用 Tailwind 的 `md:hidden`:那只是视觉隐藏,元素仍在 DOM 与 tab 序列里,
* 宽屏用户按 Tab 会聚焦到一个看不见的返回按钮上。
*/
export default function NarrowOnly({ children }: { children: React.ReactNode }) {
return useIsNarrow() ? <>{children}</> : null;
}

View File

@ -0,0 +1,77 @@
import { useEffect, useRef, useState } from 'react';
/**
* 窄屏下的「页面覆盖」容器。
*
* 与分栏的区别:底层页面(列表)始终挂载,详情页从右侧滑入**盖在它上面**。
* 这样做的两个实际好处:
* - 列表的滚动位置与选中态天然保留 —— 它没被卸载
* - 退出动画有东西可播:如果直接卸载再渲染另一个组件,没有任何一帧
* 能让旧页面往右滑出去
*
* 因此这里必须区分「逻辑上是否打开」open与「是否还在 DOM 里」mounted
* 关闭时先播 200ms 滑出动画,动画结束才卸载。
*/
export default function NarrowStack({
base,
overlay,
open
}: {
base: React.ReactNode;
overlay: React.ReactNode;
open: boolean;
}) {
// mounted是否在 DOM 里。entered是否已滑到位用于触发 transition
const [mounted, setMounted] = useState(open);
const [entered, setEntered] = useState(open);
const timer = useRef<number | null>(null);
useEffect(() => {
if (timer.current !== null) {
clearTimeout(timer.current);
timer.current = null;
}
if (open) {
setMounted(true);
// 必须等浏览器至少绘制一帧「在右侧之外」的状态,否则从挂载到
// translate-x-0 是同一帧内完成的transition 不会触发。
// 两层 rAF 是跨浏览器最稳的写法(单层在 Safari 上偶尔仍被合帧)。
const raf = requestAnimationFrame(() => requestAnimationFrame(() => setEntered(true)));
return () => cancelAnimationFrame(raf);
}
setEntered(false);
// 与下面的 duration-200 保持一致;提前卸载会把动画切掉半截
timer.current = window.setTimeout(() => {
setMounted(false);
timer.current = null;
}, 200);
return () => {
if (timer.current !== null) {
clearTimeout(timer.current);
timer.current = null;
}
};
}, [open]);
return (
<div className="flex-1 min-h-0 relative overflow-hidden">
{/* 底层:始终挂载。打开覆盖层时用 aria-hidden 把它从无障碍树里摘掉,
否则屏幕阅读器会读到两层内容 */}
<div className="absolute inset-0 flex" aria-hidden={open ? 'true' : undefined}>
{base}
</div>
{mounted && (
<div
className={`absolute inset-0 flex bg-white shadow-2xl transition-transform duration-200 ease-out motion-reduce:transition-none ${
entered ? 'translate-x-0' : 'translate-x-full'
}`}
>
{overlay}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,26 @@
import { useIsNarrow } from '../hooks/useIsNarrow';
import { useUIStore } from '../stores/uiStore';
import { MenuIcon } from './icons';
/**
* 窄屏下打开抽屉式侧栏的按钮。
*
* 只在窄屏渲染 —— 宽屏侧栏是常驻的,放个汉堡按钮点了什么也不会发生。
* 侧栏里有底部导航没放的入口(退出登录等)。
*/
export default function NavToggle() {
const narrow = useIsNarrow();
const toggleNav = useUIStore(s => s.toggleNav);
if (!narrow) return null;
return (
<button
onClick={toggleNav}
className="-ml-1 mr-0.5 p-1 rounded text-gray-500 active:bg-gray-100"
aria-label="打开导航"
>
<MenuIcon className="w-4 h-4" />
</button>
);
}

View File

@ -3,21 +3,27 @@ import * as api from '../api/client';
import { BotIcon, CheckIcon } from './icons';
/**
* Agent 发信配额面板(管理员)。
* Agent 新任务默认预算(管理员)。
*
* 配额限制的是 Agent 主动发信的次数,不限制收信 —— 卡住收信只会让邮件凭空消失,
* 卡住发信才能阻止 Agent 无限自我循环。上限 0 表示不限。
* **这里配的是默认值,不是额度。**
*
* 额度(往返预算)属于具体任务 —— 在写信时给、在对话页里随时改。
* 这个面板只决定「派给某个 Agent 的新任务,如果没人显式指定,默认给几个来回」:
* 跑测试的小工具与重构整个模块的 Agent合理来回数差一个量级所以分开设。
*
* 累计发信数只是观测数据,不拦任何请求 —— 之前它是「终身额度」,
* 但终身额度跑满要人工重置才能再干活,而 Agent 是长期在线的。
*/
export default function QuotaPanel() {
const [quotas, setQuotas] = useState<api.Quota[]>([]);
const [stats, setStats] = useState<api.AgentStats[]>([]);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [drafts, setDrafts] = useState<Record<string, string>>({});
const load = useCallback(async () => {
try {
const r = await api.adminListQuotas();
setQuotas(r.quotas);
const r = await api.adminListAgentStats();
setStats(r.quotas);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
@ -28,11 +34,11 @@ export default function QuotaPanel() {
load();
}, [load]);
const apply = async (name: string, payload: { max_rounds?: number; reset?: boolean }) => {
const apply = async (name: string, defaultRounds: number) => {
setBusy(name);
setError(null);
try {
await api.adminSetQuota(name, payload);
await api.adminSetDefaultRounds(name, defaultRounds);
await load();
setDrafts(d => {
const next = { ...d };
@ -50,76 +56,57 @@ export default function QuotaPanel() {
<div className="space-y-3">
<div className="flex items-center gap-2">
<BotIcon className="w-4 h-4 text-gray-500" />
<h3 className="text-sm font-semibold text-gray-900">Agent </h3>
<span className="text-xs text-gray-400">{quotas.length}</span>
<h3 className="text-sm font-semibold text-gray-900">Agent </h3>
<span className="text-xs text-gray-400">{stats.length}</span>
</div>
<p className="text-[11px] text-gray-500">
Agent 0
Agent
Agent 0 =
<br />
</p>
{error && <div className="text-xs text-red-600">{error}</div>}
{quotas.length === 0 ? (
{stats.length === 0 ? (
<div className="text-xs text-gray-400 py-3"> Agent</div>
) : (
<div className="border border-gray-200 rounded-md divide-y divide-gray-100">
{quotas.map(q => {
const draft = drafts[q.agent_name] ?? String(q.max_rounds);
const dirty = draft !== String(q.max_rounds);
const exhausted = !q.unlimited && q.remaining === 0;
{stats.map(s => {
const draft = drafts[s.agent_name] ?? String(s.default_rounds);
const dirty = draft !== String(s.default_rounds);
const invalid = draft.trim() !== '' && !/^\d+$/.test(draft.trim());
return (
<div key={q.agent_name} className="px-3 py-2.5 flex items-center gap-3">
<div key={s.agent_name} className="px-3 py-2.5 flex items-center gap-x-3 gap-y-1 flex-wrap">
<span className="text-xs font-mono text-gray-900 w-32 shrink-0 truncate">
{q.agent_name}
{s.agent_name}
</span>
<div className="min-w-0 flex-1">
{q.unlimited ? (
<span className="text-xs text-gray-500"> {q.used_rounds}</span>
) : (
<div className="flex items-center gap-2">
<div className="w-28 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full ${exhausted ? 'bg-red-400' : 'bg-blue-400'}`}
style={{
width: `${Math.min(100, (q.used_rounds / Math.max(1, q.max_rounds)) * 100)}%`
}}
/>
</div>
<span
className={`text-[11px] ${exhausted ? 'text-red-600' : 'text-gray-500'}`}
>
{q.used_rounds}/{q.max_rounds}
{exhausted && ' · 已用尽'}
</span>
</div>
)}
<div className="min-w-0 flex-1 text-[11px] text-gray-500">
{s.default_rounds === 0 ? '默认不限来回' : `默认 ${s.default_rounds} 个来回`}
<span className="text-gray-400">
{' · '} {s.active_sessions} · {s.sent_total}
</span>
</div>
<input
type="number"
min={0}
value={draft}
onChange={e => setDrafts(d => ({ ...d, [q.agent_name]: e.target.value }))}
className="w-16 text-xs border border-gray-300 rounded px-1.5 py-1 shrink-0 focus:outline-none focus:ring-2 focus:ring-blue-100"
onChange={e => setDrafts(d => ({ ...d, [s.agent_name]: e.target.value }))}
inputMode="numeric"
title="新任务默认往返数0 = 不限"
className={`w-16 text-xs border rounded px-1.5 py-1 shrink-0 focus:outline-none focus:ring-2 focus:ring-blue-100 ${
invalid ? 'border-red-300' : 'border-gray-300'
}`}
/>
<button
onClick={() => apply(q.agent_name, { max_rounds: Math.max(0, Number(draft) || 0) })}
disabled={!dirty || busy === q.agent_name}
title="保存上限"
onClick={() => apply(s.agent_name, Number(draft.trim() || '0'))}
disabled={!dirty || invalid || busy === s.agent_name}
title="保存默认预算"
className="shrink-0 text-gray-400 hover:text-blue-600 disabled:opacity-30"
>
<CheckIcon className="w-4 h-4" />
</button>
<button
onClick={() => apply(q.agent_name, { reset: true })}
disabled={busy === q.agent_name || q.used_rounds === 0}
className="shrink-0 text-[11px] text-gray-500 hover:text-gray-900 disabled:opacity-30"
>
</button>
</div>
);
})}

View File

@ -39,7 +39,9 @@ export default function Sidebar() {
const isAdmin = user?.role === 'admin';
return (
<div className="w-[60px] shrink-0 flex flex-col items-center py-3 gap-1 bg-slate-900">
<div className="w-[60px] h-full shrink-0 flex flex-col items-center py-3 gap-1 bg-slate-900"
style={{ paddingBottom: 'calc(0.75rem + env(safe-area-inset-bottom))' }}
>
{navItems
.filter(n => !n.adminOnly || isAdmin)
.map(({ short, title, mode, Icon }) => {

View File

@ -150,7 +150,7 @@ export default function ThreadView({ mailID, onClose }: { mailID: string; onClos
return (
<div className="flex-1 min-w-0 flex flex-col bg-gray-50">
<div className="px-6 py-3 border-b border-gray-200 bg-white flex items-center gap-2">
<div className="px-4 md:px-6 py-3 border-b border-gray-200 bg-white flex items-center gap-2">
<span className="text-sm font-semibold text-gray-900"></span>
<span className="text-xs text-gray-400">
{nodes.length}
@ -168,7 +168,7 @@ export default function ThreadView({ mailID, onClose }: { mailID: string; onClos
</button>
</div>
<div ref={scrollRef} className="flex-1 overflow-y-auto px-6 py-4">
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 md:px-6 py-4">
{initial && (
<div className="flex items-center gap-2 text-xs text-gray-400">
<SpinnerIcon className="w-3.5 h-3.5 animate-spin" />

View File

@ -0,0 +1,146 @@
import type { Contact } from '../types';
import {
ArchiveIcon,
ComposeIcon,
ChevronRightIcon,
GaugeIcon,
PersonIcon,
BotIcon
} from './icons';
/**
* 工作卡片:中间栏的另一种呈现。
*
* 与列表行ContactPanel 的 ContactRow的分工
* 列表答「跟谁在聊」,卡片答「在聊什么、进展如何」。
* 一条线索是一件正在进行的工作,卡片上要能直接看出:
* - 主题(多由 Agent 平台的模型生成的摘要)
* - 最新一封说了什么、谁说的
* - 往返预算还剩多少 —— 预算是任务的属性,快跑满的任务需要人介入
*
* 容器(列表/滚动/空态)由 ContactPanel 负责:两种视图共用同一份数据与同一套
* 打开/写信/归档动作,只有单项的渲染不同。竖向堆叠而非网格 ——
* 卡片在中间栏里320~400px 放不下多列。
*/
export function WorkCard({
contact: c,
active,
onOpen,
onCompose,
onArchive
}: {
contact: Contact;
active: boolean;
onOpen: () => void;
onCompose: () => void;
onArchive: () => void;
}) {
const time = new Date(c.last_activity).toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
const fromHuman = c.last_from !== c.agent_name;
return (
<div
className={`group flex flex-col rounded-lg border bg-white transition-colors ${
active ? 'border-blue-300 ring-1 ring-blue-100' : 'border-gray-200 hover:border-blue-300'
}`}
>
<button onClick={onOpen} className="flex-1 text-left p-3 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold text-gray-900 truncate">{c.agent_name}</span>
<span className="text-[10px] text-gray-400 font-mono truncate">{c.path}</span>
{c.unread_count > 0 && (
<span className="ml-auto shrink-0 min-w-[16px] h-4 px-1 rounded-full bg-blue-500 text-white text-[9px] font-bold flex items-center justify-center">
{c.unread_count}
</span>
)}
</div>
<div className="flex items-center gap-1 mt-0.5">
<ChevronRightIcon className="w-3 h-3 text-blue-400 shrink-0" />
<span className="text-[11px] text-blue-600 font-mono truncate">
{c.session_alias || '(未命名会话)'}
</span>
</div>
{/* 主题是这张卡片的主角:它回答「这条线索在干什么」 */}
<p className="text-xs text-gray-800 mt-1.5 line-clamp-2 leading-snug">
{c.subject || '(无主题)'}
</p>
{c.last_preview && (
<div className="flex items-start gap-1 mt-1.5">
{fromHuman ? (
<PersonIcon className="w-3 h-3 text-gray-400 shrink-0 mt-0.5" />
) : (
<BotIcon className="w-3 h-3 text-gray-400 shrink-0 mt-0.5" />
)}
<p className="text-[11px] text-gray-500 line-clamp-2 leading-snug">
{c.last_preview}
</p>
</div>
)}
<div className="flex items-center gap-2 mt-2">
<span className="text-[10px] text-gray-400">
{c.mail_count} · {time}
</span>
<div className="flex-1" />
<BudgetChip max={c.max_rounds} used={c.used_rounds} />
</div>
</button>
<div className="flex gap-1 px-3 pb-2.5 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity">
<button
onClick={onCompose}
title="写信给该地址"
className="inline-flex items-center gap-1 px-2 py-0.5 rounded border border-gray-300 text-[10px] text-gray-600 hover:bg-gray-50"
>
<ComposeIcon className="w-3 h-3" />
</button>
<button
onClick={onArchive}
title="归档该 name@path.session"
className="inline-flex items-center gap-1 px-2 py-0.5 rounded border border-gray-300 text-[10px] text-gray-600 hover:bg-gray-50 hover:text-red-600 hover:border-red-300"
>
<ArchiveIcon className="w-3 h-3" />
</button>
</div>
</div>
);
}
/**
* 预算指示条。
*
* 0 = 不限,此时不显示 —— 一个「0/0」或「不限」的徽标对每张卡片都成立
* 等于纯噪声。只在真正设了上限时才占位置。
* 剩 1 个来回时转红:那是需要人介入的时刻(要么加预算,要么让它收尾)。
*/
function BudgetChip({ max, used }: { max: number; used: number }) {
if (!max || max <= 0) return null;
const remaining = Math.max(max - used, 0);
const tone =
remaining === 0
? 'bg-red-100 text-red-700'
: remaining <= 1
? 'bg-orange-100 text-orange-700'
: 'bg-gray-100 text-gray-500';
return (
<span
className={`inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-[9px] font-medium shrink-0 ${tone}`}
title={`往返预算:已用 ${used}/${max}${remaining === 0 ? '(已用尽)' : ''}`}
>
<GaugeIcon className="w-2.5 h-2.5" />
{remaining}/{max}
</span>
);
}

View File

@ -287,3 +287,36 @@ export function GaugeIcon({ className = 'w-4 h-4' }: P) {
</Svg>
);
}
export function ChevronLeftIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<path d="m15 18-6-6 6-6" />
</Svg>
);
}
export function MenuIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<path d="M4 7h16M4 12h16M4 17h16" />
</Svg>
);
}
export function ListViewIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<path d="M8 6h12M8 12h12M8 18h12M4 6h.01M4 12h.01M4 18h.01" />
</Svg>
);
}
export function CardViewIcon({ className = 'w-4 h-4' }: P) {
return (
<Svg className={className}>
<rect x="3" y="4" width="18" height="7" rx="1.5" />
<rect x="3" y="14" width="18" height="6" rx="1.5" />
</Svg>
);
}

View File

@ -0,0 +1,29 @@
import { useEffect, useState } from 'react';
/**
* 窄屏断点。
*
* 三栏布局需要 60导航+ 320列表+ 约 400详情≈ 780px 才不挤,
* 因此以 768px 为界:以下只显示单栏。
*
* 用 matchMedia 而不是监听 resize后者每变化一像素都触发还得自己节流
* matchMedia 只在跨过阈值时回调一次。
*/
const NARROW_QUERY = '(max-width: 767px)';
export function useIsNarrow(): boolean {
const [narrow, setNarrow] = useState(() =>
typeof window === 'undefined' ? false : window.matchMedia(NARROW_QUERY).matches
);
useEffect(() => {
const mq = window.matchMedia(NARROW_QUERY);
const onChange = (e: MediaQueryListEvent) => setNarrow(e.matches);
mq.addEventListener('change', onChange);
// 挂载时同步一次:首次渲染到 effect 之间窗口可能已变化(例如手机旋屏)
setNarrow(mq.matches);
return () => mq.removeEventListener('change', onChange);
}, []);
return narrow;
}

View File

@ -2,6 +2,25 @@ import { create } from 'zustand';
import type { Contact } from '../types';
import * as api from '../api/client';
/** 中间栏的呈现方式:列表(紧凑,答"跟谁在聊")或卡片(答"在聊什么、进展如何" */
export type ContactView = 'list' | 'card';
const VIEW_KEY = 'agentmail.contactView';
/**
* 视图偏好存 localStorage。
*
* 这是纯展示偏好,不值得为它建一张表、加一个 API —— 而每次刷新都退回默认视图
* 会让人反复点同一个按钮。读取时容错localStorage 在隐私模式下可能抛异常。
*/
function loadView(): ContactView {
try {
return localStorage.getItem(VIEW_KEY) === 'card' ? 'card' : 'list';
} catch {
return 'list';
}
}
interface ContactState {
contacts: Contact[];
archivedContacts: Contact[];
@ -10,6 +29,9 @@ interface ContactState {
error: string | null;
/** 正在等待归档确认的地址 */
pendingArchive: string | null;
/** 中间栏呈现方式(持久化到 localStorage */
view: ContactView;
setView: (v: ContactView) => void;
fetchContacts: () => Promise<void>;
fetchArchived: () => Promise<void>;
@ -28,6 +50,16 @@ export const useContactStore = create<ContactState>((set, get) => ({
loading: false,
error: null,
pendingArchive: null,
view: loadView(),
setView: v => {
set({ view: v });
try {
localStorage.setItem(VIEW_KEY, v);
} catch {
/* 存不下只是下次回到默认视图,不该让切换本身失败 */
}
},
fetchContacts: async () => {
set({ loading: true, error: null });

View File

@ -12,18 +12,61 @@ interface UIState {
startCompose: (prefill?: { to?: string; cc?: string }) => void;
cancelCompose: () => void;
/**
* 窄屏下当前显示哪一栏。
*
* 宽屏是「列表 + 详情」并排,窄屏放不下,只能一次显示一栏:
* 选中邮件 → 切到 detail点返回 → 回 list。
*
* 这个状态在宽屏下**也维护**(只是不影响渲染):否则从窄屏拖宽再拖回来,
* 用户会发现自己回到了列表页,刚打开的邮件不见了。
*/
narrowPane: 'list' | 'detail';
showDetail: () => void;
showList: () => void;
/** 侧边导航在窄屏下是抽屉,宽屏下常驻 */
navOpen: boolean;
toggleNav: () => void;
closeNav: () => void;
/** 登出后重置回默认视图 */
reset: () => void;
}
export const useUIStore = create<UIState>(set => ({
viewMode: 'inbox',
setViewMode: mode => set({ viewMode: mode, composing: false, composePrefill: null }),
// 切换主视图时回到列表栏:窄屏下停在上一封邮件的详情页会让人不知道自己在哪
setViewMode: mode =>
set({
viewMode: mode,
composing: false,
composePrefill: null,
narrowPane: 'list',
navOpen: false
}),
composing: false,
composePrefill: null,
startCompose: prefill => set({ composing: true, composePrefill: prefill ?? null }),
cancelCompose: () => set({ composing: false, composePrefill: null }),
// 写信占满整个主区域,窄屏下等价于切到 detail 栏
startCompose: prefill =>
set({ composing: true, composePrefill: prefill ?? null, narrowPane: 'detail', navOpen: false }),
cancelCompose: () => set({ composing: false, composePrefill: null, narrowPane: 'list' }),
reset: () => set({ viewMode: 'inbox', composing: false, composePrefill: null })
narrowPane: 'list',
showDetail: () => set({ narrowPane: 'detail' }),
showList: () => set({ narrowPane: 'list' }),
navOpen: false,
toggleNav: () => set(s => ({ navOpen: !s.navOpen })),
closeNav: () => set({ navOpen: false }),
reset: () =>
set({
viewMode: 'inbox',
composing: false,
composePrefill: null,
narrowPane: 'list',
navOpen: false
})
}));

View File

@ -21,6 +21,8 @@ export interface Agent {
workspaces: Workspace[];
platform: string;
status: string;
/** 派给该 Agent 的新任务默认多少个来回0 = 不限) */
default_rounds?: number;
}
export interface Address {
@ -158,6 +160,14 @@ export interface Contact {
mail_count: number;
unread_count: number;
last_activity: string;
/** 会话主题(多由 Agent 平台的模型生成的摘要) */
subject: string;
/** 本任务的往返预算0 = 不限) */
max_rounds: number;
used_rounds: number;
/** 最后一封邮件的发件人与正文摘要(服务端已按字符截断) */
last_from: string;
last_preview: string;
}
export interface PermissionRequest {

View File

@ -0,0 +1,91 @@
// 窄屏布局的结构性回归测试。
//
// 不做视觉快照:那需要 headless 浏览器,且像素级比对在字体差异下极脆。
// 这里守住几条真正会坏掉的不变量。
import { readFileSync } from 'node:fs';
const read = p => readFileSync(new URL(p, import.meta.url), 'utf8');
let failed = 0;
const check = (name, cond, detail = '') => {
if (cond) {
console.log(` 通过 ${name}`);
} else {
console.error(` 失败 ${name}${detail ? ' — ' + detail : ''}`);
failed++;
}
};
console.log('窄屏布局回归:');
// 1) 覆盖式而非分栏NarrowStack 必须同时挂载 base 与 overlay
const stack = read('../src/components/NarrowStack.tsx');
check(
'覆盖层与底层同时在 DOM 里(底层不卸载,滚动位置与选中态才能保留)',
stack.includes('{base}') && stack.includes('{overlay}') && stack.includes('absolute inset-0')
);
check(
'关闭时延迟卸载,退出动画才有东西可播',
/setTimeout\(/.test(stack) && stack.includes('setMounted(false)')
);
check(
'入场用双层 rAF避免与挂载合帧导致 transition 不触发',
(stack.match(/requestAnimationFrame/g) || []).length >= 2
);
check(
'尊重 prefers-reduced-motion',
stack.includes('motion-reduce:transition-none')
);
// 2) 固定宽度的中间栏在窄屏必须让位。
// 断言的是「w-full + md: 前缀的固定宽度」这个形态,不是某个具体像素值 ——
// ContactPanel 的卡片视图用 400px列表视图用 320px。
for (const f of ['MailList', 'ContactPanel']) {
const src = read(`../src/components/${f}.tsx`);
const narrowFullWidth = src.includes('w-full');
// 固定宽度只能出现在 md: 断点后面;裸 w-[NNNpx] 会在 375px 屏上挤掉详情。
// 只看 >=200px 的min-w-[16px] 之类的徽标尺寸与布局无关
// (前置 (?<![-\w]) 排除 min-w- / max-w-,它们是约束不是宽度)。
const bareFixed = (src.match(/(?<![-\w])w-\[(\d+)px\]/g) || []).filter(m => {
const px = Number(m.match(/\d+/)[0]);
return px >= 200 && !src.includes('md:' + m);
});
check(
`${f} 中间栏窄屏全宽,固定宽度仅在 md: 之后`,
narrowFullWidth && bareFixed.length === 0,
bareFixed.length ? `裸固定宽度:${bareFixed.join(', ')}` : '缺少 w-full'
);
}
// 3) 详情页必须有返回出口,否则窄屏进去就出不来
const view = read('../src/components/MailView.tsx');
check('邮件详情有返回按钮', view.includes('<BackButton'));
const compose = read('../src/components/ComposePage.tsx');
check('写信页有返回出口', compose.includes('cancelCompose') && compose.includes('NarrowOnly'));
// 4) 窄屏专属控件不能只靠 CSS 隐藏 —— 那样宽屏 Tab 会聚焦到看不见的按钮。
// 注释里提到 md:hidden 是在解释「为什么不用它」,所以先剥掉注释再查。
const stripComments = src =>
src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
for (const f of ['BackButton', 'NavToggle', 'NarrowOnly']) {
const src = read(`../src/components/${f}.tsx`);
const code = stripComments(src);
check(
`${f} 用 useIsNarrow 条件渲染而非 md:hidden`,
src.includes('useIsNarrow') && /\bnull\b/.test(code) && !code.includes('md:hidden')
);
}
// 5) 底部导航要避开 iPhone 手势条
const nav = read('../src/components/NarrowNav.tsx');
check('底部导航留了安全区内边距', nav.includes('safe-area-inset-bottom'));
// 6) 横向内边距在窄屏收窄px-6 在 375px 屏上白吃 48px
const wide = ['MailView', 'ComposePage', 'ThreadView', 'AccountPage', 'AdminUsersPage'];
for (const f of wide) {
const src = read(`../src/components/${f}.tsx`);
const bare = src.match(/className="[^"]*(?<![-:])\bpx-6\b/g) || [];
check(`${f} 没有裸 px-6应为 px-4 md:px-6`, bare.length === 0, `发现 ${bare.length}`);
}
console.log(failed === 0 ? '\n窄屏布局全部通过' : `\n窄屏布局:${failed} 项失败`);
process.exit(failed === 0 ? 0 : 1);