feat(console): Mira Console 完整版——8 頁紙感定稿視覺 SPA (Arcrun#3 console 系)

依 claude design 定稿(紙感暖黑 2a)+ brief 8 頁資訊架構重寫 /console:
- 單檔 HTML+原生 JS hash routing,零外部資源;紙紋底/明體標題/琥珀強調/
  呼吸球嵌「安/趕/滯」;手機優先 390px + ≥1024px 側欄 + 底部五 tab
- 頁1 登入/首次設定(沿 console-auth v1)|頁2 駕駛艙(dashboard-data,60s 刷新;
  /console/dashboard 免登入獨立頁同步換視覺)|頁3 總庫搜尋(entry_type chips=
  KBDB 真能篩的欄位、共 N 筆中命中 M(誠實 total)、語意未啟用誠實降級 banner)|
  頁4 卡片詳頁+關聯(Markdown 渲染 + kbdb-graph-plugin 鄰居放射圖,經新 proxy
  /kbdb/graph/neighbors/:name;無 triplet →「尚無關聯資料」)|頁5 工作流
  (/webhooks/named list+手動觸發+最近執行;釘選 localStorage;v0 零件/recipes
  查詢收進本頁折疊區不砍功能)|頁6 憑證(新 GET /credentials/catalog D1 目錄
  唯讀,絕不回密文;新增/替換走既有端點;刪除未接 D1(T9)標「即將開通」)|
  頁7 收件匣(新 GET /console/inbox-data,session 鎖——訊息原文屬機敏)|
  頁8 設定(vectorize 狀態探測讀 search 降級訊號、開關標「即將開通」、
  /console/setup/reset 換帳密、系統資訊)

驗證:tsc exit 0;vitest 26/27(1 失敗 stash 複驗 pre-existing);wrangler
deploy --dry-run 打包過;inline JS 求值後 node --check + esc/mdRender/parseAtMs
純函式行為測試全過(含 XSS escape)。未部署,留總管接手。
This commit is contained in:
uncle6me-web
2026-07-04 17:49:49 +08:00
parent 5174ed6821
commit f9e44abc59
6 changed files with 1364 additions and 376 deletions
@@ -29,6 +29,17 @@ const CREDS_KEY = 'console:credentials';
const SESSION_PREFIX = 'console_sess:';
const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 天
/**
* 驗證 console session(給其他 route 共用,如 /console/inbox-data)。
* authHeader 形如 "Bearer <token>";有效回 true。
*/
export async function validateConsoleSession(env: Bindings, authHeader: string | undefined): Promise<boolean> {
const token = (authHeader ?? '').match(/^Bearer\s+(\S+)/i)?.[1];
if (!token) return false;
const sess = await env.SESSIONS_KV.get(`${SESSION_PREFIX}${token}`);
return !!sess;
}
interface StoredCredentials {
email: string;
salt: string; // hex
+147 -55
View File
@@ -29,6 +29,7 @@
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { kbdbBase } from './kbdb-proxy';
import { validateConsoleSession } from './console-auth';
export const consoleDashboardRouter = new Hono<{ Bindings: Bindings }>();
@@ -164,97 +165,188 @@ consoleDashboardRouter.get('/console/dashboard-data', async (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) => {
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,
});
});
function renderDashboardHtml(): string {
return `<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>arcrun 駕駛艙</title>
<title>Mira 駕駛艙</title>
<style>
:root { color-scheme: dark; }
/* Mira Console 定稿視覺(紙感暖黑「2a」,Mira Style Guide 2026-07-04):
紙紋底 repeating-linear-gradient、明體標題級聯、琥珀 #e8b45a 強調、呼吸狀態球嵌單字。 */
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, "PingFang TC", "Noto Sans TC", sans-serif; background: #0f1115; color: #e6e6e6; }
main { padding: 14px; display: grid; gap: 14px; max-width: 560px; margin: 0 auto; }
.card { background: #161922; border: 1px solid #262a33; border-radius: 12px; padding: 16px; }
.light-row { display: flex; align-items: center; gap: 12px; }
.light-emoji { font-size: 44px; line-height: 1; }
.light-word { font-size: 20px; font-weight: 700; }
.beat { color: #9aa0aa; font-size: 13px; margin-top: 8px; }
.bar-wrap { margin-top: 12px; }
.bar-label { font-size: 12px; color: #9aa0aa; margin-bottom: 4px; display: flex; justify-content: space-between; }
.bar { height: 10px; background: #0f1115; border: 1px solid #262a33; border-radius: 999px; overflow: hidden; }
.bar > i { display: block; height: 100%; background: #3a5bfd; border-radius: 999px; transition: width .4s; }
.subhead { font-size: 12px; color: #9aa0aa; margin: 12px 0 4px; text-transform: uppercase; letter-spacing: .04em; }
.subhead:first-child { margin-top: 0; }
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
li { font-size: 14px; line-height: 1.5; }
.muted { color: #6b7280; font-size: 13px; }
.inbox { margin-top: 12px; font-size: 13px; color: #9aa0aa; }
.err { color: #f06565; font-size: 13px; }
.stamp { text-align: center; color: #4b5563; font-size: 11px; }
html, body { margin: 0; background: repeating-linear-gradient(0deg,#191410 0px,#191410 3px,#1b1611 3px,#1b1611 4px); color: #ede4d3;
font-family: -apple-system, "PingFang TC", "Microsoft JhengHei", system-ui, sans-serif; font-size: 16px; -webkit-font-smoothing: antialiased; }
.serif { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; }
main { max-width: 560px; margin: 0 auto; padding: 0 20px 40px; }
.pagehead { padding: 22px 2px 14px; border-bottom: 2px solid rgba(232,180,90,.4); display: flex; justify-content: space-between; align-items: baseline; }
.pagehead .title { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 23px; letter-spacing: .2em; }
.pagehead .title small { font-size: 14px; letter-spacing: .3em; color: rgba(237,228,211,.5); }
.pagehead .date { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 14px; color: rgba(237,228,211,.55); }
.orb-row { display: flex; align-items: center; gap: 20px; padding: 26px 2px 20px; }
.orb { width: 84px; height: 84px; border-radius: 50%; flex: none; display: grid; place-items: center; }
.orb span { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 30px; font-weight: 600; color: rgba(10,20,14,.85); text-shadow: 0 1px 0 rgba(255,255,255,.25); }
.orb-title { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 23px; font-weight: 600; }
.orb-sub { margin-top: 5px; font-size: 15px; color: rgba(237,228,211,.6); line-height: 1.55; }
@keyframes breatheGreen { 0%,100% { box-shadow: 0 0 24px 6px rgba(63,190,120,.35); } 50% { box-shadow: 0 0 42px 14px rgba(63,190,120,.55); } }
@keyframes breatheAmber { 0%,100% { box-shadow: 0 0 24px 6px rgba(232,180,90,.35); } 50% { box-shadow: 0 0 42px 14px rgba(232,180,90,.6); } }
@keyframes breatheRed { 0%,100% { box-shadow: 0 0 24px 6px rgba(217,95,76,.4); } 50% { box-shadow: 0 0 44px 16px rgba(217,95,76,.65); } }
.bricks { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.brick { padding: 16px; border-radius: 12px; }
.brick.amber { background: rgba(232,180,90,.07); border: 1px solid rgba(232,180,90,.22); }
.brick.plain { background: rgba(237,228,211,.04); border: 1px solid rgba(237,228,211,.14); }
.brick .lbl { font-size: 13.5px; color: rgba(237,228,211,.55); margin-bottom: 6px; }
.brick .num { font-family: ui-monospace, Menlo, monospace; font-size: 26px; color: #e8b45a; }
.brick .num small { font-size: 15px; color: rgba(237,228,211,.5); }
.bar { margin-top: 10px; height: 6px; border-radius: 3px; background: rgba(255,255,255,.08); }
.bar > i { display: block; height: 100%; border-radius: 3px; background: linear-gradient(90deg,#b98330,#e8b45a); transition: width .6s; }
.wait-box { margin-top: 14px; padding: 20px; border-radius: 12px; border: 1px dashed rgba(63,190,120,.3); background: rgba(63,190,120,.05); }
.wait-box.has { border-color: rgba(232,180,90,.45); background: rgba(232,180,90,.05); }
.wait-head { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 16px; letter-spacing: .2em; color: rgba(237,228,211,.6); margin-bottom: 10px; text-align: center; }
.wait-none { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 20px; color: #7fe0a8; letter-spacing: .08em; text-align: center; }
.wait-item { display: flex; align-items: center; gap: 12px; padding: 12px 14px; margin-top: 8px; border-radius: 10px; background: rgba(232,180,90,.1); border: 1px solid rgba(232,180,90,.3); font-size: 16px; line-height: 1.5; }
.wait-item .dm { color: #e8b45a; font-size: 17px; flex: none; }
.subhead { display: flex; justify-content: space-between; align-items: baseline; margin: 24px 0 10px; }
.subhead .t { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 16px; letter-spacing: .2em; color: rgba(237,228,211,.6); }
.subhead .m { font-size: 13px; color: rgba(237,228,211,.4); }
ul.route { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
ul.route li { display: flex; align-items: flex-start; gap: 12px; padding: 13px 16px; border-radius: 11px; background: rgba(237,228,211,.045); border: 1px solid transparent; font-size: 16px; line-height: 1.4; }
ul.route li.doing { background: rgba(232,180,90,.09); border-color: rgba(232,180,90,.3); }
ul.route li .ic { flex: none; font-size: 15px; margin-top: 2px; }
ul.route li.done { color: rgba(237,228,211,.65); }
ul.route li.done .ic { color: #7fe0a8; }
ul.route li.doing .ic { color: #e8b45a; }
ul.route li.todo { color: rgba(237,228,211,.6); }
ul.route li.todo .ic { color: rgba(237,228,211,.35); }
ul.route li.blocked .ic { color: #e58575; }
.muted { color: rgba(237,228,211,.45); font-size: 14px; }
.err { color: #e58575; font-size: 14px; }
.stamp { margin: 16px 0 8px; text-align: center; font-size: 12.5px; color: rgba(237,228,211,.35); line-height: 1.8; }
.enter { display: block; text-align: center; font-size: 13.5px; color: rgba(232,180,90,.75); text-decoration: none; margin-top: 6px; }
</style>
</head>
<body>
<main>
<div class="card" id="card-light">
<div class="light-row"><span class="light-emoji" id="light-emoji">⏳</span><span class="light-word" id="light-word">載入中</span></div>
<div class="beat" id="beat-line"></div>
<div class="bar-wrap">
<div class="bar-label"><span>今日完成度</span><span id="bar-num"></span></div>
<div class="bar"><i id="bar-fill" style="width:0%"></i></div>
<div class="pagehead">
<div class="title serif">Mira<small> 駕駛艙</small></div>
<div class="date serif" id="date-str"></div>
</div>
<div class="orb-row">
<div class="orb" id="orb" style="background:radial-gradient(circle at 36% 30%,#8fe8b4,#3fbe78 55%,#22754a 100%)"><span id="orb-char">…</span></div>
<div>
<div class="orb-title" id="orb-title">載入中</div>
<div class="orb-sub" id="orb-sub"></div>
</div>
</div>
<div class="card">
<div class="subhead">今日路線</div>
<ul id="today-list"><li class="muted">載入中...</li></ul>
<div class="subhead" id="week-head" style="display:none">本週</div>
<ul id="week-list"></ul>
<div class="subhead" style="margin-top:14px">等你的事</div>
<ul id="wait-list"></ul>
<div class="inbox" id="inbox-line"></div>
<div class="bricks">
<div class="brick amber">
<div class="lbl">今日完成</div>
<div class="num"><span id="done-n"></span><small> / <span id="total-n"></span> 件</small></div>
<div class="bar"><i id="bar-fill" style="width:0%"></i></div>
</div>
<div class="brick plain">
<div class="lbl">收件匣未處理</div>
<div class="num"><span id="inbox-n"></span><small> 條</small></div>
<div class="lbl" style="margin:10px 0 0">來自 Telegram</div>
</div>
</div>
<div class="stamp" id="stamp"></div>
<div class="wait-box" id="wait-box">
<div class="wait-head">等你的事</div>
<div id="wait-body" class="wait-none">載入中…</div>
</div>
<div class="subhead"><span class="t">今日路線</span><span class="m">狀態即時同步</span></div>
<ul class="route" id="today-list"><li class="todo"><span class="ic">○</span>載入中…</li></ul>
<div class="subhead" id="week-head" style="display:none"><span class="t">本週</span></div>
<ul class="route" id="week-list"></ul>
<div class="stamp" id="stamp">每 60 秒自動刷新</div>
<a class="enter" href="/console">進入完整控制台 </a>
</main>
<script>
(function () {
const $ = (id) => document.getElementById(id);
const LIGHT = { green: ['🟢', '系統運轉中'], yellow: ['🟡', '落後趕工中'], red: ['🔴', '卡住'] };
const STATUS_EMOJI = { done: '', doing: '🔄', todo: '⬜', blocked: '⛔' };
const LIGHT = {
green: { ch: '', title: '系統運轉中', grad: 'radial-gradient(circle at 36% 30%,#8fe8b4,#3fbe78 55%,#22754a 100%)', anim: 'breatheGreen' },
yellow: { ch: '趕', title: '落後趕工中', grad: 'radial-gradient(circle at 36% 30%,#f2d194,#e8b45a 55%,#8a5f1e 100%)', anim: 'breatheAmber' },
red: { ch: '滯', title: '卡住或斷訊', grad: 'radial-gradient(circle at 36% 30%,#f0a094,#d95f4c 55%,#7e2c20 100%)', anim: 'breatheRed' }
};
const ICONS = { done: '✓', doing: '◐', todo: '○', blocked: '●' };
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
function taskLine(t) {
return '<li>' + (STATUS_EMOJI[t.status] || '⚠️') + ' ' + esc(t.title) + '</li>';
const cls = ICONS[t.status] ? t.status : 'blocked';
const ic = ICONS[t.status] || '●';
return '<li class="' + cls + '"><span class="ic">' + ic + '</span><span>' + esc(t.title) + '</span></li>';
}
const CNUM = ['零','一','二','三','四','五','六','七','八','九','十'];
function cnDay(n) { return n <= 10 ? CNUM[n] : (n < 20 ? '十' + (n % 10 ? CNUM[n % 10] : '') : CNUM[Math.floor(n / 10)] + '十' + (n % 10 ? CNUM[n % 10] : '')); }
const now = new Date();
$('date-str').textContent = CNUM[now.getMonth() + 1] + '月' + cnDay(now.getDate()) + '日';
async function load() {
try {
const res = await fetch('/console/dashboard-data');
if (!res.ok) throw new Error('HTTP ' + res.status);
const d = await res.json();
const [emoji, word] = LIGHT[d.light] || ['⚪', d.light];
$('light-emoji').textContent = emoji;
$('light-word').textContent = word;
$('beat-line').textContent = d.last_beat
? '最後心跳 ' + d.last_beat.actor + ' ' + d.last_beat.ago_minutes + ' 分鐘前'
: '尚無心跳';
const cfg = LIGHT[d.light] || LIGHT.green;
const orb = $('orb');
orb.style.background = cfg.grad;
orb.style.animation = cfg.anim + ' 3.4s ease-in-out infinite';
$('orb-char').textContent = cfg.ch;
$('orb-title').textContent = cfg.title;
$('orb-sub').textContent = d.last_beat
? d.last_beat.actor + '・' + d.last_beat.ago_minutes + ' 分鐘前' + (d.last_beat.note ? '・' + d.last_beat.note : '')
: '尚無心跳資料';
const done = d.today_done || 0, total = d.today_total || 0;
$('bar-num').textContent = done + ' / ' + total;
$('done-n').textContent = done; $('total-n').textContent = total;
$('bar-fill').style.width = (total ? Math.round((done / total) * 100) : 0) + '%';
$('inbox-n').textContent = d.inbox_new || 0;
const wb = $('wait-box'), body = $('wait-body');
if (d.waiting && d.waiting.length) {
wb.classList.add('has');
body.className = '';
body.innerHTML = d.waiting.map((w) => '<div class="wait-item"><span class="dm">◆</span><span>' + esc(w.title) + '</span></div>').join('');
} else {
wb.classList.remove('has');
body.className = 'wait-none';
body.textContent = '無,你不用做任何事';
}
const today = (d.tasks || []).filter((t) => t.scope === 'today');
const week = (d.tasks || []).filter((t) => t.scope === 'week');
$('today-list').innerHTML = today.length ? today.map(taskLine).join('') : '<li class="muted">今日無排定項目</li>';
$('today-list').innerHTML = today.length ? today.map(taskLine).join('') : '<li class="todo"><span class="ic">○</span><span class="muted">今日無排定項目</span></li>';
$('week-head').style.display = week.length ? '' : 'none';
$('week-list').innerHTML = week.map(taskLine).join('');
$('wait-list').innerHTML = (d.waiting && d.waiting.length)
? d.waiting.map((w) => '<li>🙋 ' + esc(w.title) + '</li>').join('')
: '<li class="muted">無,你不用做任何事</li>';
$('inbox-line').textContent = '📥 收件匣未處理:' + (d.inbox_new || 0) + ' 件';
$('stamp').textContent = '更新於 ' + new Date(d.generated_at).toLocaleTimeString('zh-TW', { hour12: false });
$('stamp').innerHTML = '每 60 秒自動刷新・上次 ' + esc(new Date(d.generated_at).toLocaleTimeString('zh-TW', { hour12: false })) + '<br>此頁不含機敏內容,免登入';
} catch (e) {
$('light-emoji').textContent = '';
$('light-word').textContent = '讀不到狀態';
$('beat-line').innerHTML = '<span class="err">' + esc(e.message) + '</span>';
$('orb-char').textContent = '';
$('orb-title').textContent = '讀不到狀態';
$('orb-sub').innerHTML = '<span class="err">' + esc(e.message) + '</span>';
}
}
load();
File diff suppressed because it is too large Load Diff
+25
View File
@@ -205,6 +205,31 @@ credentialsRouter.delete('/credentials/:name', async (c) => {
return c.json({ success: true, name });
});
// GET /credentials/catalog — D1 目錄唯讀 listMira Console 完整版,Arcrun#3 console 系)。
// 只回 metadataname/service/sensitivity/created_at/last_used_at),**絕不回密文值**——
// 密文在 Workers Secrets,本 worker 自己也讀不回(D19「擁有目錄,不擁有內容物」)。
// 與舊 GET /credentialsKV 名稱清單)並存:這裡是新制 D1 目錄;舊 KV 寫入的看不到(T5 已知誠實缺口)。
// 註冊須在 GET /credentials/:name 類 route 之前?本檔無 :name GET route,無攔截問題。
credentialsRouter.get('/credentials/catalog', async (c) => {
const apiKey = c.req.header('X-Arcrun-API-Key');
if (!apiKey) {
return c.json({ error: '缺少 X-Arcrun-API-Key header' }, 401);
}
try {
const rows = await c.env.CREDENTIALS_DB
.prepare(
`SELECT name, service, sensitivity, created_at, last_used_at
FROM credentials WHERE api_key = ? ORDER BY created_at DESC`,
)
.bind(apiKey)
.all<{ name: string; service: string | null; sensitivity: string; created_at: number; last_used_at: number | null }>();
return c.json({ success: true, credentials: rows.results ?? [], total: (rows.results ?? []).length });
} catch (e) {
// 誠實回報:D1 未建表 / migration 未跑(不假綠回空陣列裝沒事)
return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 502);
}
});
// GET /credentials — 列出 credential 名稱(不含值)(未動:仍是舊 KV 路徑,T9 範圍)
credentialsRouter.get('/credentials', async (c) => {
const apiKey = c.req.header('X-Arcrun-API-Key');
+27
View File
@@ -189,6 +189,33 @@ kbdbProxyRouter.get('/kbdb/entries/:id', async (c) => {
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
// ── graphkbdb-graph-plugin proxyMira Console 卡片詳頁「關聯視圖」)───────────────
//
// 純轉發(rule 07):圖能力真身在 kbdb-graph-plugin workerGET /graph/neighbors/:name
// 回 { node, edges[], neighbors[], edgeCount, neighborCount })。瀏覽器不能持 KBDB_INTERNAL_TOKEN
// 故經 cypher 代轉(token 只在 server 側)。plugin base 現算慣例同 registry/KBDB_BASE_URL。
function graphBase(env: Bindings): string {
if (env.KBDB_GRAPH_URL) return env.KBDB_GRAPH_URL.replace(/\/$/, '');
return `https://kbdb-graph-plugin.${env.WORKER_SUBDOMAIN}.workers.dev`;
}
// GET /kbdb/graph/neighbors/:name — 查某節點(entity/卡片名)的鄰居 + 邊。
// 查無 triplet 資料時 plugin 回空陣列——前端據此顯示「尚無關聯資料」(誠實,不編造關聯)。
kbdbProxyRouter.get('/kbdb/graph/neighbors/:name', async (c) => {
if (!tenant(c)) return c.json(NEED_KEY, 401);
const base = graphBase(c.env);
const headers: Record<string, string> = {};
if (c.env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
try {
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(c.req.param('name'))}`, { headers });
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
} catch (e) {
// plugin worker 沒部署 / 不可達 → 誠實回報(前端顯示「關聯服務未部署」而非假裝無關聯)
return c.json({ error: `kbdb-graph-plugin 不可達(${base}):${e instanceof Error ? e.message : String(e)}` }, 502);
}
});
// PATCH /kbdb/entries/:id — 更新單筆 entry。owner_id 不可被改(剝除 caller 自帶的 owner_id)。
kbdbProxyRouter.patch('/kbdb/entries/:id', async (c) => {
if (!tenant(c)) return c.json(NEED_KEY, 401);
+4
View File
@@ -78,6 +78,10 @@ export type Bindings = {
// console 登入後端一律用這個字串打 /kbdb/*、/workflows/search(不做多租戶,登入系統只擋外人看頁面)。
// 未設 → routes/console-auth.ts 預設 "leo"(發現①已核實:D1 458,357 筆資料實際使用的租戶字串)。
CONSOLE_TENANT?: string;
// kbdb-graph-plugin worker base URL(可選)。未設 → 用 WORKER_SUBDOMAIN 現算
// https://kbdb-graph-plugin.<subdomain>.workers.dev(該 repo wrangler.toml name 固定)。
// console 卡片詳頁「關聯視圖」經 cypher proxy 打它(kbdb-proxy.ts /kbdb/graph/neighbors/:name)。
KBDB_GRAPH_URL?: string;
};
// 重新 export Cloudflare Workers ExecutionContext 以便其他 module 用