feat(console): 收件夾改裝=全部待辦分流台(Arcrun#9,kb-ingest SDD R7 消費端)

console 頁 7 從 Telegram 訊息清單改裝成三欄分流台:
- 資料源二合一:entry_type=todo(Logseq 萃取,Arcrun#8 ingest 線)+ entry_type=inbox
  (Telegram),/console/inbox-data → /console/triage-data(唯一消費者是本頁,一起改
  裝不留死端點;仍鎖 console session——待辦原文屬機敏)
- 三欄 80/15/5:ai=AI 會幫我搞定/collab=協作/leo=非我不可;owner_tier 缺席或看
  不懂(triage engine 還沒判)→ 歸 collab 欄+「未分流」徽章,不冒領 ai、不誤判 leo
- 各項顯示 text/project 徽章/source/marker;status:done 預設隱藏可切換
- per-project chips 過濾(全部/各 project/未分項),前端同包資料過濾零額外請求
- 手機直排沿 console 單檔薄殼;讀 KBDB 走既有 kbdbBase HTTP 慣例,不新增 binding

契約解析/分欄/計數=純函式 lib/console-triage-model.ts(比照 console-dashboard-model
模式,route 只做 IO),tests/console-triage-model.test.ts 11 測全綠(紅→綠驗證:抽掉
實作模組同測試即紅)。全套 vitest 98 passed,唯一失敗為 pre-existing executor
「不存在的零件」訊息斷言(main 同紅,與本 PR 無關)。

[總管-subagent]

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 22:44:54 +08:00
parent 2fa0974e1c
commit 5cd790c94a
5 changed files with 377 additions and 64 deletions
+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>'; });
}