feat(console): 分流台勾掉/還原(雙向銷案:console 勾掉=終局 checked_via:console,萃取端絕不復活)
leo 2026-07-08 拍板,PR#41 分流台的後續:
- lib/console-triage-model.ts:applyTriageCheck 純函式(check→status:done+
checked_via:console+checked_at;restore→status:new+移除 checked_via/checked_at;
content 非 JSON 殘資料包成 {"text":原文} 不丟字)。雙向銷案語意寫死在註解:
console 勾掉=終局,即使 Logseq 原文還是 TODO 萃取端也絕不復活;Logseq 改 DONE
由萃取端 PATCH(checked_via:logseq);console 只忠實顯示非 done 項。
- console-dashboard.ts:POST /console/triage-check(沿 triage-data 同款 session 驗證;
瀏覽器沒 KBDB token 故 server 端做 PATCH——先 GET 原 entry 整串回寫防蓋掉別的欄位;
守 owner_id=CONSOLE_TENANT 與 entry_type∈{todo,inbox} 兩道邊界)。
- console.ts:每筆待辦 44px 圓形勾掉鈕(手機好按)+done 清單「還原」鈕(誤勾救濟);
後端回寫成功才改本地狀態移出三欄(done 預設隱藏既有機制),失敗誠實 toast 可重試。
- 測試:applyTriageCheck 7 例(先紅後綠)+ route 層 8 例(fetchMock 假 host
kbdb.test + disableNetConnect,絕不外連;驗 session 閘/整串回寫/邊界/restore)。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -112,6 +112,40 @@ export function inboxToTriageItem(e: KbdbEntry): TriageItem {
|
||||
};
|
||||
}
|
||||
|
||||
export type TriageCheckAction = 'check' | 'restore';
|
||||
|
||||
/**
|
||||
* console 勾掉/還原 → 新 content JSON 字串(純函式;route 先 GET 原 entry 再整串回寫,
|
||||
* 防 PATCH 蓋掉別的欄位)。
|
||||
*
|
||||
* ── 雙向銷案語意(leo 2026-07-08 拍板,死循環防呆——萃取端會配合這套規約)──
|
||||
* 1. console 勾掉=**終局**:status:done + checked_via:"console" + checked_at。
|
||||
* 即使 Logseq 原文還是 TODO,萃取端(Arcrun#8 ingest 線)看到 checked_via:console
|
||||
* 也**絕不復活它**(不改回 new、不重灌覆蓋)——否則「console 勾掉→下輪萃取又出現」
|
||||
* 的死循環。
|
||||
* 2. Logseq 那邊改 DONE 的,萃取端來 PATCH status:done + checked_via:"logseq"——
|
||||
* 同一個 status slot、兩個來源靠 checked_via 溯源,console 只需忠實顯示非 done 項。
|
||||
* 3. 還原(誤勾救濟)=status 回 new、**移除 checked_via/checked_at**(回到「未銷案」
|
||||
* 的乾淨狀態,讓兩邊規約重新適用;不留殘欄位讓萃取端誤讀成已銷案)。
|
||||
*
|
||||
* 防禦:content 非 JSON(不合契約殘資料,與 todoToTriageItem 同一情境)→ 包成
|
||||
* {"text":原文} 再上狀態欄——原文不丟、從此畢業成契約 JSON(status 才有地方放)。
|
||||
*/
|
||||
export function applyTriageCheck(content: string | null | undefined, action: TriageCheckAction, nowIso: string): string {
|
||||
let obj: Record<string, unknown>;
|
||||
try {
|
||||
const v = JSON.parse(content ?? '');
|
||||
obj = v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : { text: content ?? '' };
|
||||
} catch {
|
||||
obj = { text: content ?? '' };
|
||||
}
|
||||
if (action === 'restore') {
|
||||
const { checked_via: _via, checked_at: _at, ...rest } = obj;
|
||||
return JSON.stringify({ ...rest, status: 'new' });
|
||||
}
|
||||
return JSON.stringify({ ...obj, status: 'done', checked_via: 'console', checked_at: nowIso });
|
||||
}
|
||||
|
||||
/** 二源合一 → 分流台模型。排序=created_at 新→舊(時間不明沉底);分欄計數只算未完成。 */
|
||||
export function buildTriageModel(todoEntries: KbdbEntry[], inboxEntries: KbdbEntry[]): TriageModel {
|
||||
const items = [
|
||||
|
||||
@@ -68,7 +68,7 @@ import {
|
||||
sortWaitingItems,
|
||||
taipeiDayKey,
|
||||
} from '../lib/console-dashboard-model';
|
||||
import { buildTriageModel } from '../lib/console-triage-model';
|
||||
import { applyTriageCheck, buildTriageModel, type TriageCheckAction } from '../lib/console-triage-model';
|
||||
import { TAIPEI_CLIENT_JS } from '../lib/taipei-time';
|
||||
|
||||
export const consoleDashboardRouter = new Hono<{ Bindings: Bindings }>();
|
||||
@@ -473,6 +473,55 @@ consoleDashboardRouter.get('/console/triage-data', async (c) => {
|
||||
return c.json({ ...model, generated_at: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// POST /console/triage-check — 分流台勾掉/還原(leo 2026-07-08 拍板;body: {entry_id, action?})。
|
||||
// 為什麼開這個小端點而不讓瀏覽器直打 KBDB:瀏覽器沒有 KBDB_INTERNAL_TOKEN(token 只能在
|
||||
// server 側,同 kbdb-graph proxy 理由),且 console session ≠ X-Arcrun-API-Key。沿用
|
||||
// triage-data 同款 session 驗證,server 端做 KBDB PATCH(kbdbBase 慣例)。
|
||||
//
|
||||
// PATCH content 需**整串回寫**(KBDB updateEntry 是欄位級覆蓋,content 給什麼存什麼)——
|
||||
// 先 GET 原 entry、只動 status/checked_* 欄再回寫,防蓋掉 text/marker/owner_tier 等別的欄位。
|
||||
// 改寫邏輯=lib/console-triage-model.ts applyTriageCheck(純函式,vitest 驗證)。
|
||||
//
|
||||
// ── 雙向銷案語意(死循環防呆,與 applyTriageCheck 註解同一套規約,萃取端會配合)──
|
||||
// console 勾掉=終局(checked_via:"console"):即使 Logseq 原文還是 TODO,萃取端也絕不
|
||||
// 復活它;Logseq 改 DONE 的由萃取端 PATCH status:done(checked_via:"logseq")。
|
||||
// console 只需忠實顯示非 done 項;還原=status 回 new + 移除 checked_via/checked_at。
|
||||
consoleDashboardRouter.post('/console/triage-check', async (c) => {
|
||||
const ok = await validateConsoleSession(c.env, c.req.header('authorization'));
|
||||
if (!ok) return c.json({ error: '需要登入(console session)' }, 401);
|
||||
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const entryId = typeof body?.entry_id === 'string' ? body.entry_id.trim() : '';
|
||||
if (!entryId) return c.json({ error: 'entry_id 必填' }, 400);
|
||||
const action: TriageCheckAction = body?.action === 'restore' ? 'restore' : 'check';
|
||||
|
||||
const tenant = c.env.CONSOLE_TENANT || 'leo';
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
|
||||
// 先 GET 原 entry(整串回寫的前提),順便守兩道邊界:
|
||||
// 1. owner_id 必須=console 固定租戶(session 只代表 leo 這個租戶,不能改到別人的資料);
|
||||
// 2. entry_type 限分流台的兩個來源 todo/inbox(這端點不是泛用 entry 改寫器)。
|
||||
const got = await fetchJson<{ entry?: { owner_id?: string; entry_type?: string; content?: string | null } }>(
|
||||
`${base}/entries/${encodeURIComponent(entryId)}`,
|
||||
headers,
|
||||
);
|
||||
const entry = got?.entry;
|
||||
if (!entry) return c.json({ error: '找不到這筆待辦(可能已被刪除)' }, 404);
|
||||
if (entry.owner_id !== tenant) return c.json({ error: '找不到這筆待辦(可能已被刪除)' }, 404); // 不洩漏他租戶存在性
|
||||
if (entry.entry_type !== 'todo' && entry.entry_type !== 'inbox') {
|
||||
return c.json({ error: '只有分流台項目(todo/inbox)能在這裡勾掉' }, 400);
|
||||
}
|
||||
|
||||
const newContent = applyTriageCheck(entry.content, action, new Date().toISOString());
|
||||
const res = await fetch(`${base}/entries/${encodeURIComponent(entryId)}`, {
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
body: JSON.stringify({ content: newContent }),
|
||||
});
|
||||
if (!res.ok) return c.json({ error: `KBDB 回寫失敗(HTTP ${res.status})` }, 502);
|
||||
return c.json({ success: true, entry_id: entryId, action, status: action === 'restore' ? 'new' : 'done' });
|
||||
});
|
||||
|
||||
function renderDashboardHtml(): string {
|
||||
return `<!doctype html>
|
||||
<html lang="zh-Hant">
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
* 資料源二合一(kb-ingest SDD R7 / Arcrun#9):entry_type=todo(Logseq 萃取)+
|
||||
* entry_type=inbox(Telegram);三欄 80/15/5(ai/collab/leo)+ per-project chips
|
||||
* +已完成預設隱藏可切換。分欄判定在 lib/console-triage-model.ts(純函式),頁面只渲染。
|
||||
* 勾掉/還原走 POST /console/triage-check(同 session 保護;leo 2026-07-08 拍板)——
|
||||
* 雙向銷案語意:console 勾掉=終局(checked_via:console,萃取端絕不復活)、Logseq 改
|
||||
* DONE 由萃取端 PATCH(checked_via:logseq);console 只忠實顯示非 done 項。
|
||||
* - 設定:vectorize 狀態探測(讀 /kbdb/search 回應的 mode/capability_hint,無新端點;
|
||||
* 開關本體需部署端 config kbdb_embed + acr update → 誠實文案講清楚、不假裝能遠端開)
|
||||
* + MCP token TTL 誠實佔位(/console/settings-data;可調功能=Arcrun#19)
|
||||
@@ -260,6 +263,17 @@ function renderConsoleHtml(registryBase: string): string {
|
||||
.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); }
|
||||
.tri-item .row { display: flex; gap: 12px; align-items: flex-start; }
|
||||
.tri-item .row .tx { flex: 1; min-width: 0; }
|
||||
/* 勾掉鈕:≥44px 圓形(手機好按),綠框空心=待勾;按下去由後端回寫才算數(誠實,不先假裝成功) */
|
||||
.tri-check { flex: none; width: 44px; height: 44px; border-radius: 50%; cursor: pointer; font-size: 19px; line-height: 1;
|
||||
border: 1.5px solid rgba(var(--ok-rgb),.55); background: rgba(var(--ok-rgb),.06); color: var(--ok); }
|
||||
.tri-check:active { background: rgba(var(--ok-rgb),.22); }
|
||||
.tri-check:disabled { opacity: .45; cursor: default; }
|
||||
/* 還原鈕(誤勾救濟,只出現在「顯示已完成」清單):小顆但仍 44px 高好按 */
|
||||
.tri-restore { flex: none; min-height: 32px; padding: 5px 14px; border-radius: 999px; cursor: pointer; font-size: 13px;
|
||||
border: 1px solid rgba(var(--amber-rgb),.5); background: none; color: var(--amber); }
|
||||
.tri-restore:disabled { opacity: .45; cursor: default; }
|
||||
.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); }
|
||||
|
||||
@@ -1267,13 +1281,21 @@ function renderConsoleHtml(registryBase: string): string {
|
||||
{ key: 'leo', nm: '非我不可', pc: '5%', empty: '這欄空的——沒有非你不可的事' }
|
||||
];
|
||||
function triItem(m) {
|
||||
var done = m.status === 'done';
|
||||
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>';
|
||||
// 誤勾救濟:done 清單裡給「還原」(status 回 new + 移除 checked_via/checked_at,端點同一個)
|
||||
if (done) meta += '<button class="tri-restore" data-trirestore="' + esc(m.id) + '">還原</button>';
|
||||
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>';
|
||||
// 勾掉=雙向銷案的 console 端終局(checked_via:console,萃取端絕不復活);
|
||||
// 後端回寫成功才改本地狀態移出三欄(done 預設隱藏),不樂觀假裝
|
||||
var body = done
|
||||
? '<div class="tx">' + esc(m.text) + '</div>'
|
||||
: '<div class="row"><div class="tx">' + esc(m.text) + '</div><button class="tri-check" data-tricheck="' + esc(m.id) + '" title="勾掉(銷案)">✓</button></div>';
|
||||
return '<div class="tri-item ' + (done ? 'done' : 'new') + '">' + body + '<div class="mt">' + meta + '</div></div>';
|
||||
}
|
||||
function triChip(label, val, on) {
|
||||
return '<button class="chip' + (on ? ' on' : '') + '" data-triproj="' + esc(val) + '">' + esc(label) + '</button>';
|
||||
@@ -1315,6 +1337,34 @@ function renderConsoleHtml(registryBase: string): string {
|
||||
TG.project = t.getAttribute('data-triproj');
|
||||
renderTriage();
|
||||
});
|
||||
// 勾掉/還原:POST /console/triage-check(console session 保護的小端點——瀏覽器沒有 KBDB
|
||||
// token,PATCH 由 server 端做,先 GET 原 entry 整串回寫防蓋掉別的欄位)。成功才動本地
|
||||
// 狀態重繪(該筆立即移出/回到三欄),失敗誠實 toast、按鈕解鎖可重試。
|
||||
function triageAct(id, action, btn) {
|
||||
btn.disabled = true;
|
||||
fetch('/console/triage-check', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + S.token },
|
||||
body: JSON.stringify({ entry_id: id, action: action })
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||||
.then(function (x) {
|
||||
if (!x.ok) { btn.disabled = false; toast(x.d.error || ('失敗(HTTP ' + x.status + ')')); return; }
|
||||
var items = (TG.data && TG.data.items) || [];
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
if (items[i].id === id) items[i].status = (action === 'restore' ? 'new' : 'done');
|
||||
}
|
||||
renderTriage();
|
||||
toast(action === 'restore' ? '已還原' : '已勾掉');
|
||||
})
|
||||
.catch(function (e) { btn.disabled = false; toast('請求失敗:' + friendlyErr(e)); });
|
||||
}
|
||||
$('tg-out').addEventListener('click', function (ev) {
|
||||
var chk = ev.target.closest('[data-tricheck]');
|
||||
if (chk) { triageAct(chk.getAttribute('data-tricheck'), 'check', chk); return; }
|
||||
var rst = ev.target.closest('[data-trirestore]');
|
||||
if (rst) triageAct(rst.getAttribute('data-trirestore'), 'restore', rst);
|
||||
});
|
||||
function loadTriage() {
|
||||
var out = $('tg-out');
|
||||
out.innerHTML = '<div class="muted" style="padding:14px 0">載入中…</div>';
|
||||
|
||||
Reference in New Issue
Block a user