Merge pull request 'feat(console): 收件夾改裝=全部待辦分流台(三欄 80/15/5 + per-project 過濾)(#9)' (#41) from feat/console-triage-inbox into main

This commit was merged in pull request #41.
This commit is contained in:
Leo
2026-07-08 15:13:58 +00:00
5 changed files with 377 additions and 64 deletions
@@ -0,0 +1,132 @@
/**
* console「分流台」純函式層(Arcrun#9 收件夾改裝=全部待辦分流台,2026-07-08 總管派工;
* 頂層 SDD kb-ingest-architecture R7 消費端)
*
* 為什麼獨立成 lib(比照 console-dashboard-model.ts):route 只做 IOfetch KBDB entries),
* 「兩種 entry 契約 → 統一分流項」的解析/分欄/分項判定全在這裡,vitest 直接餵資料驗證。
*
* 資料來源二合一(R7):
* - entry_type=todoLogseq 萃取灌入(Arcrun#8 ingest 線),content JSON 契約(已定)=
* {"text","marker","owner_tier":"ai|collab|leo","project","source","status":"new|done"}
* - entry_type=inboxTelegram 指令(inbox_receive),沿用 {"text","from","status"}
*
* 三欄分流(80/15/5,北極星「減 leo 負」):
* ai = AI 會幫我搞定(80%
* collab = AI 要我做完它接手(15%
* leo = 非 leo 不可(5%
* 無 owner_tier / 看不懂的值(triage engine 還沒判到)→ 歸 collab 欄但標 unclassified
* UI 顯「未分流」徽章——不冒領「AI 會搞定」、也不誤判成「非 leo 不可」,且欄位維持三欄
* 不另開第四欄(80/15/5 的故事不破)。issue 原文的 mira80/collab15/leo5 詞彙當同義吃進來
* ingest 端若照 issue 舊詞灌,UI 不至於整欄蒸發)。
*/
import { type KbdbEntry, parseCreatedAtMs, parseJsonContent } from './console-dashboard-model';
export type TriageTier = 'ai' | 'collab' | 'leo';
/** 契約值 → 三欄。issue 原文舊詞(mira80/collab15/leo5)當同義收;其餘=未分流。 */
const TIER_ALIASES: Record<string, TriageTier> = {
ai: 'ai',
collab: 'collab',
leo: 'leo',
mira80: 'ai',
collab15: 'collab',
leo5: 'leo',
};
export interface TriageItem {
id: string;
text: string;
tier: TriageTier;
/** trueowner_tier 缺席/看不懂(triage engine 未判),暫歸 collab 欄+UI 標「未分流」 */
unclassified: boolean;
/** '' = 未分項(UI 過濾 chips 用「未分項」承接) */
project: string;
/** todo 來源=content.source(如 notes);inbox 一律 telegram(可帶 from */
source: string;
/** Logseq markerTODO/DOING/LATER/NOW…);inbox 無='' */
marker: string;
status: 'new' | 'done';
origin: 'todo' | 'inbox';
at: string | number;
at_ms: number | null;
}
export interface TriageModel {
/** 全部項(created_at 新→舊);分欄/過濾在 UI 端做(資料一次給齊,chips 切換零請求) */
items: TriageItem[];
/** 出現過的 project(去重、字典序;不含 ''——「未分項」由 UI 依 items 判斷要不要出 chip */
projects: string[];
/** 各欄未完成數(status=new)+整體計數,供欄位標頭/收件計數用 */
counts: { ai: number; collab: number; leo: number; unclassified: number; done: number; total: number };
}
function normalizeStatus(v: unknown): 'new' | 'done' {
return v === 'done' ? 'done' : 'new';
}
function normalizeTier(v: unknown): { tier: TriageTier; unclassified: boolean } {
const mapped = typeof v === 'string' ? TIER_ALIASES[v.trim()] : undefined;
return mapped ? { tier: mapped, unclassified: false } : { tier: 'collab', unclassified: true };
}
function str(v: unknown): string {
return typeof v === 'string' ? v.trim() : '';
}
/** todo entry → 分流項。content 非 JSON(不合契約的殘資料)→ 原文當 text 誠實顯示,不丟棄。 */
export function todoToTriageItem(e: KbdbEntry): TriageItem {
const j = parseJsonContent(e);
const { tier, unclassified } = normalizeTier(j?.owner_tier);
return {
id: e.id,
text: str(j?.text) || (e.content ?? ''),
tier,
unclassified,
project: str(j?.project),
source: str(j?.source),
marker: str(j?.marker),
status: normalizeStatus(j?.status),
origin: 'todo',
at: e.created_at,
at_ms: parseCreatedAtMs(e.created_at),
};
}
/** inbox entry → 分流項(沿用 {"text","from","status"} 契約)。無 tier =未分流歸 collab。 */
export function inboxToTriageItem(e: KbdbEntry): TriageItem {
const j = parseJsonContent(e);
const from = str(j?.from);
return {
id: e.id,
text: str(j?.text) || (e.content ?? ''),
tier: 'collab',
unclassified: true,
project: '',
source: from ? `telegram・${from}` : 'telegram',
marker: '',
status: normalizeStatus(j?.status),
origin: 'inbox',
at: e.created_at,
at_ms: parseCreatedAtMs(e.created_at),
};
}
/** 二源合一 → 分流台模型。排序=created_at 新→舊(時間不明沉底);分欄計數只算未完成。 */
export function buildTriageModel(todoEntries: KbdbEntry[], inboxEntries: KbdbEntry[]): TriageModel {
const items = [
...todoEntries.map(todoToTriageItem),
...inboxEntries.map(inboxToTriageItem),
].sort((a, b) => (b.at_ms ?? -1) - (a.at_ms ?? -1));
const projects = [...new Set(items.map((i) => i.project).filter(Boolean))].sort();
const counts = { ai: 0, collab: 0, leo: 0, unclassified: 0, done: 0, total: items.length };
for (const it of items) {
if (it.status === 'done') {
counts.done++;
continue;
}
counts[it.tier]++;
if (it.unclassified) counts.unclassified++;
}
return { items, projects, counts };
}
+1 -1
View File
@@ -30,7 +30,7 @@ const SESSION_PREFIX = 'console_sess:';
const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 天
/**
* 驗證 console session(給其他 route 共用,如 /console/inbox-data)。
* 驗證 console session(給其他 route 共用,如 /console/triage-data)。
* authHeader 形如 "Bearer <token>";有效回 true。
*/
export async function validateConsoleSession(env: Bindings, authHeader: string | undefined): Promise<boolean> {
+13 -20
View File
@@ -68,6 +68,7 @@ import {
sortWaitingItems,
taipeiDayKey,
} from '../lib/console-dashboard-model';
import { buildTriageModel } from '../lib/console-triage-model';
import { TAIPEI_CLIENT_JS } from '../lib/taipei-time';
export const consoleDashboardRouter = new Hono<{ Bindings: Bindings }>();
@@ -454,30 +455,22 @@ consoleDashboardRouter.get('/console/settings-data', (c) => {
});
});
// GET /console/inbox-data — 收件匣清單Mira Console 頁 7)。**需 console session**Bearer):
// dashboard-data 只吐計數可免登入;這裡吐訊息原文(leo 丟給 Telegram bot 的指令內容)屬機敏,鎖登入
// 資料源同 dashboard-data 的 inbox entriesentry_type=inboxcontent JSON {"text","status","result"?}
consoleDashboardRouter.get('/console/inbox-data', async (c) => {
// GET /console/triage-data — 分流台資料Mira Console 頁 7Arcrun#9 收件夾改裝;原
// /console/inbox-data 的後繼——唯一消費者是 console 頁本身,一起改裝,不留死端點)
// **需 console session**Bearer):dashboard-data 只吐計數可免登入;這裡吐待辦/訊息原文屬機敏,鎖登入
// 資料源二合一(kb-ingest SDD R7):entry_type=todoLogseq 萃取,Arcrun#8 ingest 線)+
// entry_type=inboxTelegram)。契約解析/三欄分流/計數=純函式 lib/console-triage-model.ts。
consoleDashboardRouter.get('/console/triage-data', async (c) => {
const ok = await validateConsoleSession(c.env, c.req.header('authorization'));
if (!ok) return c.json({ error: '需要登入(console session' }, 401);
const tenant = c.env.CONSOLE_TENANT || 'leo';
const entries = await fetchEntries(c.env, tenant, 'inbox', 200);
const items = entries.map((e) => {
const j = parseJsonContent(e);
return {
id: e.id,
text: typeof j?.text === 'string' ? (j.text as string) : (e.content ?? ''),
status: j && j.status === 'done' ? 'done' : 'new',
result: typeof j?.result === 'string' ? (j.result as string) : '',
at: e.created_at,
};
});
return c.json({
items,
new_count: items.filter((i) => i.status !== 'done').length,
total: items.length,
});
const [todoEntries, inboxEntries] = await Promise.all([
fetchEntries(c.env, tenant, 'todo', 500),
fetchEntries(c.env, tenant, 'inbox', 200),
]);
const model = buildTriageModel(todoEntries, inboxEntries);
return c.json({ ...model, generated_at: new Date().toISOString() });
});
function renderDashboardHtml(): string {
+97 -43
View File
@@ -7,7 +7,8 @@
* 手機優先 390px + ≥1024px 側欄、頁面本體不出水平捲軸。
*
* 資訊架構:brief 8 頁(docs/1-vision/mira-console-design-brief.md)=
* 1 登入/首次設定 2 駕駛艙 3 總庫搜尋 4 卡片詳頁+關聯 5 工作流 6 憑證管理 7 收件匣 8 設定。
* 1 登入/首次設定 2 駕駛艙 3 總庫搜尋 4 卡片詳頁+關聯 5 工作流 6 憑證管理
* 7 分流台(原收件匣,Arcrun#9 改裝) 8 設定。
* 單檔 HTML + 原生 JS hash routing#/cockpit …),零外部資源、零 build step。
*
* 薄殼(rule 07):零業務邏輯,只 fetch 既有 API 渲染。各頁資料源(誠實原則,無源的顯示
@@ -23,7 +24,10 @@
* + GET /workflows/:name/executions(最近執行);釘選存 localStorage
* - 憑證:GET /credentials/catalogD1 目錄唯讀,絕不含密文)+ POST /credentials(新增)
* + PUT /credentials/:name(整筆替換);刪除後端未接 D1(T9 範圍)→ 標「即將開通」
* - 收件匣GET /console/inbox-data(需 session——訊息原文屬機敏,計數才免登入)
* - 分流台GET /console/triage-data(需 session——待辦/訊息原文屬機敏,計數才免登入)
* 資料源二合一(kb-ingest SDD R7 / Arcrun#9):entry_type=todoLogseq 萃取)+
* entry_type=inboxTelegram);三欄 80/15/5aicollableo)+ per-project chips
* +已完成預設隱藏可切換。分欄判定在 lib/console-triage-model.ts(純函式),頁面只渲染。
* - 設定:vectorize 狀態探測(讀 /kbdb/search 回應的 mode/capability_hint,無新端點;
* 開關本體需部署端 config kbdb_embed + acr update → 誠實文案講清楚、不假裝能遠端開)
* + MCP token TTL 誠實佔位(/console/settings-data;可調功能=Arcrun#19
@@ -231,7 +235,7 @@ function renderConsoleHtml(registryBase: string): string {
.gcenter span { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 13.5px; font-weight: 600; line-height: 1.35; color: #241804; word-break: break-word; }
.nbrow { display: flex; align-items: center; gap: 11px; padding: 12px 14px; border-radius: 10px; cursor: pointer; background: rgba(var(--ink-rgb),.045); border: 1px solid rgba(var(--ink-rgb),.1); font-size: 15.5px; }
/* ── 工作流 / 憑證 / 收件匣 清單 ── */
/* ── 工作流 / 憑證 / 分流台 清單 ── */
.listcol { display: flex; flex-direction: column; gap: 10px; padding: 16px 0 8px; }
.rowhead { display: flex; align-items: center; gap: 12px; padding: 16px 18px; cursor: pointer; }
.rowbody { padding: 0 18px 18px; border-top: 1px solid rgba(var(--ink-rgb),.1); }
@@ -241,10 +245,23 @@ function renderConsoleHtml(registryBase: string): string {
.urlbox .u { font-family: ui-monospace, Menlo, monospace; font-size: 13.5px; color: var(--amber); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.avatar { width: 38px; height: 38px; border-radius: 10px; background: rgba(var(--amber-rgb),.1); border: 1px solid rgba(var(--amber-rgb),.25); display: grid; place-items: center; font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 17px; color: var(--amber); flex: none; }
.maskline { display: flex; align-items: center; gap: 10px; padding: 14px; border-radius: 10px; background: var(--well); border: 1px solid rgba(var(--ink-rgb),.1); }
.inbox-item { padding: 16px 18px; border-radius: 12px; }
.inbox-item.new { background: rgba(var(--amber-rgb),.07); border: 1px solid rgba(var(--amber-rgb),.28); }
.inbox-item.done { background: rgba(var(--ink-rgb),.04); border: 1px solid rgba(var(--ink-rgb),.1); }
.pulse-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--amber); flex: none; animation: breatheAmber 2.6s ease-in-out infinite; }
/* 分流台(Arcrun#9):三欄 80/15/5 手機直排;欄色點 ai=綠 collab=琥珀 leo=紅 */
.tri-sec { margin-top: 24px; }
.tri-sechead { display: flex; align-items: baseline; gap: 9px; }
.tri-sechead .dot { flex: none; width: 9px; height: 9px; border-radius: 50%; align-self: center; }
.tri-sec.ai .dot { background: var(--ok); } .tri-sec.collab .dot { background: var(--amber); } .tri-sec.leo .dot { background: var(--err); }
.tri-sechead .nm { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 17px; letter-spacing: .14em; }
.tri-sechead .pc { font-size: 12.5px; color: rgba(var(--ink-rgb),.4); }
.tri-sechead .ct { margin-left: auto; font-family: ui-monospace, Menlo, monospace; font-size: 14px; color: rgba(var(--ink-rgb),.55); }
.tri-item { padding: 14px 16px; border-radius: 12px; margin-top: 9px; }
.tri-item.new { background: rgba(var(--amber-rgb),.07); border: 1px solid rgba(var(--amber-rgb),.28); }
.tri-sec.leo .tri-item.new { background: rgba(var(--err-rgb),.06); border-color: rgba(var(--err-rgb),.3); }
.tri-item.done { background: rgba(var(--ink-rgb),.04); border: 1px solid rgba(var(--ink-rgb),.1); opacity: .7; }
.tri-item.done .tx { text-decoration: line-through; color: rgba(var(--ink-rgb),.6); }
.tri-item .tx { font-size: 16.5px; line-height: 1.6; word-break: break-word; }
.tri-item .mt { margin-top: 9px; display: flex; gap: 7px; align-items: center; flex-wrap: wrap; font-size: 12.5px; color: rgba(var(--ink-rgb),.45); }
.tag.red { background: rgba(var(--err-rgb),.12); color: var(--err); }
.tag.dim { background: rgba(var(--ink-rgb),.08); color: rgba(var(--ink-rgb),.55); }
/* ── 設定 ── */
.setcol { display: flex; flex-direction: column; gap: 14px; padding: 20px 0 40px; }
@@ -303,7 +320,7 @@ function renderConsoleHtml(registryBase: string): string {
<div class="nav" data-nav="search">總庫搜尋</div>
<div class="nav" data-nav="workflows">工作流</div>
<div class="nav" data-nav="creds">憑證管理</div>
<div class="nav" data-nav="inbox">收件匣</div>
<div class="nav" data-nav="inbox">分流台</div>
<div class="nav" data-nav="settings">設定</div>
<div class="nav themelabel" data-themetoggle style="margin-top:8px;font-size:14.5px">☾ 切深色</div>
<div class="foot" id="side-foot"></div>
@@ -417,10 +434,11 @@ function renderConsoleHtml(registryBase: string): string {
<div class="listcol" id="cred-list"><div class="muted">載入中…</div></div>
</div>
<!-- 收件匣 -->
<!-- 分流台(原收件匣,Arcrun#9 改裝=全部待辦分流台) -->
<div class="view page narrow" id="v-inbox">
<div class="pagehead"><span class="t">收件匣</span><span class="m">Telegram 指令</span></div>
<div id="ib-out" style="padding-top:6px"><div class="muted" style="padding:14px 0">載入中…</div></div>
<div class="pagehead"><span class="t">分流台</span><span class="m">全部待辦・80/15/5</span></div>
<div class="hscroll" id="tg-chips" style="margin-top:14px"></div>
<div id="tg-out" style="padding-top:2px"><div class="muted" style="padding:14px 0">載入中…</div></div>
</div>
<!-- 設定 -->
@@ -489,7 +507,7 @@ function renderConsoleHtml(registryBase: string): string {
<div class="tab" data-nav="cockpit">駕駛艙</div>
<div class="tab" data-nav="search">搜尋</div>
<div class="tab" data-nav="workflows">工作流</div>
<div class="tab" data-nav="inbox">收件匣</div>
<div class="tab" data-nav="inbox">分流台</div>
<div class="tab" data-nav="settings">設定</div>
</div>
@@ -631,7 +649,7 @@ function renderConsoleHtml(registryBase: string): string {
});
window.scrollTo(0, 0);
}
var LOADERS = { cockpit: loadCockpit, search: initSearchOnce, card: loadCard, workflows: loadWorkflows, creds: loadCreds, inbox: loadInbox, settings: loadSettings };
var LOADERS = { cockpit: loadCockpit, search: initSearchOnce, card: loadCard, workflows: loadWorkflows, creds: loadCreds, inbox: loadTriage, settings: loadSettings };
function route() {
var r = currentRoute();
if (r.view === 'card' && r.arg) S.cardId = decodeURIComponent(r.arg);
@@ -1239,42 +1257,78 @@ function renderConsoleHtml(registryBase: string): string {
}
});
// ── 收件匣 ──
function loadInbox() {
var out = $('ib-out');
// ── 分流台(原收件匣,Arcrun#9todoinbox 二源、三欄 80/15/5、per-project chips──
// 頁面只渲染 /console/triage-data 的結果(分欄/計數判定在後端純函式);chips 切換與
// 「顯示已完成」是同一包資料的前端過濾,零額外請求(leo 手機看,省來回)。
var TG = { data: null, project: '', showDone: false };
var TG_TIERS = [
{ key: 'ai', nm: 'AI 會幫我搞定', pc: '80%', empty: '這欄空的——還沒有分給 AI 的待辦' },
{ key: 'collab', nm: '協作・AI 做完你接手', pc: '15%', empty: '這欄空的——沒有要你配合的事' },
{ key: 'leo', nm: '非我不可', pc: '5%', empty: '這欄空的——沒有非你不可的事' }
];
function triItem(m) {
var meta = '';
if (m.marker) meta += '<span class="tag dim">' + esc(m.marker) + '</span>';
meta += '<span class="tag">' + esc(m.project || '未分項') + '</span>';
if (m.source) meta += '<span>' + esc(m.source) + '</span>';
if (m.unclassified && m.origin === 'todo') meta += '<span class="tag red">未分流</span>';
meta += '<span class="mono" style="margin-left:auto">' + esc(fmtDateTime(m.at)) + '</span>';
return '<div class="tri-item ' + (m.status === 'done' ? 'done' : 'new') + '"><div class="tx">' + esc(m.text) + '</div><div class="mt">' + meta + '</div></div>';
}
function triChip(label, val, on) {
return '<button class="chip' + (on ? ' on' : '') + '" data-triproj="' + esc(val) + '">' + esc(label) + '</button>';
}
function renderTriage() {
var d = TG.data || {};
var items = d.items || [];
var out = $('tg-out');
// per-project chips:全部+各 project+(有未分項才出)未分項
var chips = triChip('全部', '', TG.project === '');
(d.projects || []).forEach(function (p) { chips += triChip(p, p, TG.project === p); });
if (items.some(function (i) { return !i.project; })) chips += triChip('未分項', '__none__', TG.project === '__none__');
$('tg-chips').innerHTML = items.length ? chips : '';
if (!items.length) {
out.innerHTML = '<div class="honest" style="margin-top:14px"><div class="h">分流台是空的</div><div class="b">資料源=KBDB todo entriesLogseq 萃取,ingest 灌入中)+ inbox entriesTelegram)。<br>寫入方尚未灌資料時這裡誠實顯示空。</div></div>';
return;
}
var vis = items.filter(function (i) {
return TG.project === '' || (TG.project === '__none__' ? !i.project : i.project === TG.project);
});
var doneN = vis.filter(function (i) { return i.status === 'done'; }).length;
var html = '';
TG_TIERS.forEach(function (t) {
var news = vis.filter(function (i) { return i.tier === t.key && i.status !== 'done'; });
var dones = TG.showDone ? vis.filter(function (i) { return i.tier === t.key && i.status === 'done'; }) : [];
html += '<div class="tri-sec ' + t.key + '"><div class="tri-sechead"><span class="dot"></span><span class="nm serif">' + t.nm + '</span><span class="pc">' + t.pc + '</span><span class="ct">' + news.length + ' 件</span></div>';
if (!news.length && !dones.length) html += '<div class="muted" style="font-size:14px;padding:10px 2px 0">' + t.empty + '</div>';
html += news.map(triItem).join('') + dones.map(triItem).join('');
html += '</div>';
});
html += '<div style="margin:24px 0 20px;text-align:center"><button class="btn3" id="tg-donetoggle" style="padding:9px 18px;font-size:13.5px;border-radius:999px">' +
(TG.showDone ? '隱藏已完成' : '顯示已完成(' + doneN + '') + '</button></div>';
out.innerHTML = html;
$('tg-donetoggle').addEventListener('click', function () { TG.showDone = !TG.showDone; renderTriage(); });
}
$('tg-chips').addEventListener('click', function (ev) {
var t = ev.target.closest('[data-triproj]');
if (!t) return;
TG.project = t.getAttribute('data-triproj');
renderTriage();
});
function loadTriage() {
var out = $('tg-out');
out.innerHTML = '<div class="muted" style="padding:14px 0">載入中…</div>';
fetch('/console/inbox-data', { headers: { Authorization: 'Bearer ' + S.token } })
fetch('/console/triage-data', { headers: { Authorization: 'Bearer ' + S.token } })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
if (!x.ok) {
out.innerHTML = '<div class="honest" style="margin-top:14px"><div class="h">讀不到收件匣</div><div class="b">' + esc(x.d.error || ('HTTP ' + x.status)) + '</div></div>';
out.innerHTML = '<div class="honest" style="margin-top:14px"><div class="h">讀不到分流台</div><div class="b">' + esc(x.d.error || ('HTTP ' + x.status)) + '</div></div>';
return;
}
var items = x.d.items || [];
if (!items.length) {
out.innerHTML = '<div class="honest" style="margin-top:14px"><div class="h">收件匣是空的</div><div class="b">從 Telegram 丟指令給機器人,這裡會列出「新的」與「已處理」。<br>(資料源:KBDB inbox entries——寫入方尚未灌資料時這裡誠實顯示空。)</div></div>';
return;
}
var news = items.filter(function (i) { return i.status !== 'done'; });
var done = items.filter(function (i) { return i.status === 'done'; });
var html = '';
html += '<div class="serif" style="margin:20px 0 10px;font-size:16px;letter-spacing:.18em;color:var(--amber)">新的・' + news.length + ' 條</div>';
html += news.length
? '<div style="display:flex;flex-direction:column;gap:9px">' + news.map(function (m) {
return '<div class="inbox-item new"><div style="font-size:16.5px;line-height:1.6;word-break:break-word">' + esc(m.text) + '</div>' +
'<div style="margin-top:8px;display:flex;gap:10px;align-items:center;font-size:13px;color:rgba(var(--ink-rgb),.45)"><span class="pulse-dot"></span><span>等待 AI 處理</span><span class="mono" style="margin-left:auto">' + esc(fmtDateTime(m.at)) + '</span></div></div>';
}).join('') + '</div>'
: '<div class="muted" style="font-size:14px">沒有待處理的指令</div>';
html += '<div class="serif" style="margin:26px 0 10px;font-size:16px;letter-spacing:.18em;color:rgba(var(--ink-rgb),.55)">已處理・' + done.length + ' 條</div>';
html += done.length
? '<div style="display:flex;flex-direction:column;gap:9px;padding-bottom:20px">' + done.map(function (m) {
return '<div class="inbox-item done"><div style="font-size:16px;line-height:1.6;color:rgba(var(--ink-rgb),.7);word-break:break-word">' + esc(m.text) + '</div>' +
'<div style="margin-top:8px;display:flex;gap:9px;align-items:baseline;font-size:14px"><span class="ok" style="flex:none">✓</span>' +
'<span style="color:rgba(127,224,168,.8);line-height:1.5;word-break:break-word">' + esc(m.result || '已處理') + '</span>' +
'<span class="dim mono" style="margin-left:auto;flex:none;font-size:13px">' + esc(fmtDateTime(m.at)) + '</span></div></div>';
}).join('') + '</div>'
: '<div class="muted" style="font-size:14px;padding-bottom:20px">還沒有已處理的紀錄</div>';
out.innerHTML = html;
TG.data = x.d;
// 記著的 project 篩選若已不存在(資料變了)→ 退回全部,別讓頁面空得不明不白
if (TG.project && TG.project !== '__none__' && (x.d.projects || []).indexOf(TG.project) < 0) TG.project = '';
renderTriage();
})
.catch(function (e) { out.innerHTML = '<div class="err" style="padding:14px 0">請求失敗:' + esc(friendlyErr(e)) + '</div>'; });
}
@@ -0,0 +1,134 @@
/**
* 分流台純函式測試(Arcrun#9 收件夾改裝=全部待辦分流台,2026-07-08)
*
* 驗證兩份 content 契約 → 統一分流項的判定:
* 1. todo 契約 {"text","marker","owner_tier","project","source","status"} 照 render
* 2. inbox 契約 {"text","from","status"} 無 tier → 歸 collab 欄+unclassified 標記
* 3. 三欄計數只算未完成(status:done 不佔欄)、per-project 清單去重排序
* 4. 防禦:owner_tier 看不懂/content 非 JSON 不得讓項目蒸發或冒領 ai 欄
*
* 樣本形態對照 leo21c live KBDBepoch 秒 created_at)與 kb-ingest SDD R5/R7 的
* Logseq marker 詞彙(TODO/DOING/LATER/NOW),非憑空編造。
*/
import { describe, it, expect } from 'vitest';
import { todoToTriageItem, inboxToTriageItem, buildTriageModel } from '../src/lib/console-triage-model';
import type { KbdbEntry } from '../src/lib/console-dashboard-model';
const SEC = (iso: string) => Math.floor(Date.parse(iso) / 1000);
function entry(created: string, content: unknown, type = 'todo'): KbdbEntry {
return {
id: crypto.randomUUID(),
entry_type: type,
content: typeof content === 'string' ? content : JSON.stringify(content),
created_at: SEC(created),
};
}
describe('todoToTriageItem — Logseq todo 契約', () => {
it('契約全欄照收(text/marker/owner_tier/project/source/status', () => {
const it_ = todoToTriageItem(
entry('2026-07-08T02:00:00Z', {
text: '把 KBDB 憑證輪替',
marker: 'TODO',
owner_tier: 'ai',
project: 'Arcrun',
source: 'notes',
status: 'new',
}),
);
expect(it_.text).toBe('把 KBDB 憑證輪替');
expect(it_.marker).toBe('TODO');
expect(it_.tier).toBe('ai');
expect(it_.unclassified).toBe(false);
expect(it_.project).toBe('Arcrun');
expect(it_.source).toBe('notes');
expect(it_.status).toBe('new');
expect(it_.origin).toBe('todo');
expect(it_.at_ms).toBe(Date.parse('2026-07-08T02:00:00Z'));
});
it('三欄值 ai/collab/leo 都認;issue 舊詞 mira80/collab15/leo5 當同義收', () => {
const tier = (v: unknown) => todoToTriageItem(entry('2026-07-08T02:00:00Z', { text: 'x', owner_tier: v }));
expect(tier('collab').tier).toBe('collab');
expect(tier('leo').tier).toBe('leo');
expect(tier('leo').unclassified).toBe(false);
expect(tier('mira80').tier).toBe('ai');
expect(tier('collab15').tier).toBe('collab');
expect(tier('leo5').tier).toBe('leo');
});
it('owner_tier 缺席/看不懂 → 歸 collab 欄+unclassified,不冒領 ai、不蒸發', () => {
const noTier = todoToTriageItem(entry('2026-07-08T02:00:00Z', { text: '還沒 triage 的', marker: 'LATER' }));
expect(noTier.tier).toBe('collab');
expect(noTier.unclassified).toBe(true);
const weird = todoToTriageItem(entry('2026-07-08T02:00:00Z', { text: 'x', owner_tier: 'boss' }));
expect(weird.tier).toBe('collab');
expect(weird.unclassified).toBe(true);
});
it('content 非 JSON(不合契約殘資料)→ 原文當 text 誠實顯示,不丟棄', () => {
const it_ = todoToTriageItem(entry('2026-07-08T02:00:00Z', 'TODO 裸文字殘資料'));
expect(it_.text).toBe('TODO 裸文字殘資料');
expect(it_.tier).toBe('collab');
expect(it_.unclassified).toBe(true);
expect(it_.status).toBe('new');
});
it('status 只有 done 算完成,其餘(含缺席/怪值)一律 new', () => {
expect(todoToTriageItem(entry('2026-07-08T02:00:00Z', { text: 'x', status: 'done' })).status).toBe('done');
expect(todoToTriageItem(entry('2026-07-08T02:00:00Z', { text: 'x', status: 'wip' })).status).toBe('new');
expect(todoToTriageItem(entry('2026-07-08T02:00:00Z', { text: 'x' })).status).toBe('new');
});
});
describe('inboxToTriageItem — Telegram inbox 契約', () => {
it('{"text","from","status"} 沿用:無 tier 歸 collabunclassifiedsource 帶 from', () => {
const it_ = inboxToTriageItem(entry('2026-07-08T03:00:00Z', { text: '幫我查憑證', from: 'leo', status: 'new' }, 'inbox'));
expect(it_.text).toBe('幫我查憑證');
expect(it_.tier).toBe('collab');
expect(it_.unclassified).toBe(true);
expect(it_.source).toBe('telegram・leo');
expect(it_.project).toBe('');
expect(it_.marker).toBe('');
expect(it_.origin).toBe('inbox');
});
it('無 from → source 仍標 telegramstatus done 照收', () => {
const it_ = inboxToTriageItem(entry('2026-07-08T03:00:00Z', { text: 'x', status: 'done' }, 'inbox'));
expect(it_.source).toBe('telegram');
expect(it_.status).toBe('done');
});
});
describe('buildTriageModel — 二源合一 / 三欄計數 / per-project', () => {
const todo = [
entry('2026-07-08T02:00:00Z', { text: 'A', owner_tier: 'ai', project: 'Arcrun', status: 'new' }),
entry('2026-07-08T01:00:00Z', { text: 'B', owner_tier: 'ai', project: 'Arcrun', status: 'done' }),
entry('2026-07-07T23:00:00Z', { text: 'C', owner_tier: 'leo', project: 'mira', status: 'new' }),
entry('2026-07-07T22:00:00Z', { text: 'D', project: '', status: 'new' }), // 未分流+未分項
];
const inbox = [
entry('2026-07-08T04:00:00Z', { text: 'E', from: 'leo', status: 'new' }, 'inbox'),
entry('2026-07-07T21:00:00Z', { text: 'F', status: 'done' }, 'inbox'),
];
it('合併排序=created_at 新→舊,跨兩源交錯', () => {
const m = buildTriageModel(todo, inbox);
expect(m.items.map((i) => i.text)).toEqual(['E', 'A', 'B', 'C', 'D', 'F']);
expect(m.counts.total).toBe(6);
});
it('三欄計數只算未完成:done 不佔欄;未分流(todo 無 tier + inbox 全部)歸 collab', () => {
const m = buildTriageModel(todo, inbox);
expect(m.counts.ai).toBe(1); // AB 是 done
expect(m.counts.collab).toBe(2); // D(未分流)+ Einbox
expect(m.counts.leo).toBe(1); // C
expect(m.counts.unclassified).toBe(2); // D + E
expect(m.counts.done).toBe(2); // B + F
});
it('projects 去重、字典序、不含空字串(未分項由 UI 承接)', () => {
const m = buildTriageModel(todo, inbox);
expect(m.projects).toEqual(['Arcrun', 'mira']);
});
it('空庫誠實回空,不編資料', () => {
const m = buildTriageModel([], []);
expect(m.items).toHaveLength(0);
expect(m.projects).toEqual([]);
expect(m.counts).toEqual({ ai: 0, collab: 0, leo: 0, unclassified: 0, done: 0, total: 0 });
});
});