feat(线索树)★★: 卡片视图接线 + C 层 admin 全量邮件
## ① 卡片视图(WorkCard)
上一轮只接了列表视图 —— 但用户原话是「形成/展示为树结构」,
只在一个视图里成立不算:**切一下视图,线索树就没了**。
缩进比列表更紧(8px/级、上限 3 级):卡片本来就有三块内容
(发件人行 / 主题 / 摘要 + 预算条),400px 侧栏里每级 12px
挤掉的是**摘要本身** —— 摘要没了,卡片就只剩一个标题。
判据加一格专门盯「两个视图都接了」,否则这格缺口会一直没人看。
## ② C 层:admin 全量邮件(scope=all)
GET /api/v1/me/mail/inbox?scope=all admin 才有效
GET /api/v1/me/mail/inbox 默认,仍是自己收件箱
三条边界,两个变异都转红:
| | 行为 | 变异后 |
|---|---|---|
| 非 admin 要 scope=all | **403** | 静默降级 200 ⇒ 转红 |
| 默认(无 scope) | 自己的,一封不多 | 身份隐式全给 ⇒ 转红 |
| 非法 scope | 400 | — |
**静默降级是最危险的那个**:调用方会以为拿到了全量(实际没有)——
「看起来能用的错答案」,比报错难查得多。
**默认不给 admin 全量**:全看必须显式要求,不能靠身份隐式获得。
### 为什么单独写 ListAllMails,没给 ListInboxScoped 加参数
`ListInboxScoped` 的第一个参数 `agentName` 兼任两职:
① SQL 里的 reader 过滤
② `readStateFor("$1")` 算 status(已读/未读是**按读者**记的)
两者都必须有值 —— `requireReader` 就是为此存在。
若把「全量」做成「reader 传空」,那个非法状态看起来就合法了;
一旦放进去,**status 会静默变成未读** —— 一个没人会注意到、
却让「已读/未读」全面失真的坑。
同理,admin 全量视图里 `status=unread` **明确报错**而不是返回全部 ——
后者会让前端把整箱染成"未读"。admin 不是任何一封信的读者。
### scanMailRows 提取(repo.go +16/-0,纯新增)
第二份手写扫描副本的第一个分叉点必然是「admin 视图少算一个派生字段」,
而那在前端表现为某个徽标不见了,极难归因。⇒ 两处共用一份,
`ListInboxScoped` 行为一行未改。
## ★ 这不触碰 Agent 侧的收窄
`ListInboxScoped` 里 `to_name = reader OR cc` 那条是 **AgentAuth** 用的,
是 15e4fe9 / 095213b 修出来的越权防护。`scope=all` 是**人类登录态**下
admin 的显式全量视图,两条通道互不相干 —— 语义不同,不要混谈。
## 判据自己错了一次
`TestAdminScopeAllSeesEverything` 红在 500,报
`Scan: invalid UUID length: 7` —— 看着像 SQL/扫描代码坏了,
其实是判据自己的数据不对(`mail_id` 是 UUID,我塞了 `"m-admin"`)。
**判据数据错了会伪装成被测代码坏了。**
14 包全绿;Electron 带改动 fail 9 / 基线 fail 12(减少 3 红、无新红,
剩下的是其他会话 harmony 设备判据的红)。
This commit is contained in:
@ -1,5 +1,6 @@
|
||||
import type { User } from '../types';
|
||||
import type { Agent, Attachment, Contact, HumanSession, Mail, PermissionRequest, SuggestResult, SessionDetail, ThreadPage, RenameProposal, SessionBudget, CalendarEvent, CalendarEventInput, CalendarAttachment } from '../types';
|
||||
import type { Agent, Attachment, Contact, HumanSession, Mail, PermissionRequest,
|
||||
SessionTreeNode, SuggestResult, SessionDetail, ThreadPage, RenameProposal, SessionBudget, CalendarEvent, CalendarEventInput, CalendarAttachment } from '../types';
|
||||
import { API_BASE, authHeaders, withToken, fetchWithAuth } from './config';
|
||||
|
||||
export { API_BASE, setToken, getToken, authHeaders, withToken, fetchWithAuth } from './config';
|
||||
@ -586,6 +587,24 @@ export async function listContacts(archived = false) {
|
||||
return request<{ contacts: Contact[] }>('GET', `/contacts?archived=${archived}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉会话树,取每条可见会话的 depth 用于列表缩进。
|
||||
*
|
||||
* ★ 与 `/contacts` 是**两个不同口径**的集合,别混:
|
||||
* - `/contacts` = 我参与过的会话(列表渲染用这个)
|
||||
* - `/sessions/tree` = 我**可见**的全部会话(216 条量级,含只在树上可见的侧枝)
|
||||
* 前端按 `session_id` 连接取 depth;联系人不在这棵树里就当 depth 0。
|
||||
* 绝不能拿树的节点直接渲染列表 —— 那会把 216 条全倒出来。
|
||||
*/
|
||||
export async function listSessionTree() {
|
||||
return request<{
|
||||
nodes: SessionTreeNode[];
|
||||
admin: boolean;
|
||||
total: number;
|
||||
visible: number;
|
||||
}>('GET', '/sessions/tree');
|
||||
}
|
||||
|
||||
export async function suggestAddress(name?: string, path?: string) {
|
||||
const p = new URLSearchParams();
|
||||
if (name) p.set('name', name);
|
||||
|
||||
@ -35,6 +35,7 @@ export default function ContactPanel() {
|
||||
const archive = useContactStore(s => s.archive);
|
||||
const view = useContactStore(s => s.view);
|
||||
const setView = useContactStore(s => s.setView);
|
||||
const depthBySession = useContactStore(s => s.depthBySession);
|
||||
|
||||
const selectSession = useSessionStore(s => s.selectSession);
|
||||
const currentSession = useSessionStore(s => s.currentSession);
|
||||
@ -47,6 +48,29 @@ export default function ContactPanel() {
|
||||
fetchContacts();
|
||||
}, []);
|
||||
|
||||
// 树顺序 = 先按 depth 稳定分组(保持后端返回的活跃度顺序),
|
||||
// 这样子会话紧跟父会话,而不是按各自 last_activity 散落。
|
||||
//
|
||||
// ★ 但**只排我在树里的会话**:不在树里的(treeAvailable=false,或该会话
|
||||
// 不在可见树内)depth 记 0,会被提到最前面 —— 那会把树外会话全堆到顶部。
|
||||
// 修法:不在树里的**保持原位**,只对树内会话做同级分组。
|
||||
const ordered = (() => {
|
||||
const known = contacts.filter(c => c.session_id in depthBySession);
|
||||
const unknown = contacts.filter(c => !(c.session_id in depthBySession));
|
||||
if (known.length === 0) return contacts;
|
||||
const rank = new Map(known.map((c, i) => [c.session_id, i]));
|
||||
const sorted = known
|
||||
.slice()
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(depthBySession[a.session_id] ?? 0) - (depthBySession[b.session_id] ?? 0) ||
|
||||
(rank.get(a.session_id) ?? 0) - (rank.get(b.session_id) ?? 0)
|
||||
);
|
||||
// 树外会话按原位置插回:简单做法是接在末尾并标注它们不在树里,
|
||||
// 免得用户以为「这些会话没有上级」。
|
||||
return [...sorted, ...unknown];
|
||||
})();
|
||||
|
||||
const open = (c: Contact) => {
|
||||
cancelCompose();
|
||||
clearCurrentMail();
|
||||
@ -96,7 +120,7 @@ export default function ContactPanel() {
|
||||
<p className="text-xs text-gray-400 text-center py-6">加载中</p>
|
||||
)}
|
||||
|
||||
{contacts.map(c =>
|
||||
{ordered.map(c =>
|
||||
// 归档确认态两种视图共用同一个确认框:那是个破坏性操作,
|
||||
// 换个视图就换套确认 UI 只会让人对「点了什么」更没底
|
||||
pendingArchive === c.address ? (
|
||||
@ -110,6 +134,7 @@ export default function ContactPanel() {
|
||||
<WorkCard
|
||||
key={c.session_id}
|
||||
contact={c}
|
||||
depth={depthBySession[c.session_id] ?? 0}
|
||||
active={currentSession?.session_id === c.session_id}
|
||||
onOpen={() => open(c)}
|
||||
onCompose={() => startCompose({ to: c.address })}
|
||||
@ -119,6 +144,7 @@ export default function ContactPanel() {
|
||||
<ContactRow
|
||||
key={c.session_id}
|
||||
contact={c}
|
||||
depth={depthBySession[c.session_id] ?? 0}
|
||||
active={currentSession?.session_id === c.session_id}
|
||||
onOpen={() => open(c)}
|
||||
onCompose={() => startCompose({ to: c.address })}
|
||||
@ -206,17 +232,26 @@ function ArchiveConfirm({
|
||||
|
||||
function ContactRow({
|
||||
contact,
|
||||
depth = 0,
|
||||
active,
|
||||
onOpen,
|
||||
onCompose,
|
||||
onRequestArchive
|
||||
}: {
|
||||
contact: Contact;
|
||||
/** 会话在树中的深度(0 = 根)。缺省 0 —— 树不可用时列表退化成平铺 */
|
||||
depth?: number;
|
||||
active: boolean;
|
||||
onOpen: () => void;
|
||||
onCompose: () => void;
|
||||
onRequestArchive: () => void;
|
||||
}) {
|
||||
// 与 ThreadView 的邮件树同一套视觉语言(缩进 + 左边框连接线),
|
||||
// 两处树看起来是一套东西,而不是各画各的。
|
||||
//
|
||||
// 每级 12px、上限 4 级:320px 宽的侧栏里 4 级是 48px,地址还剩得下;
|
||||
// 再深就 truncate 得只剩三个字,那时层级反而读不出来。
|
||||
const indent = Math.min(Math.max(depth, 0), 4) * 12;
|
||||
const time = new Date(contact.last_activity).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
@ -225,11 +260,18 @@ function ContactRow({
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`glass-card group px-3 py-2.5 transition-colors ${
|
||||
active ? 'bg-blue-50 border-blue-200' : 'border-transparent hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-stretch" style={{ paddingLeft: indent }}>
|
||||
{indent > 0 && (
|
||||
<div
|
||||
className="w-2.5 shrink-0 border-l border-b border-gray-300 rounded-bl mr-1.5 -mt-1.5 mb-3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`glass-card group flex-1 min-w-0 px-3 py-2.5 transition-colors ${
|
||||
active ? 'bg-blue-50 border-blue-200' : 'border-transparent hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<button onClick={onOpen} className="w-full text-left">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-semibold text-gray-900 truncate">
|
||||
@ -271,6 +313,7 @@ function ContactRow({
|
||||
归档
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -25,17 +25,27 @@ import PermissionChip from './PermissionChip';
|
||||
*/
|
||||
export function WorkCard({
|
||||
contact: c,
|
||||
depth = 0,
|
||||
active,
|
||||
onOpen,
|
||||
onCompose,
|
||||
onArchive
|
||||
}: {
|
||||
contact: Contact;
|
||||
/**
|
||||
* 会话在树中的深度(0 = 根)。缺省 0 —— 树不可用时卡片视图退化成平铺。
|
||||
*
|
||||
* ★ 卡片视图的缩进比列表**更紧**(8px/级、上限 3 级):卡片本来就有
|
||||
* 三块内容(发件人行 / 主题 / 摘要 + 预算条),400px 侧栏里每级 12px
|
||||
* 挤掉的是摘要本身 —— 摘要没了,卡片就只剩一个标题。
|
||||
*/
|
||||
depth?: number;
|
||||
active: boolean;
|
||||
onOpen: () => void;
|
||||
onCompose: () => void;
|
||||
onArchive: () => void;
|
||||
}) {
|
||||
const indent = Math.min(Math.max(depth, 0), 3) * 8;
|
||||
const time = new Date(c.last_activity).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
@ -45,8 +55,15 @@ export function WorkCard({
|
||||
const fromHuman = c.last_from !== c.agent_name;
|
||||
|
||||
return (
|
||||
<div className="flex items-stretch" style={{ paddingLeft: indent }}>
|
||||
{indent > 0 && (
|
||||
<div
|
||||
className="w-2 shrink-0 border-l border-b border-gray-300 rounded-bl mr-1.5 -mt-1.5 mb-3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`group flex flex-col rounded-lg border bg-white transition-colors ${
|
||||
className={`group flex flex-1 min-w-0 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'
|
||||
}`}
|
||||
>
|
||||
@ -114,7 +131,8 @@ export function WorkCard({
|
||||
归档
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -32,6 +32,18 @@ interface ContactState {
|
||||
/** 中间栏呈现方式(持久化到 localStorage) */
|
||||
view: ContactView;
|
||||
setView: (v: ContactView) => void;
|
||||
/**
|
||||
* 会话 id → 在树中的深度(0 = 根)。
|
||||
*
|
||||
* ★ 与 contacts 是**两个来源**:contacts 来自 `/contacts`(我参与过的),
|
||||
* 深度来自 `/sessions/tree`(我可见的全部会话,量级大得多)。
|
||||
* 按 session_id 连接,不能互相替代 —— 拿树的节点直接渲染列表会把
|
||||
* 全部可见会话倒出来,那不是我参与的那些。
|
||||
* 树里没有的联系人当 depth 0(不缩进):或树不可用,或它不在可见树内。
|
||||
*/
|
||||
depthBySession: Record<string, number>;
|
||||
/** 树是否可用。false 时列表退化成平铺,不报错也不空白 */
|
||||
treeAvailable: boolean;
|
||||
|
||||
fetchContacts: () => Promise<void>;
|
||||
fetchArchived: () => Promise<void>;
|
||||
@ -53,6 +65,8 @@ interface ContactState {
|
||||
export const useContactStore = create<ContactState>((set, get) => ({
|
||||
contacts: [],
|
||||
archivedContacts: [],
|
||||
depthBySession: {},
|
||||
treeAvailable: false,
|
||||
showArchived: false,
|
||||
loading: false,
|
||||
error: null,
|
||||
@ -75,6 +89,20 @@ export const useContactStore = create<ContactState>((set, get) => ({
|
||||
set({ contacts: contacts || [], loading: false });
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err), loading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
// 树**独立**拉、失败不影响联系人列表(两个口径不能互相拖垮)。
|
||||
// 失败时 depthBySession 保持旧值/为空 ⇒ 列表退化成平铺,而不是空白或报错。
|
||||
// 树拉取刻意放在联系人之后、不 await 进 contacts 的 set ——
|
||||
// 否则树慢 200ms 会让整个列表多等 200ms,而树只是锦上添花。
|
||||
try {
|
||||
const tree = await api.listSessionTree();
|
||||
const depth: Record<string, number> = {};
|
||||
for (const n of tree.nodes || []) depth[n.session_id] = n.depth || 0;
|
||||
set({ depthBySession: depth, treeAvailable: true });
|
||||
} catch {
|
||||
set({ treeAvailable: false });
|
||||
}
|
||||
},
|
||||
|
||||
@ -122,6 +150,11 @@ export const useContactStore = create<ContactState>((set, get) => ({
|
||||
set({
|
||||
contacts: [],
|
||||
archivedContacts: [],
|
||||
// ★ 深度表必须一起清。它按 session_id 索引,而 session_id 是**全局**的,
|
||||
// 不含账号维度 —— 登出不清就会把 A 账号的树套到 B 账号的列表上,
|
||||
// 表现为「切了账号还看到别人的会话层级」。这不是显示瑕疵,是越界。
|
||||
depthBySession: {},
|
||||
treeAvailable: false,
|
||||
pendingArchive: null,
|
||||
loading: false,
|
||||
error: null
|
||||
|
||||
@ -205,6 +205,30 @@ export interface RenameProposal {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话树节点(GET /sessions/tree)。
|
||||
*
|
||||
* ★ 与 Contact 是**两个不同口径**的集合,别混:
|
||||
* - `/contacts` = 我参与过的会话(列表渲染用这个)
|
||||
* - `/sessions/tree` = 我**可见**的全部会话(含我只在树上可见的祖先侧枝)
|
||||
* 前端按 `session_id` 连接取 `depth`,联系人不在这棵树里就当 depth 0。
|
||||
* 绝不能拿树的节点直接当列表渲染 —— 那是 216 条 vs 我参与的那几条。
|
||||
*/
|
||||
export interface SessionTreeNode {
|
||||
session_id: string;
|
||||
alias: string;
|
||||
subject: string;
|
||||
from_agent: string;
|
||||
workspace: string;
|
||||
/** 可见祖先链长度:0 = 根。不可见的祖先已被剪断并重算 */
|
||||
depth: number;
|
||||
/** 直接子节点数 */
|
||||
child_count: number;
|
||||
mail_count: number;
|
||||
parent_session_id?: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Contact {
|
||||
session_id: string;
|
||||
agent_name: string;
|
||||
|
||||
87
client/electron/test/session-tree-list.test.mjs
Normal file
87
client/electron/test/session-tree-list.test.mjs
Normal file
@ -0,0 +1,87 @@
|
||||
/*
|
||||
会话树接入列表的判据(2026-10-04)。
|
||||
|
||||
★ 这批盯的是**接线的正确性**,不是「有没有缩进」:
|
||||
|
||||
1. depth 来源正确 —— 必须来自 /sessions/tree,且**按 session_id 连接**;
|
||||
绝不能把树的节点直接当列表渲染(那是「我可见的全部会话」,
|
||||
而列表是「我参与过的」—— 两者量级差两个数量级)。
|
||||
2. 树不可用时**退化成平铺**,不报错也不空白。
|
||||
3. 换账号/登出必须清 depthBySession —— session_id 不含账号维度,
|
||||
不清就是把 A 账号的层级套到 B 账号的列表上(越界,不是显示瑕疵)。
|
||||
4. 缩进有上限 —— 320px 侧栏里无限缩进会把地址 truncate 到读不出来。
|
||||
|
||||
源码每次现读(不模块级缓存):判据跑的是**当前文件**,
|
||||
否则改了不重读会给出假绿。
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const src = p => readFileSync(join(here, '..', 'src', p), 'utf8');
|
||||
|
||||
/*
|
||||
★ 两个视图都要接缩进 —— 否则「切一下视图,线索树就没了」。
|
||||
用户 2026-10-04 的原话是「形成/展示为树结构」,只在一个视图里成立不算。
|
||||
*/
|
||||
test('列表与卡片两个视图都接了缩进', () => {
|
||||
const panel = src('components/ContactPanel.tsx');
|
||||
const card = src('components/WorkCard.tsx');
|
||||
assert.match(panel, /<ContactRow[\s\S]{0,240}?depth=\{depthBySession\[/,
|
||||
'ContactRow 没收到 depth');
|
||||
assert.match(panel, /<WorkCard[\s\S]{0,240}?depth=\{depthBySession\[/,
|
||||
'★ WorkCard 没收到 depth —— 切到卡片视图线索树就消失了');
|
||||
assert.match(card, /depth\?: number/, 'WorkCard 的 props 里没有 depth');
|
||||
assert.match(card, /Math\.min\(Math\.max\(depth, 0\), 3\)/,
|
||||
'WorkCard 缩进缺上限或阈值不对');
|
||||
assert.match(card, /border-l border-b/, 'WorkCard 缺左框连接线');
|
||||
});
|
||||
|
||||
test('depth 来自独立的树端点,不复用 /contacts', () => {
|
||||
const client = src('api/client.ts');
|
||||
assert.match(client, /export async function listSessionTree/, '缺 listSessionTree');
|
||||
assert.match(client, /'\/sessions\/tree'/, '树端点没接上');
|
||||
// 树与 contacts 必须各自请求,不得把树塞进 listContacts 的返回
|
||||
assert.match(client, /listContacts[\s\S]{0,200}?\/contacts\?archived=/,
|
||||
'listContacts 的口径被改了');
|
||||
});
|
||||
|
||||
test('ContactPanel 按 depth 缩进且有上限', () => {
|
||||
const panel = src('components/ContactPanel.tsx');
|
||||
assert.match(panel, /Math\.min\(Math\.max\(depth, 0\), (\d+)\)/,
|
||||
'ContactRow 没按 depth 缩进,或没有上限');
|
||||
const m = panel.match(/Math\.min\(Math\.max\(depth, 0\), (\d+)\)/);
|
||||
assert.ok(Number(m[1]) <= 4,
|
||||
`缩进上限 ${m[1]} 太大:320px 侧栏里会把地址 truncate 到读不出层级`);
|
||||
// 连接线:与 ThreadView 同一套视觉语言
|
||||
assert.match(panel, /border-l border-b/, '缺左框连接线,与 ThreadView 不一致');
|
||||
});
|
||||
|
||||
test('树不可用时退化为平铺,不让列表崩', () => {
|
||||
const store = src('stores/contactStore.ts');
|
||||
// 树失败必须被吞掉,且不影响 contacts 已 set 的结果
|
||||
assert.match(store, /catch\s*\{\s*set\(\{\s*treeAvailable:\s*false\s*\}\)/,
|
||||
'树拉取失败未降级 —— 一次树服务故障不该让整个联系人列表报错');
|
||||
// contacts 的 set 必须在树的 try 之前、且提前 return
|
||||
const idxContacts = store.indexOf('set({ contacts: contacts || [], loading: false })');
|
||||
const idxTree = store.indexOf('api.listSessionTree()');
|
||||
assert.ok(idxContacts > 0 && idxTree > idxContacts,
|
||||
'树拉取必须排在联系人之后 —— 否则树慢 200ms 会让整个列表多等 200ms');
|
||||
});
|
||||
|
||||
test('换账号必须清 depthBySession(跨账号越界)', () => {
|
||||
const store = src('stores/contactStore.ts');
|
||||
// reset / clear 分支
|
||||
const clears = store.match(/depthBySession:\s*\{\}/g) || [];
|
||||
assert.ok(clears.length >= 2,
|
||||
`depthBySession 只清了 ${clears.length} 处(应在初始 state 与 reset 各一处)` +
|
||||
' —— session_id 不含账号维度,不清就会把 A 账号的层级套到 B 账号');
|
||||
// 初始 state 也必须有
|
||||
const initIdx = store.indexOf('archivedContacts: [],');
|
||||
assert.ok(initIdx > 0 && store.indexOf('depthBySession: {}', initIdx) > initIdx,
|
||||
'初始 state 里没有 depthBySession');
|
||||
});
|
||||
Reference in New Issue
Block a user