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:
2026-10-04 11:47:16 +08:00
parent dfd5661e24
commit 7b0207b621
10 changed files with 543 additions and 10 deletions

View 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');
});