Skip to content

Commit feef174

Browse files
committed
fix(panel): 实体页左侧栏改报全库实体数,不再写死 500
真机验证新面板时发现:状态页已显示全库 16,861,点进实体页左侧栏 仍是「500 个实体」。EntityPanel 拿 entities.length 当总数,而那份列表 是 /entities?limit=500 取回的最新 500 个。前一单只修了状态页那张卡, 这一处漏了,而它正是「实体太少」最直接的来源——用户点进实体页看到的 第一个数字。 后端早已回 total + totalExact(计数失败或老宿主会回落并把回落标记 出来),面板只在该标记为真时把它当全库数,否则退回切片长度。数字改 对还不够:列表本身仍只有 500 行,所以截断真发生时在计数下多一行说明, 两个口径分开讲,否则同一处错位会换个方向复发。 回归锁四条:总数取自 totalExact 守门的 total、读不到时退回切片长度、 截断时必须出现说明、文案中英各一条。三个变异体逐个变红。
1 parent 14a48d2 commit feef174

3 files changed

Lines changed: 83 additions & 4 deletions

File tree

‎dsh-mneme/CHANGELOG.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@
88
- **老宿主回落是真的条件回落,不是「两个都装」**:只用 `slots.inject` 判断不了——它对未声明的槽**不抛错也不立即回调**(内部 `specDynamic(key) === undefined` 时静默等待声明),所以新宿主上照样会跑、老宿主上则永远 pending。改法是把「问宿主有没有这套座位」收成一个 `hostHasPanelSeat()`(`slots.spec("sidebar.panellist") !== undefined`),再让 `mountFooterSeat()` 在装之前查一次;两条 `inject` 各自在回调里调它,**谁先声明谁赢,同一时刻至多一份在册**。回落还必须能被装回来:panellist 座位的 disposer 里显式 `mountFooterSeat()`,否则那台宿主在座位被撤(宿主折叠该槽 / 换形态)之后会停在「没有任何入口」的状态——这条路径只有它自己会触发,没有别的代码来救。
99
- **语义色层的根从四个变五个**:`.mneme-paneliglyph` 必须一起挂上 `--mneme-*`,否则那颗冲突红点吃不到 `--mneme-danger`。它长在宿主的 `span.panelGlyph` 里、不在 `.mneme-x` 子树内,靠继承拿不到;而 `test/contrast.test.js` 的四根精确锁正是为这一类「新加的渲染根悄悄绕过 AA 审计」立的,所以改的是选择器本身而不是新加一条规则。
1010
- **回归锁补的是「装/卸真的按条件跑」,不是「代码里有这两段字符串」**:用文件里真实的座位代码块起一个假 `ctx`(`spec` 可切换、`register` 记账、`inject` 只登记不自动点火——宿主本来也只在槽已声明时才点火)跑三个场景:新宿主上只该有 panellist + main 且 footer 不装;老宿主上只该有一份 footer;座位消失后 footer 必须自己装回来、且 main 要跟着卸。三个变异体逐个变红:删掉座位消失时的 `mountFooterSeat()` → 红在「回落要能装回来」;去掉装之前的 `hostHasPanelSeat()` 抑制 → 红在「新宿主不该有 footer」;卸载时只卸 glyph 不卸 main → 红在「main 要跟着卸」。另有一条纯结构锁:glyph 组件体内不许出现 `h("button"`、整个 bundle 不许出现 `ctx.layout.selectPanel`(真需要时才加,届时得同时把 layout 放进可选注入)。
11-
- **验证**:`npm test` 2715 tests / 2714 pass / 0 fail / 1 skipped;`check-sync` 85 文件一致(本改动只碰 `lib/client.js`,该文件无 `src/` 对应物、不进 sync)。**已知未覆盖**:本机被加载的那两份安装(`profiles/web` 的 symlink 指向主树、`profiles/desktop` 是 0.10.0 实体副本)都还没有这次改动,真机观感(铺满后的页头、窄容器下的布局、badge 位置)未经实际渲染验证——接手的人要先更新被加载的那棵树并重启宿主才看得到。
11+
- **顺手修掉「实体页左侧栏永远 500 个实体」**:真机验证时发现状态页已经显示 16,861,点进实体页左侧栏仍是「500 个实体」——`EntityPanel` 拿 `entities.length` 当总数,而那份列表是 `/entities?limit=500` 取回的最新 500 个。#559 只修了状态页那张卡,这一处当时漏了,而它恰恰是「实体太少了」这个观感最直接的来源(用户点进实体页看到的第一个数字)。改法照抄后端已有的口径:`total` + `totalExact` 两个字段都在,面板只在 `totalExact === true` 时把它当全库数,否则退回切片长度。
12+
13+
- **数字对了还不够,两个口径必须分开说**:总数向上取全库,但列表本身真的只有 500 行;只改大数字而不同时说明「列表只列最近 500 个」,同一处错位会换个方向复发(用户会问「说有一万六,怎么只列出 500 条」)。所以截断真发生时(`entityTotal > entities.length`)在计数下多一行 `.mneme-xcountnote`,文案走 i18n 双语、颜色走 `--mneme-fg-dim`。这条是条件渲染,不截断时不出现——平时不占地方。
14+
- **回归锁锁四件事**:总数取自 `totalExact` 守门的 `total`(不是切片长度)、读不到时退回切片长度、截断时必须出现那句说明、文案中英各一条。变异自证三个:把计数改回 `String(entities.length)` → 红;删掉采样说明那段 → 红;去掉 `totalExact === true` 守门(把回落值也当全库数)→ 红。
15+
16+
- **验证**:`npm test` 2716 tests / 2715 pass / 0 fail / 1 skipped;`check-sync` 85 文件一致(本改动只碰 `lib/client.js` 与 `test/client.test.js`,前者无 `src/` 对应物、不进 sync)。**真机验证已做**:另起一个隔离 profile(`panel-verify`,junction 指向本车道、`autoDream` 与 `externalApiEnabled` 都关,避免与在跑的宿主抢 8790 端口和重复做梦)跑起来看过——侧栏「全局面板」里第三项就是「记忆」,点开后中列整页铺满,页头四个 tab(记忆/实体/状态/设置)俱在,无浮层无背板无关闭按钮,记忆页与实体页的数据都正常加载。这张单子里的「实体页仍写死 500」就是这轮真机截图里发现的。
1217

1318
- **存量 quality_score 回填(#560 的收尾):一次性迁移撤回旧重复度度量留下的 repetitive 误判**:上一条只改了判据,管的只是**新写入**——`evaluateMemoryQuality` 全仓只有 `src/service.js` 写路径一处调用,更新路径不重算 `quality_score`,所以库里那批标签与扣分原封不动。本机 12,827 条里有 1,476 条带 `repetitive`,其中 729 条还是归档态。这条迁移在 `createStore` 打开库时跑,跑过就登记,不再跑第二次。
1419

‎dsh-mneme/lib/client.js‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,7 @@ window.__ModuleLoader__.load({
395395
"memory.explorer.tabSettings": "设置",
396396
"memory.explorer.tabStatus": "状态",
397397
"memory.entities.count": "{n} 个实体",
398+
"memory.entities.countSampled": "列表只列最近 {shown} 个;上方总数是全库",
398399
"memory.entities.pick": "从左侧选择一个实体,查看它的属性与关系",
399400
// 搜索框有字但一条没筛出来时用它;与 entities.none(库里真没实体)分开,
400401
// 否则「暂无实体」会把打错一个字误导成「你还没开抽取」。
@@ -1231,6 +1232,7 @@ window.__ModuleLoader__.load({
12311232
"memory.explorer.tabSettings": "Settings",
12321233
"memory.explorer.tabStatus": "Status",
12331234
"memory.entities.count": "{n} entities",
1235+
"memory.entities.countSampled": "The list shows the newest {shown} only; the total above is the whole library",
12341236
"memory.entities.pick": "Select an entity to see its attributes and relations",
12351237
"memory.entities.noMatch": "No entities match that search — try another word",
12361238
"memory.entities.none": "No entities yet — turn on entity extraction and people / projects / concepts will accumulate here",
@@ -2067,6 +2069,9 @@ window.__ModuleLoader__.load({
20672069
// 低于详情抽屉(6),下拉菜单/导入对话框随容器整体上浮。
20682070
".mneme-xtools{position:absolute;right:14px;top:50%;transform:translateY(-50%);display:flex;align-items:center;gap:8px;padding:0;z-index:3}",
20692071
".mneme-xcount{flex:none;font-size:12px;line-height:16px;color:var(--mneme-fg-dim);white-space:nowrap}",
2072+
// 目录计数下面那行小字:说清「总数是全库、列表只列最近 N 个」,两个口径
2073+
// 不一样不能只靠一个数字冒充(实体页此前就吃到这个亏)。
2074+
".mneme-xcountnote{font-size:11px;line-height:15px;color:var(--mneme-fg-dim);white-space:nowrap}",
20702075
// --- three-column browse layout: hairline separators, no outer box ---
20712076
".mneme-xmain{flex:1;min-height:0;display:flex;flex-direction:row;overflow:hidden}",
20722077
".mneme-xside{flex:none;width:264px;min-width:0;min-height:0;overflow-y:auto;overflow-x:hidden;padding:16px 14px;border-right:1px solid var(--dsw-alias-border-l2);box-sizing:border-box;display:flex;flex-direction:column;gap:8px}",
@@ -2901,6 +2906,12 @@ window.__ModuleLoader__.load({
29012906
// ④ 关系图谱 the same ego graph the old graph-only tab drew
29022907
function EntityPanel({ t, focusEntity, onJumpMemory }) {
29032908
const [entities, setEntities] = useState(null);
2909+
// 目录只取最近 500 个(limit=500),可「一共抽出多少实体」问的是全库。
2910+
// 后端回 total + totalExact(见 src/api.js 的 /entities),这里必须用全库数,
2911+
// 否则左侧栏永远显示「500 个实体」——那正是「实体太少」这个观感的另一半来源:
2912+
// 状态页已经在 #559 修正,实体页这一处当时漏了。null = 全库数读不到(老宿主
2913+
// 或计数失败),此时退回切片长度并如实标成切片口径,不冒充全库。
2914+
const [entityTotal, setEntityTotal] = useState(null);
29042915
const [railQuery, setRailQuery] = useState("");
29052916
const [entityName, setEntityName] = useState(focusEntity || "");
29062917
const [depth, setDepth] = useState(1);
@@ -2923,8 +2934,12 @@ window.__ModuleLoader__.load({
29232934
let cancelled = false;
29242935
apiFetch("/api/dsh-mneme/entities?limit=500")
29252936
.then((r) => (r.ok ? r.json() : { entities: [] }))
2926-
.then((j) => { if (!cancelled) setEntities(Array.isArray(j.entities) ? j.entities : []); })
2927-
.catch(() => { if (!cancelled) setEntities([]); });
2937+
.then((j) => {
2938+
if (cancelled) return;
2939+
setEntities(Array.isArray(j.entities) ? j.entities : []);
2940+
setEntityTotal(j.totalExact === true && typeof j.total === "number" ? j.total : null);
2941+
})
2942+
.catch(() => { if (!cancelled) { setEntities([]); setEntityTotal(null); } });
29282943
return () => { cancelled = true; };
29292944
}, []);
29302945

@@ -3176,8 +3191,13 @@ window.__ModuleLoader__.load({
31763191
value: railQuery,
31773192
onChange: (e) => setRailQuery(e.target.value)
31783193
}),
3194+
// 大数字用全库数;全库数读不到(老宿主 / 计数失败)时退回切片长度,
3195+
// 但那只是「这次取回多少」,所以要多一行脚注说清列表本身只列最近 500 个。
31793196
entities !== null && entities.length > 0 && h("div", { className: "mneme-xcount" },
3180-
t("memory.entities.count").replace("{n}", String(entities.length)))
3197+
t("memory.entities.count").replace("{n}", String(entityTotal !== null ? entityTotal : entities.length))),
3198+
entities !== null && entityTotal !== null && entityTotal > entities.length
3199+
&& h("div", { className: "mneme-xcountnote" },
3200+
t("memory.entities.countSampled").replace("{shown}", String(entities.length)))
31813201
),
31823202
h("div", { className: "mneme-entrailbody" },
31833203
entities === null && h("div", { className: "mneme-xempty" }, "…"),

‎dsh-mneme/test/client.test.js‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3259,3 +3259,57 @@ test("every client component declares the setters it calls (issue #553)", () =>
32593259
"a setter must be declared in the component that calls it — a state hook placed in the "
32603260
+ "neighbouring component compiles fine but throws ReferenceError at render time (#553)");
32613261
});
3262+
3263+
// --- 实体页计数口径(#562 真机验证发现) ---
3264+
// 实体页左侧栏此前固定显示「500 个实体」:它拿 `entities.length` 当总数,而那份列表
3265+
// 是 `/entities?limit=500` 取回的最新 500 个。库里一万六千个实体被截成一个看起来正常
3266+
// 的数字,用户据此判断「实体太少」——#559 只修了状态页那张卡,实体页这一处漏了。
3267+
// 本用例锁四件事,防的回归各不相同:
3268+
// ① 总数来源必须是后端回的 `total`+ `totalExact`,不是切片长度;
3269+
// ② 全库数读不到时(老宿主 / 计数失败)退回切片长度而不是显示空白;
3270+
// ③ 列表本身仍只有 500 条,必须在界面上说清「总数是全库、列表是最近 N 个」,
3271+
// 否则数字对了而列表还是 500 行,同一处观感错位只是换了个方向;
3272+
// ④ 那句说明走 i18n(中英各一条),不能硬编码中文。
3273+
test("the entity rail counts the whole library, not the newest slice", () => {
3274+
const start = clientSource.indexOf("function EntityPanel(");
3275+
assert.ok(start > 0, "EntityPanel must exist");
3276+
const nextFn = clientSource.indexOf("\n function ", start + 10);
3277+
assert.ok(nextFn > start, "EntityPanel must be followed by another module-level function");
3278+
const body = clientSource.slice(start, nextFn);
3279+
3280+
// ① 总数取自 totalExact 守门的 total。
3281+
assert.ok(
3282+
body.includes("setEntityTotal(j.totalExact === true && typeof j.total === \"number\" ? j.total : null)"),
3283+
"the rail total must come from the backend's total/totalExact, not the slice length"
3284+
);
3285+
3286+
// ② 计数行用全库数,读不到才退回切片长度。
3287+
assert.ok(
3288+
body.includes('t("memory.entities.count").replace("{n}", String(entityTotal !== null ? entityTotal : entities.length))'),
3289+
"the rail count must print the whole-library total with a slice-length fallback"
3290+
);
3291+
assert.ok(
3292+
!body.includes('t("memory.entities.count").replace("{n}", String(entities.length))'),
3293+
"printing entities.length directly is the \"always 500\" bug this test exists to prevent"
3294+
);
3295+
3296+
// ③ 列表只列最近 N 个这件事必须写出来(且只在真有截断时写)。
3297+
assert.ok(
3298+
/entityTotal !== null && entityTotal > entities\.length[\s\S]{0,200}memory\.entities\.countSampled/.test(body),
3299+
"a truncated list must say so instead of letting one number stand for both"
3300+
);
3301+
assert.ok(
3302+
body.includes('t("memory.entities.countSampled").replace("{shown}", String(entities.length))'),
3303+
"the notice must name how many entities the list actually shows"
3304+
);
3305+
3306+
// ④ 文案双语齐备,样式走语义色层。
3307+
for (const key of ["memory.entities.countSampled"]) {
3308+
const defined = clientSource.split(`"${key}":`).length - 1;
3309+
assert.equal(defined, 2, `i18n key ${key} must be defined exactly once in zh and once in en (got ${defined})`);
3310+
}
3311+
assert.ok(
3312+
clientSource.includes(".mneme-xcountnote{") && /color:var\(--mneme-fg-dim\)/.test(clientSource.slice(clientSource.indexOf(".mneme-xcountnote{"))),
3313+
"the notice needs its own dim style on the semantic colour layer"
3314+
);
3315+
});

0 commit comments

Comments
 (0)