diff --git a/console-ui/public/portal/index.html b/console-ui/public/portal/index.html
index cb163c3..83d64ba 100644
--- a/console-ui/public/portal/index.html
+++ b/console-ui/public/portal/index.html
@@ -1533,6 +1533,17 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
var removeBtn = l.auto
? ''
: '';
+ // t142:卡數+三元組數顯示(政府驗收用)。兩者皆 0 顯示「還沒有內容」,不顯示「0 張」。
+ var cardCount = typeof l.card_count === 'number' ? l.card_count : undefined;
+ var tripletCount = typeof l.triplet_count === 'number' ? l.triplet_count : undefined;
+ var statsHtml = '';
+ if (cardCount !== undefined || tripletCount !== undefined) {
+ var parts = [];
+ if (cardCount > 0) parts.push(cardCount + ' 張知識卡');
+ if (tripletCount > 0) parts.push(tripletCount + ' 條關聯');
+ statsHtml = '
' +
+ (parts.length ? parts.join('・') : '還沒有內容') + '
';
+ }
return '' +
'
' +
'' + esc(l.display_name || l.name) + '' +
@@ -1542,6 +1553,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
: '' + (disabled ? '已停用' : '啟用中') + '') +
removeBtn +
'
' +
+ statsHtml +
(!l.auto && l.description ? '
' + esc(l.description) + '
' : '') +
(notWatching ? '
小幫手目前沒有在同步這個資料夾
' : '') +
'
';
diff --git a/cypher-executor/src/routes/portal.ts b/cypher-executor/src/routes/portal.ts
index 3220318..6d75fd6 100644
--- a/cypher-executor/src/routes/portal.ts
+++ b/cypher-executor/src/routes/portal.ts
@@ -1081,11 +1081,35 @@ portalRouter.get('/portal/admin/libraries', (c) =>
return { ...lib, ...(watching !== undefined ? { daemon_watching: watching } : {}) };
});
const known = new Set(out.map((l) => l.name));
- // 資料面實際出現的庫(來自 ingest 蓋章的 metadata.library)
+ // t142:資料面實際出現的庫+統計數字(卡數、三元組數)並行撈取,避免 N+1。
+ // 任一端點失敗不擋登記簿列表(誠實降級:stats 保持 0,不炸主流程)。
try {
- const res = await kbdbFetch(c.env, `/entries/libraries?owner_id=${encodeURIComponent(portalTenant(c.env))}`);
- if (res.ok) {
- const body = (await res.json()) as { libraries?: string[] };
+ const tenant = portalTenant(c.env);
+ const ownerParam = `owner_id=${encodeURIComponent(tenant)}`;
+ const [autoRes, cardRes, tripletRes] = await Promise.all([
+ kbdbFetch(c.env, `/entries/libraries?${ownerParam}`).catch(() => null),
+ kbdbFetch(c.env, `/entries/library-stats?${ownerParam}`).catch(() => null),
+ kbdbFetch(c.env, `/records/triplet-stats?${ownerParam}`).catch(() => null),
+ ]);
+ // 解析統計,建成 Map 供 O(1) 查找
+ const cardMap = new Map();
+ if (cardRes?.ok) {
+ const body = (await cardRes.json()) as { stats?: { library: string; card_count: number }[] };
+ for (const s of body.stats ?? []) cardMap.set(s.library, s.card_count);
+ }
+ const tripletMap = new Map();
+ if (tripletRes?.ok) {
+ const body = (await tripletRes.json()) as { stats?: { library: string; triplet_count: number }[] };
+ for (const s of body.stats ?? []) tripletMap.set(s.library, s.triplet_count);
+ }
+ // 已登記庫補入統計
+ for (const lib of out) {
+ (lib as Record).card_count = cardMap.get(lib.name) ?? 0;
+ (lib as Record).triplet_count = tripletMap.get(lib.name) ?? 0;
+ }
+ // 資料面自動出現的庫(蓋章即現身)
+ if (autoRes?.ok) {
+ const body = (await autoRes.json()) as { libraries?: string[] };
for (const name of body.libraries ?? []) {
const n = String(name ?? '').trim();
// general 是系統內部「未標庫」桶(未標記 entry 的 fallback),不在用戶目錄露臉
@@ -1096,6 +1120,8 @@ portalRouter.get('/portal/admin/libraries', (c) =>
record_id: '', name: n, display_name: n,
description: '資料同步時自動出現(可在此補顯示名)',
status: 'active', graph_source: false, auto: true,
+ card_count: cardMap.get(n) ?? 0,
+ triplet_count: tripletMap.get(n) ?? 0,
...(watching !== undefined ? { daemon_watching: watching } : {}),
});
}
diff --git a/cypher-executor/tests/portal-admin.test.ts b/cypher-executor/tests/portal-admin.test.ts
index 55ffbc1..59eede4 100644
--- a/cypher-executor/tests/portal-admin.test.ts
+++ b/cypher-executor/tests/portal-admin.test.ts
@@ -381,10 +381,19 @@ describe('/portal/admin/libraries', () => {
await seedAdminSession();
mockGetRecord('rec_admin', adminValues());
mockListByTemplate('portal_library', []);
+ // t142:GET /portal/admin/libraries 現在並行呼叫三個 kbdb 端點,三個都要 mock
fetchMock
.get(KBDB)
.intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
.reply(200, { libraries: ['kb', 'general', 'notes'] });
+ fetchMock
+ .get(KBDB)
+ .intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' })
+ .reply(200, { success: true, stats: [] });
+ fetchMock
+ .get(KBDB)
+ .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
+ .reply(200, { success: true, stats: [] });
const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' });
expect(res.status).toBe(200);
const data = (await res.json()) as { libraries: { name: string; auto?: boolean }[] };
@@ -395,6 +404,90 @@ describe('/portal/admin/libraries', () => {
});
});
+// ═══════════════ t142 庫目錄卡數+三元組數 ═══════════════
+
+describe('GET /portal/admin/libraries + stats(t142)', () => {
+ it('kbdb 回傳統計 → 已登記庫帶 card_count + triplet_count', async () => {
+ await seedAdminSession();
+ mockGetRecord('rec_admin', adminValues());
+ mockListByTemplate('portal_library', [
+ { record_id: 'rec_lib_kb', values: { name: 'kb', display_name: '知識庫', status: 'active', graph_source: 'false' } },
+ ]);
+ fetchMock
+ .get(KBDB)
+ .intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
+ .reply(200, { libraries: ['kb'] });
+ fetchMock
+ .get(KBDB)
+ .intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' })
+ .reply(200, { success: true, stats: [{ library: 'kb', card_count: 42 }] });
+ fetchMock
+ .get(KBDB)
+ .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
+ .reply(200, { success: true, stats: [{ library: 'kb', triplet_count: 111 }] });
+ const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' });
+ expect(res.status).toBe(200);
+ const data = (await res.json()) as { libraries: { name: string; card_count?: number; triplet_count?: number }[] };
+ const kb = data.libraries.find((l) => l.name === 'kb');
+ expect(kb).toBeDefined();
+ expect(kb!.card_count).toBe(42);
+ expect(kb!.triplet_count).toBe(111);
+ });
+
+ it('auto 庫也帶 card_count + triplet_count', async () => {
+ await seedAdminSession();
+ mockGetRecord('rec_admin', adminValues());
+ mockListByTemplate('portal_library', []);
+ fetchMock
+ .get(KBDB)
+ .intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
+ .reply(200, { libraries: ['notes'] });
+ fetchMock
+ .get(KBDB)
+ .intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' })
+ .reply(200, { success: true, stats: [{ library: 'notes', card_count: 7 }] });
+ fetchMock
+ .get(KBDB)
+ .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
+ .reply(200, { success: true, stats: [{ library: 'notes', triplet_count: 108 }] });
+ const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' });
+ expect(res.status).toBe(200);
+ const data = (await res.json()) as { libraries: { name: string; card_count?: number; triplet_count?: number; auto?: boolean }[] };
+ const notes = data.libraries.find((l) => l.name === 'notes');
+ expect(notes).toBeDefined();
+ expect(notes!.auto).toBe(true);
+ expect(notes!.card_count).toBe(7);
+ expect(notes!.triplet_count).toBe(108);
+ });
+
+ it('庫無內容時 card_count=0 + triplet_count=0(前端顯示「還沒有內容」)', async () => {
+ await seedAdminSession();
+ mockGetRecord('rec_admin', adminValues());
+ mockListByTemplate('portal_library', [
+ { record_id: 'rec_lib_empty', values: { name: 'empty', display_name: '空庫', status: 'active', graph_source: 'false' } },
+ ]);
+ fetchMock
+ .get(KBDB)
+ .intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' })
+ .reply(200, { libraries: [] });
+ fetchMock
+ .get(KBDB)
+ .intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' })
+ .reply(200, { success: true, stats: [] });
+ fetchMock
+ .get(KBDB)
+ .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' })
+ .reply(200, { success: true, stats: [] });
+ const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' });
+ expect(res.status).toBe(200);
+ const data = (await res.json()) as { libraries: { name: string; card_count: number; triplet_count: number }[] };
+ const empty = data.libraries.find((l) => l.name === 'empty');
+ expect(empty).toBeDefined();
+ expect(empty!.card_count).toBe(0);
+ expect(empty!.triplet_count).toBe(0);
+ });
+});
+
// ═══════════════ t135 庫目錄移除 ═══════════════
describe('DELETE /portal/admin/libraries(t135)', () => {
diff --git a/kbdb/src/routes/entries.ts b/kbdb/src/routes/entries.ts
index 99954cb..130e539 100644
--- a/kbdb/src/routes/entries.ts
+++ b/kbdb/src/routes/entries.ts
@@ -51,6 +51,30 @@ entryRoutes.get('/libraries', async (c) => {
return c.json({ success: true, libraries, count: libraries.length });
});
+// GET /entries/library-stats?owner_id=... — 每個庫的知識卡數(distinct page_name,非 block 數)。
+// t142(2026-07-29):政府驗收用——一眼看出每個庫有幾張卡(page 粒度,不是 block 粒度,
+// 一張卡通常對應 3-5 個 block;不含 deprecated entries)。
+// 只計 entry_type='block' 的條目,因為 block 才對應知識卡的一個段落(page_name 標記所屬頁面)。
+entryRoutes.get('/library-stats', async (c) => {
+ const owner = c.req.query('owner_id') || '';
+ const rows = await c.env.DB.prepare(
+ `SELECT
+ COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') AS library,
+ COUNT(DISTINCT page_name) AS card_count
+ FROM entries
+ WHERE (?1 = '' OR owner_id = ?1)
+ AND entry_type = 'block'
+ AND page_name IS NOT NULL
+ AND COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'
+ GROUP BY library
+ ORDER BY library`,
+ )
+ .bind(owner)
+ .all<{ library: string; card_count: number }>();
+ const stats = (rows.results ?? []).map((r) => ({ library: r.library, card_count: r.card_count }));
+ return c.json({ success: true, stats });
+});
+
// GET /entries — list with filters (entry_type, owner_id, parent_id, page_name, source, q/search)
// e.g. list workflows under a project: ?parent_id=PROJECT&entry_type=workflow
// e.g. get one by idempotency key: ?page_name=skill-rag_with_arcrun
diff --git a/kbdb/src/routes/records.ts b/kbdb/src/routes/records.ts
index cc24de0..1758200 100644
--- a/kbdb/src/routes/records.ts
+++ b/kbdb/src/routes/records.ts
@@ -19,6 +19,38 @@ recordRoutes.post('/', async (c) => {
}
});
+// GET /records/triplet-stats?owner_id=... — 每個庫的三元組(關聯)數。
+// t142(2026-07-29):政府驗收——顯示每個庫整理出幾條知識關聯。
+// 計法:依 triplet 型 record 的 'library' slot 值分組計數。無 library slot 的舊三元組歸 general。
+// 使用子查詢先取 distinct triplet record IDs(針對 owner),再 LEFT JOIN library slot,
+// 避免 N+1(全部一次 SQL 完成,不逐筆 getRecord)。
+recordRoutes.get('/triplet-stats', async (c) => {
+ const owner = c.req.query('owner_id') || '';
+ // 子查詢:找到屬於這個 owner 的所有 triplet records;LEFT JOIN library slot 取庫名
+ const rows = await c.env.DB.prepare(
+ `SELECT
+ COALESCE(NULLIF(lib_e.content, ''), 'general') AS library,
+ COUNT(*) AS triplet_count
+ FROM (
+ SELECT DISTINCT ev.record_id
+ FROM entry_values ev
+ JOIN templates t ON ev.template_id = t.id
+ JOIN entries e ON ev.entry_id = e.id
+ WHERE t.name = 'triplet'
+ AND (?1 = '' OR e.owner_id = ?1)
+ ) AS tr
+ LEFT JOIN entry_values lev
+ ON lev.record_id = tr.record_id AND lev.slot_name = 'library'
+ LEFT JOIN entries lib_e ON lib_e.id = lev.entry_id
+ GROUP BY COALESCE(NULLIF(lib_e.content, ''), 'general')
+ ORDER BY library`,
+ )
+ .bind(owner)
+ .all<{ library: string; triplet_count: number }>();
+ const stats = (rows.results ?? []).map((r) => ({ library: r.library, triplet_count: r.triplet_count }));
+ return c.json({ success: true, stats });
+});
+
// GET /records/by-template/:template — list records of a template
recordRoutes.get('/by-template/:template', async (c) => {
const records = await searchByTemplate(c.env.DB, c.req.param('template'), c.req.query('owner_id') || undefined);
diff --git a/system-dev/docs/3-specs/portal-auth/tasks.md b/system-dev/docs/3-specs/portal-auth/tasks.md
index da48a2b..8a68536 100644
--- a/system-dev/docs/3-specs/portal-auth/tasks.md
+++ b/system-dev/docs/3-specs/portal-auth/tasks.md
@@ -305,6 +305,26 @@
KV key 設計:`{tenant}:portal:ai_config`(合併設定)`{tenant}:portal:daemon_caps`(能力回報,TTL 7 天)
測試:portal-admin.test.ts 新增 7 案(全通;全套 238 tests 229 passed,9 failed 皆 pre-existing)。
+- [x] **t142 庫目錄顯示同步張數+關聯數(2026-07-29,任務層小改)**:
+ 來源=leo 裁定(政府專案驗收)「雲端子庫顯示同步了幾個 wiki+幾個三元組,一眼看出這個庫真的有東西」。
+ 後端(kbdb):
+ ① `GET /entries/library-stats?owner_id=` → `{stats: [{library, card_count}]}`
+ SQL:`COUNT(DISTINCT page_name)` GROUP BY library(僅 entry_type='block',排 deprecated)。
+ ⚠️ 卡數=distinct page_name(一張卡 3-5 個 block),不是 COUNT(*)(防膨脹 3-5 倍)。
+ 路由在 `/libraries` 之後、`/` 之前(避免被 `/:id` 吃掉)。
+ ② `GET /records/triplet-stats?owner_id=` → `{stats: [{library, triplet_count}]}`
+ SQL:subquery DISTINCT triplet record IDs + LEFT JOIN library slot(無 slot→general),
+ 一次 SQL 完成,不 N+1。路由在 `/by-template/:template` 之前。
+ 後端(portal.ts):`GET /portal/admin/libraries` 改並行撈三端點(Promise.all + .catch(→null));
+ 已登記庫與 auto 庫各補 card_count + triplet_count(Map O(1) 查找),任一失敗不炸主流程。
+ 前端(index.html renderAdminLibs):每張庫卡片補一行
+ 「N 張知識卡・M 條關聯」(只顯示非零項);兩者均 0 → 顯示「還沒有內容」,不顯示「0 張」。
+ 測試:`kbdb/tests/library-stats.test.ts`(14 新案:SQL 形狀/COUNT DISTINCT/entry_type filter/
+ deprecated filter/COALESCE general fallback/參數傳入/回傳結構/空陣列不炸);
+ `portal-admin.test.ts`(t97 既有測試補三端點 mock + t142 describe 3 案:
+ 已登記庫帶 stats/auto 庫帶 stats/空庫 card_count=triplet_count=0)。
+ vitest + node --check 待 leo 驗收環境跑(本機無 Workers runtime)。
+
## 第二波(不在本 SDD 動工範圍,掛號)
- MCP token 綁庫集合(design §9;PR#15 擴充,只動 `mcp/`)