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:
@ -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",
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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 ----------
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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" />
|
||||
|
||||
30
web/src/components/BackButton.tsx
Normal file
30
web/src/components/BackButton.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { useIsNarrow } from '../hooks/useIsNarrow';
|
||||
import { useUIStore } from '../stores/uiStore';
|
||||
import { ChevronLeftIcon } from './icons';
|
||||
|
||||
/**
|
||||
* 窄屏返回按钮。
|
||||
*
|
||||
* 只在窄屏出现:宽屏是列表与详情并排,没有「返回」这个概念 ——
|
||||
* 放一个按钮在那里,点了什么也不会发生。
|
||||
*
|
||||
* 覆盖式布局下返回 = 让覆盖层滑出去(narrowPane 回到 list),
|
||||
* 而不是卸载详情组件:底层列表一直挂载着,滚动位置与选中态都还在。
|
||||
*/
|
||||
export default function BackButton({ label = '返回' }: { label?: string }) {
|
||||
const narrow = useIsNarrow();
|
||||
const showList = useUIStore(s => s.showList);
|
||||
|
||||
if (!narrow) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={showList}
|
||||
className="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>
|
||||
);
|
||||
}
|
||||
@ -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" />
|
||||
|
||||
@ -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 ${
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
>
|
||||
清空
|
||||
|
||||
103
web/src/components/NarrowNav.tsx
Normal file
103
web/src/components/NarrowNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
web/src/components/NarrowOnly.tsx
Normal file
11
web/src/components/NarrowOnly.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { useIsNarrow } from '../hooks/useIsNarrow';
|
||||
|
||||
/**
|
||||
* 只在窄屏渲染子元素。
|
||||
*
|
||||
* 不用 Tailwind 的 `md:hidden`:那只是视觉隐藏,元素仍在 DOM 与 tab 序列里,
|
||||
* 宽屏用户按 Tab 会聚焦到一个看不见的返回按钮上。
|
||||
*/
|
||||
export default function NarrowOnly({ children }: { children: React.ReactNode }) {
|
||||
return useIsNarrow() ? <>{children}</> : null;
|
||||
}
|
||||
77
web/src/components/NarrowStack.tsx
Normal file
77
web/src/components/NarrowStack.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
web/src/components/NavToggle.tsx
Normal file
26
web/src/components/NavToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -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 }) => {
|
||||
|
||||
@ -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" />
|
||||
|
||||
146
web/src/components/WorkCard.tsx
Normal file
146
web/src/components/WorkCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
29
web/src/hooks/useIsNarrow.ts
Normal file
29
web/src/hooks/useIsNarrow.ts
Normal 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;
|
||||
}
|
||||
@ -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 });
|
||||
|
||||
@ -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
|
||||
})
|
||||
}));
|
||||
|
||||
@ -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 {
|
||||
|
||||
91
web/test/narrow-layout.test.mjs
Normal file
91
web/test/narrow-layout.test.mjs
Normal 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);
|
||||
Reference in New Issue
Block a user