refactor(cypher): 第一刀拆分——UI 搬 CF Pages,ingest 從必爆變穩定
根因(leo 2026-07-21 三個判斷全被數字證實): - 「cypher 展開上萬行,當時我就懷疑這樣跑得動嗎」→ 15,446 行、bundle 748KB - 「不相信 CF 連讀文字檔都會撞牆」→ 不是 CF 的問題,是 cypher 太肥 - 「它違反了樂高化的原則」→ 零件 4KB,調度它們的 cypher 是巨石 定位錯誤(比效能更根本):console.ts:1 自陳「Mira Console」,而 wiki 明載 「Mira 是 Arcrun 的使用者,不是開發者」→ 使用者的前端寫進了框架的執行引擎。 CLI/MCP 都已拆成獨立薄殼,唯獨 UI 沒有。責任誠實記:console.ts 標 「2026-07-04 總管派工」,是總管當初選了「就近寫在 cypher」的方便路。 本刀: - console.ts(1623行) + portal-ui.ts(1390行) 移出 → console-ui/ CF Pages 專案 (單檔 HTML + 原生 JS,零 build step,保持原特性) - index.ts 移除兩個路由掛載、修 implicit any - console-dashboard.ts 只留 API(UI 部分移出) 實測驗收(leo 指定判準:不看 /health,看真實任務): | 測試 | 拆分前 | 拆分後 | |---|---|---| | 連灌 10 張新卡(無間隔) | 從未成功 | 10 張全 create、73 條三元組 | | 同卡連跑 6 次 | 前 2 過、後 4 全 1102 | 6 次全過 | | Bundle | 747.9 KB | 528.0 KB(-29%) | Pages 已上線:arcrun-console-ui.pages.dev(console/portal 皆 200) cypher API 正常:/health、/console/dashboard-data 皆 200 未做(第二刀):console-dashboard/portal/portal-data 尚未拆,cypher 仍 528KB。 但 ingest 已穩定,急迫性下降。 註:部署撞到三個既有 self-hosted 陷阱(KV/D1 id 是官方帳號的、arcrun.dev route leo21c 無該 zone),用 wrangler.leo21c.toml 繞過,未改原始設定檔。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -51,3 +51,4 @@ backup-*.sql
|
||||
|
||||
# GitHub 公開 mirror 工作目錄(publish-github.sh 產物)
|
||||
.github-public/
|
||||
wrangler.leo21c.toml
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "arcrun-console-ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Arcrun Console / Portal 靜態前端(Cloudflare Pages)——從 cypher-executor 搬出的 UI 層",
|
||||
"scripts": {
|
||||
"build": "node scripts/build.mjs",
|
||||
"deploy": "npm run build && wrangler pages deploy public --project-name arcrun-console-ui",
|
||||
"preview": "npm run build && npx serve public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Arcrun UI runtime 組態——改這一行就能切 API 目標,不必重新 build。
|
||||
window.ARCRUN_CONFIG = { apiBase: "" };
|
||||
@@ -0,0 +1,295 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-Hant">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Arcrun 駕駛艙</title>
|
||||
<script>
|
||||
// 主題預載(防閃色):預設淺色(leo 2026-07-04 明示),與 /console 共用同一 localStorage key
|
||||
document.documentElement.setAttribute('data-theme', (function () {
|
||||
try { return localStorage.getItem('arcrun_console_theme') === 'dark' ? 'dark' : 'light'; } catch (e) { return 'light'; }
|
||||
})());
|
||||
</script>
|
||||
<style>
|
||||
/* Mira Console 定稿視覺(紙感「2a」,Mira Style Guide 2026-07-04):
|
||||
紙紋底 repeating-linear-gradient、明體標題級聯、琥珀強調、呼吸狀態球嵌單字。
|
||||
2026-07-04 二輪:CSS custom properties 兩份色板——預設淺色(宣紙米白+墨字),深色=原定稿暖黑不動。 */
|
||||
* { box-sizing: border-box; }
|
||||
:root {
|
||||
--paper-a: #f4eddc; --paper-b: #f1e9d6;
|
||||
--ink: #2f2a20; --ink-rgb: 30,24,14;
|
||||
--amber: #8a5f1e; --amber-rgb: 138,95,30;
|
||||
--ok: #1d7a48; --ok-rgb: 29,122,72;
|
||||
--err: #b03a26; --err-rgb: 176,58,38;
|
||||
--track: rgba(30,24,14,.12);
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--paper-a: #191410; --paper-b: #1b1611;
|
||||
--ink: #ede4d3; --ink-rgb: 237,228,211;
|
||||
--amber: #e8b45a; --amber-rgb: 232,180,90;
|
||||
--ok: #7fe0a8; --ok-rgb: 63,190,120;
|
||||
--err: #e58575; --err-rgb: 217,95,76;
|
||||
--track: rgba(255,255,255,.08);
|
||||
}
|
||||
html, body { margin: 0; background: repeating-linear-gradient(0deg,var(--paper-a) 0px,var(--paper-a) 3px,var(--paper-b) 3px,var(--paper-b) 4px); color: var(--ink);
|
||||
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(var(--amber-rgb),.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(var(--ink-rgb),.5); }
|
||||
.pagehead .date { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 14px; color: rgba(var(--ink-rgb),.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(var(--ink-rgb),.6); line-height: 1.55; }
|
||||
@keyframes breatheGreen { 0%,100% { box-shadow: 0 0 24px 6px rgba(var(--ok-rgb),.35); } 50% { box-shadow: 0 0 42px 14px rgba(var(--ok-rgb),.55); } }
|
||||
@keyframes breatheAmber { 0%,100% { box-shadow: 0 0 24px 6px rgba(var(--amber-rgb),.35); } 50% { box-shadow: 0 0 42px 14px rgba(var(--amber-rgb),.6); } }
|
||||
@keyframes breatheRed { 0%,100% { box-shadow: 0 0 24px 6px rgba(var(--err-rgb),.4); } 50% { box-shadow: 0 0 44px 16px rgba(var(--err-rgb),.65); } }
|
||||
.bricks { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.brick { padding: 16px; border-radius: 12px; }
|
||||
.brick.amber { background: rgba(var(--amber-rgb),.07); border: 1px solid rgba(var(--amber-rgb),.22); }
|
||||
.brick.plain { background: rgba(var(--ink-rgb),.04); border: 1px solid rgba(var(--ink-rgb),.14); }
|
||||
.brick .lbl { font-size: 13.5px; color: rgba(var(--ink-rgb),.55); margin-bottom: 6px; }
|
||||
.brick .num { font-family: ui-monospace, Menlo, monospace; font-size: 26px; color: var(--amber); }
|
||||
.brick .num small { font-size: 15px; color: rgba(var(--ink-rgb),.5); }
|
||||
.bar { margin-top: 10px; height: 6px; border-radius: 3px; background: var(--track); }
|
||||
.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(var(--ok-rgb),.3); background: rgba(var(--ok-rgb),.05); }
|
||||
.wait-box.has { border-color: rgba(var(--amber-rgb),.45); background: rgba(var(--amber-rgb),.05); }
|
||||
.wait-head { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 16px; letter-spacing: .2em; color: rgba(var(--ink-rgb),.6); margin-bottom: 10px; text-align: center; }
|
||||
.wait-none { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 20px; color: var(--ok); 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(var(--amber-rgb),.1); border: 1px solid rgba(var(--amber-rgb),.3); font-size: 16px; line-height: 1.5; }
|
||||
.wait-item .dm { color: var(--amber); font-size: 17px; flex: none; }
|
||||
.wait-meta { margin-top: 10px; text-align: center; font-size: 12.5px; color: rgba(var(--ink-rgb),.45); line-height: 1.7; }
|
||||
.wait-meta .warn { color: var(--err); }
|
||||
.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(var(--ink-rgb),.6); }
|
||||
.subhead .m { font-size: 13px; color: rgba(var(--ink-rgb),.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(var(--ink-rgb),.045); border: 1px solid transparent; font-size: 16px; line-height: 1.4; }
|
||||
ul.route li.doing { background: rgba(var(--amber-rgb),.09); border-color: rgba(var(--amber-rgb),.3); }
|
||||
ul.route li .ic { flex: none; font-size: 15px; margin-top: 2px; }
|
||||
ul.route li.done { color: rgba(var(--ink-rgb),.65); }
|
||||
ul.route li.done .ic { color: var(--ok); }
|
||||
ul.route li.doing .ic { color: var(--amber); }
|
||||
ul.route li.todo { color: rgba(var(--ink-rgb),.6); }
|
||||
ul.route li.todo .ic { color: rgba(var(--ink-rgb),.35); }
|
||||
ul.route li.blocked .ic { color: var(--err); }
|
||||
ul.route.faded li { opacity: .55; }
|
||||
.sys { margin-top: 6px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.sys .row { display: flex; justify-content: space-between; align-items: baseline; padding: 10px 14px; border-radius: 10px; background: rgba(var(--ink-rgb),.04); border: 1px solid rgba(var(--ink-rgb),.12); font-size: 14.5px; }
|
||||
.sys .row .k { color: rgba(var(--ink-rgb),.6); }
|
||||
.sys .row .v { font-family: ui-monospace, Menlo, monospace; font-size: 14px; }
|
||||
.sys .ok { color: var(--ok); }
|
||||
.sys .bad { color: var(--err); }
|
||||
.sys .off { color: rgba(var(--ink-rgb),.5); }
|
||||
.muted { color: rgba(var(--ink-rgb),.45); font-size: 14px; }
|
||||
.err { color: var(--err); font-size: 14px; }
|
||||
.stamp { margin: 16px 0 8px; text-align: center; font-size: 12.5px; color: rgba(var(--ink-rgb),.35); line-height: 1.8; }
|
||||
.enter { display: block; text-align: center; font-size: 13.5px; color: rgba(var(--amber-rgb),.75); text-decoration: none; margin-top: 6px; }
|
||||
.theme-btn { flex: none; margin-left: 12px; width: 34px; height: 34px; border-radius: 50%; border: 1px solid rgba(var(--ink-rgb),.25); background: none; color: rgba(var(--ink-rgb),.65); font-size: 16px; cursor: pointer; line-height: 1; align-self: center; }
|
||||
</style>
|
||||
<script src="/config.js"></script>
|
||||
<script>window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase) || "";</script>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="pagehead">
|
||||
<div class="title serif">Arcrun<small> 駕駛艙</small></div>
|
||||
<div style="display:flex;align-items:baseline">
|
||||
<div class="date serif" id="date-str"></div>
|
||||
<button class="theme-btn" id="theme-btn" title="切換深/淺色">☾</button>
|
||||
</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="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="wait-box" id="wait-box">
|
||||
<div class="wait-head">等你的事</div>
|
||||
<div id="wait-body" class="wait-none">載入中…</div>
|
||||
<div class="wait-meta" id="wait-meta"></div>
|
||||
</div>
|
||||
<div class="subhead"><span class="t">今日路線</span><span class="m" id="route-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="subhead"><span class="t">系統狀況</span><span class="m">live 健康信號</span></div>
|
||||
<div class="sys" id="sys-list"><div class="row"><span class="k">載入中…</span></div></div>
|
||||
<div class="stamp" id="stamp">每 60 秒自動刷新</div>
|
||||
<a class="enter" href="/console">進入完整控制台 ›</a>
|
||||
</main>
|
||||
<script>
|
||||
(function () {
|
||||
var API_BASE = window.ARCRUN_API_BASE || '';
|
||||
// 台北時間 helper(lib/taipei-time.ts 注入,與 server 判定同一套——顯示不隨看的裝置時區漂移)
|
||||
var TAIPEI_OFFSET_MS = 28800000; // UTC+8,台北無 DST
|
||||
function tpePad(n) { n = String(n); return n.length < 2 ? '0' + n : n; }
|
||||
function taipeiDayKey(ms) { return new Date(ms + TAIPEI_OFFSET_MS).toISOString().slice(0, 10); }
|
||||
function taipeiDateStr(ms) { return taipeiDayKey(ms); }
|
||||
function taipeiDateTimeStr(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return taipeiDayKey(ms) + ' ' + tpePad(d.getUTCHours()) + ':' + tpePad(d.getUTCMinutes()); }
|
||||
function taipeiTimeStr(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return tpePad(d.getUTCHours()) + ':' + tpePad(d.getUTCMinutes()) + ':' + tpePad(d.getUTCSeconds()); }
|
||||
function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { month: d.getUTCMonth() + 1, day: d.getUTCDate() }; }
|
||||
const $ = (id) => document.getElementById(id);
|
||||
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
function taskLine(t) {
|
||||
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>';
|
||||
}
|
||||
function humanAge(m) {
|
||||
if (m == null || m < 0) return '時間不明';
|
||||
if (m < 60) return m + ' 分鐘前';
|
||||
if (m < 2880) return Math.round(m / 60) + ' 小時前';
|
||||
return Math.round(m / 1440) + ' 天前';
|
||||
}
|
||||
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 nowTpe = taipeiMonthDay(Date.now());
|
||||
$('date-str').textContent = CNUM[nowTpe.month] + '月' + cnDay(nowTpe.day) + '日';
|
||||
// 深/淺切換(與 /console 共用 arcrun_console_theme;預設淺色)
|
||||
function syncThemeBtn() { $('theme-btn').textContent = document.documentElement.getAttribute('data-theme') === 'dark' ? '☀' : '☾'; }
|
||||
$('theme-btn').addEventListener('click', () => {
|
||||
const next = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', next);
|
||||
try { localStorage.setItem('arcrun_console_theme', next); } catch (e) { /* 私密模式忽略 */ }
|
||||
syncThemeBtn();
|
||||
});
|
||||
syncThemeBtn();
|
||||
// fetch 失敗(斷網)的裸訊息 → 友善誠實文案;60 秒定時器常駐,網路恢復自動刷回
|
||||
function friendlyErr(e) {
|
||||
const m = e && e.message ? String(e.message) : String(e);
|
||||
return /failed to fetch|load failed|networkerror|network request failed/i.test(m) ? '連線中斷' : m;
|
||||
}
|
||||
function sysRow(k, v, cls) {
|
||||
return '<div class="row"><span class="k">' + esc(k) + '</span><span class="v ' + cls + '">' + esc(v) + '</span></div>';
|
||||
}
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch(API_BASE + '/console/dashboard-data');
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
const d = await res.json();
|
||||
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 : '')
|
||||
: '尚無心跳資料') + (d.light !== 'green' && d.light_reason ? '(' + d.light_reason + ')' : '');
|
||||
const done = d.today_done || 0, total = d.today_total || 0;
|
||||
$('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;
|
||||
// ── 等你的事:來源 + 維護時間攤開講,stale 一定警示 ──
|
||||
const wb = $('wait-box'), body = $('wait-body'), wmeta = $('wait-meta');
|
||||
const wm = d.waiting_meta || {};
|
||||
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">' + (w.urgency ? esc(w.urgency) : '◆') + '</span><span>' +
|
||||
(w.id ? '<b>#' + esc(w.id) + '</b> ' : '') + esc(w.title) + '</span></div>').join('');
|
||||
} else {
|
||||
wb.classList.remove('has');
|
||||
body.className = 'wait-none';
|
||||
body.textContent = wm.source === 'none' ? '(管線未接)' : '無,你不用做任何事';
|
||||
}
|
||||
let metaTxt = '';
|
||||
if (wm.source === 'gitea_sprint') {
|
||||
metaTxt = '來源:sprint 等leo清單(' + esc((wm.sprint_files || []).join('、')) + ')・清單維護於 ' + humanAge(wm.updated_ago_minutes);
|
||||
if (wm.stale) metaTxt += '<br><span class="warn">⚠ 清單超過 2 天沒維護,可能過時</span>';
|
||||
} else if (wm.source === 'kbdb_dash_wait') {
|
||||
metaTxt = '<span class="warn">⚠ ' + esc(wm.note || 'dash_wait 殘資料') + '・上次寫入 ' + humanAge(wm.updated_ago_minutes) + ',可能過時</span>';
|
||||
} else {
|
||||
metaTxt = '<span class="warn">管線未接:Gitea sprint 清單與 dash_wait 皆無資料</span>';
|
||||
}
|
||||
wmeta.innerHTML = metaTxt;
|
||||
// ── 今日路線:sprint 任務板優先(來源攤開講);dash_task fallback 沿舊誠實降級 ──
|
||||
const rm = d.route_meta || {};
|
||||
const today = (d.tasks || []).filter((t) => t.scope === 'today');
|
||||
const week = (d.tasks || []).filter((t) => t.scope === 'week');
|
||||
if (rm.source === 'gitea_sprint_board') {
|
||||
$('route-m').textContent = '來源 sprint 任務板・更新於 ' + humanAge(rm.updated_ago_minutes);
|
||||
$('today-list').className = 'route';
|
||||
const staleHead = rm.is_today ? '' :
|
||||
'<li class="todo"><span class="ic">○</span><span class="muted">⚠ 今日任務板未更新(最後 ' + humanAge(rm.updated_ago_minutes) + ')——以下是板上現況</span></li>';
|
||||
$('today-list').innerHTML = staleHead + (today.length
|
||||
? today.map(taskLine).join('')
|
||||
: '<li class="todo"><span class="ic">○</span><span class="muted">任務板上沒有可解析的事項</span></li>');
|
||||
} else if (rm.is_today) {
|
||||
$('route-m').textContent = '更新於 ' + humanAge(rm.updated_ago_minutes);
|
||||
$('today-list').className = 'route';
|
||||
$('today-list').innerHTML = today.length ? today.map(taskLine).join('') : '<li class="todo"><span class="ic">○</span><span class="muted">今日無排定項目</span></li>';
|
||||
} else if (today.length) {
|
||||
$('route-m').textContent = '最後路線・' + humanAge(rm.updated_ago_minutes) + '寫入';
|
||||
$('today-list').className = 'route faded';
|
||||
$('today-list').innerHTML =
|
||||
'<li class="todo"><span class="ic">○</span><span class="muted">今日尚無路線寫入——以下是 ' + humanAge(rm.updated_ago_minutes) +
|
||||
'的殘留路線(sprint 任務板→dashboard 投影管線未接,等leo清單#15 裁決中)</span></li>' + today.map(taskLine).join('');
|
||||
} else {
|
||||
$('route-m').textContent = '';
|
||||
$('today-list').className = 'route';
|
||||
$('today-list').innerHTML = '<li class="todo"><span class="ic">○</span><span class="muted">無資料——dash_task 管線未接</span></li>';
|
||||
}
|
||||
$('week-head').style.display = week.length ? '' : 'none';
|
||||
$('week-list').innerHTML = week.map(taskLine).join('');
|
||||
// ── 系統狀況 + 總庫規模(全 live,讀不到就標讀不到)──
|
||||
const sys = d.system || {}, kb = d.kb || {};
|
||||
const rows = [];
|
||||
rows.push(sysRow('KBDB 基本盤', sys.kbdb_ok ? '● 正常' : '● 打不通', sys.kbdb_ok ? 'ok' : 'bad'));
|
||||
if (sys.embed) {
|
||||
rows.push(sys.embed.enabled
|
||||
? sysRow('語意嵌入', '● 啟用(已嵌 ' + (sys.embed.embedded ?? '?') + '・待嵌 ' + (sys.embed.pending ?? '?') + ')', 'ok')
|
||||
: sysRow('語意嵌入', '○ 停用(已嵌 ' + (sys.embed.embedded ?? '?') + '・待嵌 ' + (sys.embed.pending ?? '?') + ')', 'bad'));
|
||||
} else {
|
||||
rows.push(sysRow('語意嵌入', '狀態讀不到', 'off'));
|
||||
}
|
||||
rows.push(sys.graph && sys.graph.ok
|
||||
? sysRow('知識圖譜', '● 正常・三元組 ' + (sys.graph.triplets == null ? '?' : sys.graph.triplets), 'ok')
|
||||
: sysRow('知識圖譜', '● 打不通', 'bad'));
|
||||
rows.push(sysRow('工作流', sys.workflow_total == null ? '讀不到' : sys.workflow_total + ' 條', sys.workflow_total == null ? 'off' : ''));
|
||||
// 精耕層 wiki 卡(leo 2026-07-07 裁:14-E 遺產總數 deprecated 不再顯示,只顯示真的新的;
|
||||
// 三元組/已嵌入 已各有一列)
|
||||
rows.push(sysRow('精耕層 wiki 卡', kb.wiki_card_total == null ? '讀不到' : kb.wiki_card_total + ' 張', kb.wiki_card_total == null ? 'off' : ''));
|
||||
$('sys-list').innerHTML = rows.join('');
|
||||
$('stamp').innerHTML = '每 60 秒自動刷新・上次 ' + esc(taipeiTimeStr(Date.parse(d.generated_at))) + '(台北)<br>此頁不含機敏內容,免登入';
|
||||
} catch (e) {
|
||||
$('orb-char').textContent = '?';
|
||||
$('orb-title').textContent = '讀不到狀態';
|
||||
$('orb-sub').innerHTML = '<span class="err">' + esc(friendlyErr(e)) + '・每 60 秒自動重試</span>';
|
||||
}
|
||||
}
|
||||
load();
|
||||
setInterval(load, 60000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,91 +1,9 @@
|
||||
/**
|
||||
* Mira Console 完整版(Arcrun#3 console 系,2026-07-04 總管派工)
|
||||
*
|
||||
* 視覺:claude design 定稿「紙感暖黑 2a」(system-dev/docs/6-user/Mira Console 設計規劃/
|
||||
* Mira Console.dc.html + Mira Style Guide.dc.html)——紙紋底 repeating-linear-gradient、
|
||||
* 明體標題(Songti TC 級聯)、琥珀 var(--amber) 強調、墨綠呼吸球嵌「安」字、正文 ≥16px、
|
||||
* 手機優先 390px + ≥1024px 側欄、頁面本體不出水平捲軸。
|
||||
*
|
||||
* 資訊架構:brief 8 頁(docs/1-vision/mira-console-design-brief.md)=
|
||||
* 1 登入/首次設定 2 駕駛艙 3 總庫搜尋 4 卡片詳頁+關聯 5 工作流 6 憑證管理
|
||||
* 7 分流台(原收件匣,Arcrun#9 改裝) 8 設定。
|
||||
* 單檔 HTML + 原生 JS hash routing(#/cockpit …),零外部資源、零 build step。
|
||||
*
|
||||
* 薄殼(rule 07):零業務邏輯,只 fetch 既有 API 渲染。各頁資料源(誠實原則,無源的顯示
|
||||
* 誠實空狀態,禁假資料):
|
||||
* - 駕駛艙:GET /console/dashboard-data(免登入聚合端點,console-dashboard.ts)
|
||||
* - 總庫搜尋:GET /kbdb/search(mode=semantic 未開 Vectorize 會誠實降級 + capability_hint;
|
||||
* chips 用 entry_type——KBDB 真能篩的欄位,不編造「電腦筆記/手機筆記」等不存在的分類);
|
||||
* 頭部統計來自 GET /console/kb-scale-data(精耕層 wiki 卡/三元組/已嵌入——leo 2026-07-07
|
||||
* 裁:45.8 萬 14-E 搬遷遺產 deprecated 不再當招牌數字;搜尋本身仍搜全庫);
|
||||
* 搜尋框上方=藏書地圖(library-map SDD M5/R4,Arcrun#39):GET /kbdb/map 全館庫卡片
|
||||
* (narrative+top entities+triplet 數+更新時間感),逐庫 GET /kbdb/map/:library 補
|
||||
* degree/跨庫 bridges/commit_hash(詳圖拉不到就維持名字版,不擋渲染);點庫卡片=
|
||||
* 設 library filter(/kbdb/search 既有 library 參數)進該庫搜尋;/map 空陣列或錯誤=
|
||||
* 誠實空狀態(指引 POST /map/recompute backfill),不擋搜尋等其他功能
|
||||
* - 卡片詳頁:GET /kbdb/entries/:id;關聯視圖走 GET /kbdb/graph/neighbors/:name
|
||||
* (cypher 代轉 kbdb-graph-plugin,kbdb-proxy.ts;查無 triplet → 「尚無關聯資料」)
|
||||
* - 工作流:GET /webhooks/named(list)+ POST /webhooks/named/:name/trigger(手動觸發)
|
||||
* + GET /workflows/:name/executions(最近執行);釘選存 localStorage
|
||||
* - 憑證:GET /credentials/catalog(D1 目錄唯讀,絕不含密文)+ POST /credentials(新增)
|
||||
* + PUT /credentials/:name(整筆替換);刪除後端未接 D1(T9 範圍)→ 標「即將開通」
|
||||
* - 分流台:GET /console/triage-data(需 session——待辦/訊息原文屬機敏,計數才免登入)。
|
||||
* 資料源二合一(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)
|
||||
* + /console/setup/reset 換帳密 + 系統資訊(精耕層規模)
|
||||
*
|
||||
* 2026-07-07 fix/console-truth-audit(總管稽核派工 + leo 裁決):
|
||||
* 1. 全站日期/時間顯示統一 Asia/Taipei(lib/taipei-time.ts,與駕駛艙 #36 台北日判定同套;
|
||||
* 原本用瀏覽器本地 getter,換裝置就換日期;憑證頁加顯時分——7/6 22:57 建立不再被誤讀)。
|
||||
* 2. 總庫搜尋/設定頁頭部統計改精耕層(458,732 是真數字——cypher 與 base 一手同值實證——
|
||||
* 但 leo 裁 14-E 遺產 deprecated 不再顯示)。
|
||||
* 3. 駕駛艙「今日完成/今日路線」接 sprint 任務板真資料(dashboard-data 端,同 #36 模式)。
|
||||
*
|
||||
* 認證模型沿 v1(console-auth.ts):單一管理員 email+password,session token 在 localStorage,
|
||||
* 實際打 /kbdb/* 用後端回的固定租戶字串(CONSOLE_TENANT)。駕駛艙免登入版在 /console/dashboard。
|
||||
*
|
||||
* v0 的 components/recipes 查詢區(Arcrun#3 原搜尋台)收進「工作流」頁下方折疊區,不砍功能。
|
||||
*
|
||||
* 2026-07-04 leo 實測三回饋(總管派工二輪):
|
||||
* 1. 深/淺主題:CSS custom properties 兩份色板(:root=淺色紙感宣紙米白+墨字,
|
||||
* :root[data-theme=dark]=原定稿暖黑不動),預設淺色,選擇存 localStorage
|
||||
* (key arcrun_console_theme),head 預載腳本防閃色。切換入口=登入/首次設定頁小鈕、
|
||||
* 側欄項、設定頁正式開關。金琥珀例外(按鈕漸層/狀態球/toast/gcenter)兩版不變。
|
||||
* 2. 登入/首次設定置中:.authwrap.view.on 補 display:flex(原被 .view.on 的 block 蓋掉 → 靠左)。
|
||||
* 3. fetch 韌性:friendlyErr() 把裸 Failed to fetch / Load failed 轉「連線中斷」誠實文案;
|
||||
* 駕駛艙 60 秒定時器常駐,網路恢復自動刷回。
|
||||
*/
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { TAIPEI_CLIENT_JS } from '../lib/taipei-time';
|
||||
|
||||
export const consoleRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
function renderConsoleHtml(registryBase: string, brand: string, profile: string): string {
|
||||
// Console profile(config 裁剪,非 auth——#24/#25 多人 Portal 是另一回事):
|
||||
// full(未設即此)=全套,Mira 實例現行為一字不變。
|
||||
// rag=企業產品樣:只留 總庫搜尋/工作流/設定(card 是搜尋的詳頁,跟著留),落地=搜尋頁。
|
||||
// 隱藏頁的 view div 仍在 DOM(display:none、路由 guard 導回,永遠點不進)——
|
||||
// 因 creds/inbox 有 top-level addEventListener 綁定,抽掉 div 會讓整包 JS 啟動即炸,
|
||||
// 純渲染層裁剪以「導不進去」為準,不為了抽 DOM 把 null-guard 灑滿全檔。
|
||||
const rag = profile === 'rag';
|
||||
const views = rag
|
||||
? ['search', 'card', 'workflows', 'settings']
|
||||
: ['cockpit', 'search', 'card', 'workflows', 'creds', 'inbox', 'settings'];
|
||||
const home = rag ? 'search' : 'cockpit';
|
||||
return `<!doctype html>
|
||||
<!doctype html>
|
||||
<html lang="zh-Hant">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${brand} Console</title>
|
||||
<title>Arcrun Console</title>
|
||||
<script>
|
||||
// 主題預載(防閃色):預設淺色(leo 2026-07-04 明示),選擇存 localStorage
|
||||
document.documentElement.setAttribute('data-theme', (function () {
|
||||
@@ -301,6 +219,8 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
.switch.on i { transform: translateX(24px); }
|
||||
.kvline { display: flex; justify-content: space-between; gap: 12px; font-size: 15px; margin: 5px 0; }
|
||||
</style>
|
||||
<script src="/config.js"></script>
|
||||
<script>window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase) || "";</script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -308,7 +228,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
<div class="authwrap view" id="v-login">
|
||||
<div class="authbox">
|
||||
<div>
|
||||
<div class="brand">${brand}</div>
|
||||
<div class="brand">Arcrun</div>
|
||||
<div class="brand-sub">私人系統・僅供擁有者進入</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:12px;text-align:left">
|
||||
@@ -318,7 +238,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
<div id="login-status" class="err" style="font-size:14px;min-height:1.2em"></div>
|
||||
</div>
|
||||
<div style="font-size:13.5px;color:rgba(var(--ink-rgb),.4);line-height:1.7">這是一個人的智慧總部。<br>若你不是擁有者,這裡沒有你要找的東西。</div>
|
||||
${rag ? '' : '<a href="/console/dashboard" style="font-size:13.5px;color:rgba(var(--amber-rgb),.7)">免登入看駕駛艙 ›</a>'}
|
||||
<a href="/console/dashboard" style="font-size:13.5px;color:rgba(var(--amber-rgb),.7)">免登入看駕駛艙 ›</a>
|
||||
<button class="btn3 themelabel" data-themetoggle style="align-self:center;padding:8px 16px;font-size:13.5px;border-radius:999px">☾ 切深色</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -327,7 +247,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
<div class="authwrap view" id="v-setup">
|
||||
<div class="authbox" style="max-width:440px;text-align:left">
|
||||
<div style="text-align:center">
|
||||
<div class="brand" style="font-size:38px">${brand}</div>
|
||||
<div class="brand" style="font-size:38px">Arcrun</div>
|
||||
<div class="serif" style="margin-top:12px;font-size:21px">首次設定</div>
|
||||
<div style="margin-top:8px;font-size:15px;color:rgba(var(--ink-rgb),.55);line-height:1.7">系統偵測到尚未設定擁有者帳號。<br>設定一組 Email 與密碼,之後只有你能進入。</div>
|
||||
</div>
|
||||
@@ -345,12 +265,12 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
<!-- 主應用 -->
|
||||
<div id="shell">
|
||||
<div id="sidenav">
|
||||
<div class="logo">${brand}</div>
|
||||
${rag ? '' : '<div class="nav" data-nav="cockpit">駕駛艙</div>'}
|
||||
<div class="logo">Arcrun</div>
|
||||
<div class="nav" data-nav="cockpit">駕駛艙</div>
|
||||
<div class="nav" data-nav="search">總庫搜尋</div>
|
||||
<div class="nav" data-nav="workflows">工作流</div>
|
||||
${rag ? '' : `<div class="nav" data-nav="creds">憑證管理</div>
|
||||
<div class="nav" data-nav="inbox">分流台</div>`}
|
||||
<div class="nav" data-nav="creds">憑證管理</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>
|
||||
@@ -359,7 +279,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
|
||||
<!-- 駕駛艙 -->
|
||||
<div class="view page" id="v-cockpit">
|
||||
<div class="pagehead"><span class="t">${brand}<span style="font-size:14px;letter-spacing:.3em;color:rgba(var(--ink-rgb),.5)"> 控制台</span></span><span class="m serif" id="ck-date"></span></div>
|
||||
<div class="pagehead"><span class="t">Arcrun<span style="font-size:14px;letter-spacing:.3em;color:rgba(var(--ink-rgb),.5)"> 控制台</span></span><span class="m serif" id="ck-date"></span></div>
|
||||
<div class="cockgrid">
|
||||
<div>
|
||||
<div class="orb-row">
|
||||
@@ -522,13 +442,13 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
<div id="st-pw-status" style="font-size:14px;min-height:1.2em"></div>
|
||||
</div>
|
||||
</div>
|
||||
${rag ? '' : `<div class="panel" style="display:flex;align-items:center;gap:12px;cursor:pointer" data-nav="creds">
|
||||
<div class="panel" style="display:flex;align-items:center;gap:12px;cursor:pointer" data-nav="creds">
|
||||
<div>
|
||||
<div style="font-size:17px;font-weight:600">憑證管理</div>
|
||||
<div style="margin-top:4px;font-size:14px;color:rgba(var(--ink-rgb),.55)">只保存目錄與加密值,不可回看</div>
|
||||
</div>
|
||||
<span style="margin-left:auto;color:rgba(var(--amber-rgb),.6);font-size:18px">›</span>
|
||||
</div>`}
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div style="font-size:17px;font-weight:600;margin-bottom:12px">系統資訊</div>
|
||||
<div id="st-info"><div class="muted">載入中…</div></div>
|
||||
@@ -542,10 +462,10 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
|
||||
<!-- 手機底部導覽 -->
|
||||
<div id="tabbar">
|
||||
${rag ? '' : '<div class="tab" data-nav="cockpit">駕駛艙</div>'}
|
||||
<div class="tab" data-nav="cockpit">駕駛艙</div>
|
||||
<div class="tab" data-nav="search">搜尋</div>
|
||||
<div class="tab" data-nav="workflows">工作流</div>
|
||||
${rag ? '' : '<div class="tab" data-nav="inbox">分流台</div>'}
|
||||
<div class="tab" data-nav="inbox">分流台</div>
|
||||
<div class="tab" data-nav="settings">設定</div>
|
||||
</div>
|
||||
|
||||
@@ -553,11 +473,18 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var API_BASE = window.ARCRUN_API_BASE || '';
|
||||
'use strict';
|
||||
// 台北時間 helper(lib/taipei-time.ts 注入,與駕駛艙 #36 台北日判定同一套)——
|
||||
// console 所有日期/時間顯示固定 Asia/Taipei,不隨看的裝置時區漂移
|
||||
${TAIPEI_CLIENT_JS}
|
||||
var REGISTRY_BASE = ${JSON.stringify(registryBase)};
|
||||
var TAIPEI_OFFSET_MS = 28800000; // UTC+8,台北無 DST
|
||||
function tpePad(n) { n = String(n); return n.length < 2 ? '0' + n : n; }
|
||||
function taipeiDayKey(ms) { return new Date(ms + TAIPEI_OFFSET_MS).toISOString().slice(0, 10); }
|
||||
function taipeiDateStr(ms) { return taipeiDayKey(ms); }
|
||||
function taipeiDateTimeStr(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return taipeiDayKey(ms) + ' ' + tpePad(d.getUTCHours()) + ':' + tpePad(d.getUTCMinutes()); }
|
||||
function taipeiTimeStr(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return tpePad(d.getUTCHours()) + ':' + tpePad(d.getUTCMinutes()) + ':' + tpePad(d.getUTCSeconds()); }
|
||||
function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { month: d.getUTCMonth() + 1, day: d.getUTCDate() }; }
|
||||
var REGISTRY_BASE = "https://registry.arcrun.dev";
|
||||
var $ = function (id) { return document.getElementById(id); };
|
||||
|
||||
// ── 狀態 ──
|
||||
@@ -617,7 +544,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
function parseAtMs(v) {
|
||||
if (v == null || v === '') return null;
|
||||
if (typeof v === 'number') return v < 1e12 ? v * 1000 : v;
|
||||
if (/^\\d+$/.test(v)) { var n = Number(v); return n < 1e12 ? n * 1000 : n; }
|
||||
if (/^\d+$/.test(v)) { var n = Number(v); return n < 1e12 ? n * 1000 : n; }
|
||||
var s = /T/.test(v) ? v : String(v).replace(' ', 'T') + 'Z';
|
||||
var ms = Date.parse(s);
|
||||
return isNaN(ms) ? null : ms;
|
||||
@@ -637,31 +564,31 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
// ── 極簡 Markdown 渲染(先全 escape 再上格式,防 XSS)──
|
||||
function mdInline(s) {
|
||||
return s
|
||||
.replace(/\\*\\*([^*]+)\\*\\*/g, '<b>$1</b>')
|
||||
.replace(/(^|[^\\\\])\\x60([^\\x60]+)\\x60/g, '$1<code>$2</code>');
|
||||
.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>')
|
||||
.replace(/(^|[^\\])\x60([^\x60]+)\x60/g, '$1<code>$2</code>');
|
||||
}
|
||||
function mdRender(src) {
|
||||
var lines = String(src || '').split(/\\r?\\n/);
|
||||
var lines = String(src || '').split(/\r?\n/);
|
||||
var out = [], inCode = false, listMode = '';
|
||||
function closeList() { if (listMode) { out.push(listMode === 'ul' ? '</ul>' : '</ol>'); listMode = ''; } }
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var raw = lines[i];
|
||||
if (/^\\s*\\x60\\x60\\x60/.test(raw)) {
|
||||
if (/^\s*\x60\x60\x60/.test(raw)) {
|
||||
closeList();
|
||||
if (inCode) { out.push('</code></pre>'); inCode = false; }
|
||||
else { out.push('<pre><code>'); inCode = true; }
|
||||
continue;
|
||||
}
|
||||
if (inCode) { out.push(esc(raw) + '\\n'); continue; }
|
||||
if (inCode) { out.push(esc(raw) + '\n'); continue; }
|
||||
var line = esc(raw);
|
||||
var h = raw.match(/^(#{1,4})\\s+(.*)$/);
|
||||
var h = raw.match(/^(#{1,4})\s+(.*)$/);
|
||||
if (h) { closeList(); out.push('<h' + h[1].length + '>' + mdInline(esc(h[2])) + '</h' + h[1].length + '>'); continue; }
|
||||
if (/^\\s*>\\s?/.test(line)) { closeList(); out.push('<blockquote>' + mdInline(line.replace(/^\\s*>\\s?/, '')) + '</blockquote>'); continue; }
|
||||
var li = raw.match(/^\\s*[-*+]\\s+(.*)$/);
|
||||
if (/^\s*>\s?/.test(line)) { closeList(); out.push('<blockquote>' + mdInline(line.replace(/^\s*>\s?/, '')) + '</blockquote>'); continue; }
|
||||
var li = raw.match(/^\s*[-*+]\s+(.*)$/);
|
||||
if (li) { if (listMode !== 'ul') { closeList(); out.push('<ul>'); listMode = 'ul'; } out.push('<li>' + mdInline(esc(li[1])) + '</li>'); continue; }
|
||||
var oli = raw.match(/^\\s*\\d+[.)]\\s+(.*)$/);
|
||||
var oli = raw.match(/^\s*\d+[.)]\s+(.*)$/);
|
||||
if (oli) { if (listMode !== 'ol') { closeList(); out.push('<ol>'); listMode = 'ol'; } out.push('<li>' + mdInline(esc(oli[1])) + '</li>'); continue; }
|
||||
if (/^\\s*$/.test(raw)) { closeList(); continue; }
|
||||
if (/^\s*$/.test(raw)) { closeList(); continue; }
|
||||
closeList();
|
||||
out.push('<p>' + mdInline(line) + '</p>');
|
||||
}
|
||||
@@ -673,10 +600,10 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
// ── 路由 ──
|
||||
// VIEWS / HOME 由 server 依 CONSOLE_PROFILE 注入(full=全套 7 頁落地駕駛艙;rag=4 頁落地搜尋)。
|
||||
// 不在 VIEWS 裡的 hash(含被 profile 裁掉的頁)一律導回 HOME——手打 #/cockpit 也進不去。
|
||||
var VIEWS = ${JSON.stringify(views)};
|
||||
var HOME = ${JSON.stringify(home)};
|
||||
var VIEWS = ["cockpit","search","card","workflows","creds","inbox","settings"];
|
||||
var HOME = "cockpit";
|
||||
function currentRoute() {
|
||||
var h = location.hash.replace(/^#\\/?/, '');
|
||||
var h = location.hash.replace(/^#\/?/, '');
|
||||
var seg = h.split('/');
|
||||
return { raw: seg[0] || '', view: VIEWS.indexOf(seg[0]) >= 0 ? seg[0] : HOME, arg: seg.slice(1).join('/') };
|
||||
}
|
||||
@@ -724,7 +651,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
}
|
||||
function init() {
|
||||
if (S.token) {
|
||||
fetch('/console/session', { headers: { Authorization: 'Bearer ' + S.token } })
|
||||
fetch(API_BASE + '/console/session', { headers: { Authorization: 'Bearer ' + S.token } })
|
||||
.then(function (r) { return r.ok ? r.json() : Promise.reject(new Error('session 失效')); })
|
||||
.then(function (d) { S.tenant = d.tenant || ''; showApp(); })
|
||||
.catch(function () {
|
||||
@@ -736,7 +663,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
}
|
||||
}
|
||||
function checkConfigured() {
|
||||
fetch('/console/auth-status')
|
||||
fetch(API_BASE + '/console/auth-status')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) { showAuth(d.configured ? 'login' : 'setup'); })
|
||||
.catch(function () { showAuth('login'); });
|
||||
@@ -750,7 +677,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
$('login-submit').addEventListener('click', function () {
|
||||
var email = $('login-email').value.trim(), password = $('login-password').value;
|
||||
$('login-status').textContent = '';
|
||||
fetch('/console/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email, password: password }) })
|
||||
fetch(API_BASE + '/console/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email, password: password }) })
|
||||
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
|
||||
.then(function (x) { if (!x.ok) { $('login-status').textContent = x.d.error || '登入失敗'; return; } handleAuthResponse(x.d); })
|
||||
.catch(function (e) { $('login-status').textContent = '請求失敗:' + friendlyErr(e); });
|
||||
@@ -760,7 +687,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
var email = $('setup-email').value.trim(), pw = $('setup-password').value, pw2 = $('setup-password2').value;
|
||||
if (pw !== pw2) { $('setup-status').textContent = '兩次密碼不一致'; return; }
|
||||
$('setup-status').textContent = '';
|
||||
fetch('/console/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email, password: pw }) })
|
||||
fetch(API_BASE + '/console/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email, password: pw }) })
|
||||
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
|
||||
.then(function (x) { if (!x.ok) { $('setup-status').textContent = x.d.error || '設定失敗'; return; } handleAuthResponse(x.d); })
|
||||
.catch(function (e) { $('setup-status').textContent = '請求失敗:' + friendlyErr(e); });
|
||||
@@ -788,7 +715,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
function loadCockpit() {
|
||||
var nowTpe = taipeiMonthDay(Date.now()); // 頁首日期=台北日,不吃裝置時區
|
||||
$('ck-date').textContent = CNUM[nowTpe.month] + '月' + cnDay(nowTpe.day) + '日';
|
||||
fetch('/console/dashboard-data')
|
||||
fetch(API_BASE + '/console/dashboard-data')
|
||||
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
||||
.then(function (d) {
|
||||
var cfg = LIGHT[d.light] || LIGHT.green;
|
||||
@@ -930,7 +857,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
}).join('<span style="width:6px"></span>') + '</div></div>';
|
||||
}
|
||||
function loadLibraryMap() {
|
||||
fetch('/kbdb/map', { headers: apiHeaders() })
|
||||
fetch(API_BASE + '/kbdb/map', { headers: apiHeaders() })
|
||||
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||||
.then(function (x) {
|
||||
if (!x.ok || x.d.success === false) {
|
||||
@@ -945,7 +872,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
LM.libs = libs; LM.details = {};
|
||||
renderLmCards(libs, {});
|
||||
Promise.allSettled(libs.map(function (l) {
|
||||
return fetch('/kbdb/map/' + encodeURIComponent(l.library), { headers: apiHeaders() })
|
||||
return fetch(API_BASE + '/kbdb/map/' + encodeURIComponent(l.library), { headers: apiHeaders() })
|
||||
.then(function (r) { return r.ok ? r.json() : null; });
|
||||
})).then(function (rs) {
|
||||
var details = {};
|
||||
@@ -999,7 +926,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
// 頭部統計=精耕層 live 數字(leo 2026-07-07 裁:45.8 萬 14-E 搬遷遺產已 deprecated、
|
||||
// 之後要刪——不再拿遺產總數撐場面,只顯示真的新的)。搜尋本身仍搜全庫,資料不藏。
|
||||
function loadScale() {
|
||||
fetch('/console/kb-scale-data')
|
||||
fetch(API_BASE + '/console/kb-scale-data')
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (d) {
|
||||
if (!d) return;
|
||||
@@ -1019,14 +946,14 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
$('se-q').addEventListener('keydown', function (ev) { if (ev.key === 'Enter') doSearch(); });
|
||||
function entryTitle(e) {
|
||||
if (e.page_name) return e.page_name;
|
||||
var first = String(e.content || '').split(/\\r?\\n/).find(function (l) { return l.trim(); }) || '';
|
||||
first = first.replace(/^#+\\s*/, '').replace(/^[-*>]\\s*/, '').trim();
|
||||
var first = String(e.content || '').split(/\r?\n/).find(function (l) { return l.trim(); }) || '';
|
||||
first = first.replace(/^#+\s*/, '').replace(/^[-*>]\s*/, '').trim();
|
||||
if (first.length > 60) first = first.slice(0, 60) + '…';
|
||||
return first || '(無標題・' + (e.entry_type || 'entry') + ')';
|
||||
}
|
||||
function entrySnippet(e) {
|
||||
var lines = String(e.content || '').split(/\\r?\\n/).filter(function (l) { return l.trim(); });
|
||||
var body = lines.slice(1).join(' ').replace(/\\s+/g, ' ').trim();
|
||||
var lines = String(e.content || '').split(/\r?\n/).filter(function (l) { return l.trim(); });
|
||||
var body = lines.slice(1).join(' ').replace(/\s+/g, ' ').trim();
|
||||
if (!body) body = lines.join(' ');
|
||||
if (body.length > 140) body = body.slice(0, 140) + '…';
|
||||
return body;
|
||||
@@ -1037,7 +964,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
$('se-banner').innerHTML = '';
|
||||
$('se-count').textContent = '查詢中…';
|
||||
$('se-results').innerHTML = '';
|
||||
var url = '/kbdb/search?q=' + encodeURIComponent(q) + '&mode=' + (S.semantic ? 'semantic' : 'keyword');
|
||||
var url = API_BASE + '/kbdb/search?q=' + encodeURIComponent(q) + '&mode=' + (S.semantic ? 'semantic' : 'keyword');
|
||||
if (S.chip) url += '&entry_type=' + encodeURIComponent(S.chip);
|
||||
// 藏書地圖進庫(M5/R4):library filter=/kbdb/search 既有參數(portal-auth P1 順延項),不新造
|
||||
if (S.library) url += '&library=' + encodeURIComponent(S.library);
|
||||
@@ -1081,7 +1008,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
function loadCard() {
|
||||
if (!S.cardId) { $('cd-main').innerHTML = '<div class="muted" style="padding:24px 0">沒有指定卡片——從「總庫搜尋」點一張進來。</div>'; renderGraphEmpty('尚未載入卡片'); return; }
|
||||
$('cd-main').innerHTML = '<div class="muted" style="padding:24px 0">載入中…</div>';
|
||||
fetch('/kbdb/entries/' + encodeURIComponent(S.cardId), { headers: apiHeaders() })
|
||||
fetch(API_BASE + '/kbdb/entries/' + encodeURIComponent(S.cardId), { headers: apiHeaders() })
|
||||
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
|
||||
.then(function (x) {
|
||||
if (!x.ok || !x.d.entry) { $('cd-main').innerHTML = '<div class="err" style="padding:24px 0">' + esc(x.d.error || '讀不到這張卡片') + '</div>'; renderGraphEmpty('讀不到卡片'); return; }
|
||||
@@ -1109,7 +1036,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
}
|
||||
function loadNeighbors(name) {
|
||||
renderGraphEmpty('查詢關聯中…');
|
||||
fetch('/kbdb/graph/neighbors/' + encodeURIComponent(name), { headers: apiHeaders() })
|
||||
fetch(API_BASE + '/kbdb/graph/neighbors/' + encodeURIComponent(name), { headers: apiHeaders() })
|
||||
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||||
.then(function (x) {
|
||||
if (!x.ok) { renderGraphEmpty(x.d.error || ('關聯查詢失敗(HTTP ' + x.status + ')')); return; }
|
||||
@@ -1152,7 +1079,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
|
||||
// ── 工作流 ──
|
||||
function loadWorkflows() {
|
||||
fetch('/webhooks/named', { headers: apiHeaders() })
|
||||
fetch(API_BASE + '/webhooks/named', { headers: apiHeaders() })
|
||||
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
|
||||
.then(function (x) {
|
||||
if (!x.ok) { $('wf-list').innerHTML = '<div class="err">' + esc(x.d.error || '讀取失敗') + '</div>'; return; }
|
||||
@@ -1229,7 +1156,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
function fetchWfRuns(name) {
|
||||
var box = document.getElementById('wf-runs');
|
||||
if (!box) return;
|
||||
fetch('/workflows/' + encodeURIComponent(name) + '/executions?limit=3', { headers: apiHeaders() })
|
||||
fetch(API_BASE + '/workflows/' + encodeURIComponent(name) + '/executions?limit=3', { headers: apiHeaders() })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
var runs = (d.data && d.data.executions) || [];
|
||||
@@ -1247,7 +1174,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
function triggerWf(name, btn) {
|
||||
btn.disabled = true; btn.textContent = '執行中…';
|
||||
var msg = document.getElementById('wf-runmsg');
|
||||
fetch('/webhooks/named/' + encodeURIComponent(name) + '/trigger', {
|
||||
fetch(API_BASE + '/webhooks/named/' + encodeURIComponent(name) + '/trigger', {
|
||||
method: 'POST',
|
||||
headers: Object.assign({ 'Content-Type': 'application/json' }, apiHeaders()),
|
||||
body: '{}'
|
||||
@@ -1279,7 +1206,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
out.innerHTML = '<div class="muted">查詢中…</div>';
|
||||
Promise.allSettled([
|
||||
fetch(REGISTRY_BASE + '/components/search?q=' + encodeURIComponent(q)).then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); }),
|
||||
fetch('/public-recipes?q=' + encodeURIComponent(q)).then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
|
||||
fetch(API_BASE + '/public-recipes?q=' + encodeURIComponent(q)).then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
|
||||
]).then(function (rs) {
|
||||
var html = '';
|
||||
var comp = rs[0];
|
||||
@@ -1320,9 +1247,9 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
var name = $('cred-name').value.trim(), service = $('cred-service').value.trim(), value = $('cred-secret').value;
|
||||
var st = $('cred-form-status');
|
||||
if (!name || !value) { st.innerHTML = '<span class="err">名稱與密文值必填</span>'; return; }
|
||||
if (!/^\\w+$/.test(name)) { st.innerHTML = '<span class="err">名稱只能包含英文字母、數字和底線</span>'; return; }
|
||||
if (!/^\w+$/.test(name)) { st.innerHTML = '<span class="err">名稱只能包含英文字母、數字和底線</span>'; return; }
|
||||
st.textContent = '保存中…';
|
||||
fetch('/credentials', {
|
||||
fetch(API_BASE + '/credentials', {
|
||||
method: 'POST',
|
||||
headers: Object.assign({ 'Content-Type': 'application/json' }, apiHeaders()),
|
||||
body: JSON.stringify({ name: name, value: value, service: service || undefined })
|
||||
@@ -1341,7 +1268,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
var credOpen = '';
|
||||
var credCache = [];
|
||||
function loadCreds() {
|
||||
fetch('/credentials/catalog', { headers: apiHeaders() })
|
||||
fetch(API_BASE + '/credentials/catalog', { headers: apiHeaders() })
|
||||
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
|
||||
.then(function (x) {
|
||||
if (!x.ok || x.d.success === false) {
|
||||
@@ -1389,7 +1316,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
if (!val.trim()) { if (msg) msg.innerHTML = '<span class="err">先貼上新值</span>'; return; }
|
||||
rep.disabled = true;
|
||||
var cur = credCache.find(function (c) { return c.name === name; });
|
||||
fetch('/credentials/' + encodeURIComponent(name), {
|
||||
fetch(API_BASE + '/credentials/' + encodeURIComponent(name), {
|
||||
method: 'PUT',
|
||||
headers: Object.assign({ 'Content-Type': 'application/json' }, apiHeaders()),
|
||||
body: JSON.stringify({ value: val, service: cur && cur.service ? cur.service : undefined })
|
||||
@@ -1484,7 +1411,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
// 狀態重繪(該筆立即移出/回到三欄),失敗誠實 toast、按鈕解鎖可重試。
|
||||
function triageAct(id, action, btn) {
|
||||
btn.disabled = true;
|
||||
fetch('/console/triage-check', {
|
||||
fetch(API_BASE + '/console/triage-check', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + S.token },
|
||||
body: JSON.stringify({ entry_id: id, action: action })
|
||||
@@ -1510,7 +1437,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
function loadTriage() {
|
||||
var out = $('tg-out');
|
||||
out.innerHTML = '<div class="muted" style="padding:14px 0">載入中…</div>';
|
||||
fetch('/console/triage-data', { headers: { Authorization: 'Bearer ' + S.token } })
|
||||
fetch(API_BASE + '/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) {
|
||||
@@ -1528,7 +1455,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
// ── 設定 ──
|
||||
function loadSettings() {
|
||||
// vectorize 狀態:探測既有 /kbdb/search 的 mode / capability_hint(無新端點,誠實讀降級訊號)
|
||||
fetch('/kbdb/search?q=mira&mode=semantic', { headers: apiHeaders() })
|
||||
fetch(API_BASE + '/kbdb/search?q=mira&mode=semantic', { headers: apiHeaders() })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
var on = d.mode === 'semantic';
|
||||
@@ -1543,7 +1470,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
$('st-vec-state').textContent = '偵測失敗';
|
||||
});
|
||||
// MCP token TTL(誠實佔位:只顯示目前生效值,不假裝能改)
|
||||
fetch('/console/settings-data')
|
||||
fetch(API_BASE + '/console/settings-data')
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (d) {
|
||||
if (!d) { $('st-mcp-ttl').textContent = '讀取失敗'; return; }
|
||||
@@ -1554,8 +1481,8 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
.catch(function () { $('st-mcp-ttl').textContent = '讀取失敗'; });
|
||||
// 系統資訊:worker 自報 + 精耕層規模(14-E 遺產總數不再顯示,leo 2026-07-07 裁)
|
||||
Promise.allSettled([
|
||||
fetch('/').then(function (r) { return r.json(); }),
|
||||
fetch('/console/kb-scale-data').then(function (r) { return r.ok ? r.json() : null; })
|
||||
fetch(API_BASE + '/').then(function (r) { return r.json(); }),
|
||||
fetch(API_BASE + '/console/kb-scale-data').then(function (r) { return r.ok ? r.json() : null; })
|
||||
]).then(function (rs) {
|
||||
var svc = rs[0].status === 'fulfilled' ? rs[0].value : {};
|
||||
var kb = rs[1].status === 'fulfilled' ? rs[1].value : null;
|
||||
@@ -1576,7 +1503,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
if (!email) { st.innerHTML = '<span class="err">請輸入 Email(沿用原本的也要填)</span>'; return; }
|
||||
if (!newPw || newPw.length < 8) { st.innerHTML = '<span class="err">新密碼至少 8 碼</span>'; return; }
|
||||
st.textContent = '更新中…';
|
||||
fetch('/console/setup/reset', {
|
||||
fetch(API_BASE + '/console/setup/reset', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ current_password: oldPw, email: email, password: newPw })
|
||||
@@ -1595,7 +1522,7 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
});
|
||||
$('st-logout').addEventListener('click', function () {
|
||||
var t = S.token;
|
||||
if (t) fetch('/console/logout', { method: 'POST', headers: { Authorization: 'Bearer ' + t } }).catch(function () {});
|
||||
if (t) fetch(API_BASE + '/console/logout', { method: 'POST', headers: { Authorization: 'Bearer ' + t } }).catch(function () {});
|
||||
S.token = ''; S.tenant = '';
|
||||
localStorage.removeItem('arcrun_console_session');
|
||||
location.hash = '';
|
||||
@@ -1607,17 +1534,3 @@ function renderConsoleHtml(registryBase: string, brand: string, profile: string)
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
// GET /console — Mira Console 完整版(Arcrun#3 console 系)。registry base 現算:同帳號
|
||||
// arcrun-registry worker(WORKER_SUBDOMAIN 沿用既有 KBDB_BASE_URL 那套組法),沒設就退回官方公開 registry。
|
||||
consoleRouter.get('/console', (c) => {
|
||||
const subdomain = c.env.WORKER_SUBDOMAIN;
|
||||
const registryBase = subdomain
|
||||
? `https://arcrun-registry.${subdomain}.workers.dev`
|
||||
: 'https://registry.arcrun.dev';
|
||||
// 品牌字樣(Arcrun#21):引擎預設 Arcrun,實例可用 CONSOLE_BRAND 覆蓋(如 "Arcrun RAG")
|
||||
// 頁面組成(console-profile-trim):CONSOLE_PROFILE 未設=full 全套;"rag"=企業產品樣(搜尋落地)
|
||||
return c.html(renderConsoleHtml(registryBase, c.env.CONSOLE_BRAND || 'Arcrun', c.env.CONSOLE_PROFILE || 'full'));
|
||||
});
|
||||
@@ -1,44 +1,9 @@
|
||||
/**
|
||||
* RAG Portal 前端殼 — P3:GET /portal 單檔 HTML(portal-auth design §1 D-1/§6,Gitea #24/#25)
|
||||
*
|
||||
* 形態(D-1):cypher-executor 的新路由、**獨立 HTML 殼**——不在 console 上加 if(「Admin
|
||||
* Console 現狀不動」鐵律)。樣式重用 console 的設計語言(紙感/明體標題/琥珀強調/深淺主題),
|
||||
* 取捨=**複製樣式子集而非抽共用模組**:抽共用要動 console.ts(違「console 不動」),且兩頁
|
||||
* 受眾不同(owner vs 同仁)預期各自演化;代價=樣式雙份、改版要兩邊同步(PR 說明誠實記)。
|
||||
*
|
||||
* 頁面(design §6,leo 頁面級拍板):
|
||||
* - 登入頁(brand=CONSOLE_BRAND;未登入只見這個殼)
|
||||
* - 搜尋頁(落地頁):keyword/semantic/graph 三模式+結果含 source 溯源+卡片詳頁
|
||||
* - 設定頁:改自己密碼、看自己角色與可查庫、主題切換
|
||||
* - 工作流頁:D-8 定案 admin 可見(PORTAL_SHOW_WORKFLOWS 可調 all/off);唯讀、不開 trigger
|
||||
* - graph 模式按 D-4 粗閘顯示(/portal/session 的 graph_allowed)
|
||||
* - 管理頁(P4,admin-only nav):帳號管理(列表/新增/停用/重設密碼+每帳號勾選可查庫
|
||||
* 含 ["*"] 全庫)+庫目錄管理(登記/停用/graph_source 標記)。一次性密碼只在畫面顯示
|
||||
* 一次(server 不留明碼);一般用戶 nav 不顯示、直打 /portal/admin/* 也是 403(role 閘)。
|
||||
* - Mira 專屬頁(駕駛艙/分流台/憑證/專案)一律不進
|
||||
*
|
||||
* 安全(design §3.3 關鍵差異 vs console):
|
||||
* - 本頁 JS **沒有任何租戶字串、沒有 X-Arcrun-API-Key**——只持 portal session token
|
||||
* (localStorage `arcrun_portal_session`),一切資料走 /portal/data/*(server-side enforce)。
|
||||
* - 前端的「藏」(graph 模式不顯示、工作流頁不顯示)只是 UX;真閘在 /portal/data/* 路由層
|
||||
* (403/404),curl 直打也繞不過(#48 guard 精神)。
|
||||
* - 所有動態內容經 esc() 跳脫(防 XSS,同 console 慣例);密碼欄位值不進 log、不進 URL。
|
||||
*
|
||||
* 薄殼(rule 07):零業務邏輯,只 fetch /portal/* 端點渲染。
|
||||
*/
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { TAIPEI_CLIENT_JS } from '../lib/taipei-time';
|
||||
|
||||
export const portalUiRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
function renderPortalHtml(brand: string, sourceWebBase = ''): string {
|
||||
return `<!doctype html>
|
||||
<!doctype html>
|
||||
<html lang="zh-Hant">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${brand} Portal</title>
|
||||
<title>Arcrun Portal</title>
|
||||
<script>
|
||||
// 主題預載(防閃色):預設淺色,選擇存 localStorage(與 console 同設計語言、不同 key)
|
||||
document.documentElement.setAttribute('data-theme', (function () {
|
||||
@@ -198,6 +163,8 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
|
||||
.switch.on i { transform: translateX(24px); }
|
||||
.kvline { display: flex; justify-content: space-between; gap: 12px; font-size: 15px; margin: 5px 0; }
|
||||
</style>
|
||||
<script src="/config.js"></script>
|
||||
<script>window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase) || "";</script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -205,7 +172,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
|
||||
<div class="authwrap view" id="v-login">
|
||||
<div class="authbox">
|
||||
<div>
|
||||
<div class="brand">${brand}</div>
|
||||
<div class="brand">Arcrun</div>
|
||||
<div class="brand-sub">知識入口・Portal</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:12px;text-align:left">
|
||||
@@ -222,7 +189,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
|
||||
<!-- 主應用 -->
|
||||
<div id="shell">
|
||||
<div id="sidenav">
|
||||
<div class="logo">${brand}</div>
|
||||
<div class="logo">Arcrun</div>
|
||||
<div class="nav" data-nav="search">搜尋</div>
|
||||
<div class="nav hide" id="nav-map" data-nav="map">總圖</div>
|
||||
<div class="nav hide" id="nav-upload" data-nav="upload">上傳</div>
|
||||
@@ -400,9 +367,16 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var API_BASE = window.ARCRUN_API_BASE || '';
|
||||
'use strict';
|
||||
// 台北時間 helper(lib/taipei-time.ts 注入,console 同套——顯示不隨裝置時區漂移)
|
||||
${TAIPEI_CLIENT_JS}
|
||||
var TAIPEI_OFFSET_MS = 28800000; // UTC+8,台北無 DST
|
||||
function tpePad(n) { n = String(n); return n.length < 2 ? '0' + n : n; }
|
||||
function taipeiDayKey(ms) { return new Date(ms + TAIPEI_OFFSET_MS).toISOString().slice(0, 10); }
|
||||
function taipeiDateStr(ms) { return taipeiDayKey(ms); }
|
||||
function taipeiDateTimeStr(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return taipeiDayKey(ms) + ' ' + tpePad(d.getUTCHours()) + ':' + tpePad(d.getUTCMinutes()); }
|
||||
function taipeiTimeStr(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return tpePad(d.getUTCHours()) + ':' + tpePad(d.getUTCMinutes()) + ':' + tpePad(d.getUTCSeconds()); }
|
||||
function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { month: d.getUTCMonth() + 1, day: d.getUTCDate() }; }
|
||||
var $ = function (id) { return document.getElementById(id); };
|
||||
|
||||
// ── 狀態:只有 session token;**沒有租戶字串、沒有 API key**(design §3.3)──
|
||||
@@ -455,7 +429,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
|
||||
function parseAtMs(v) {
|
||||
if (v == null || v === '') return null;
|
||||
if (typeof v === 'number') return v < 1e12 ? v * 1000 : v;
|
||||
if (/^\\d+$/.test(v)) { var n = Number(v); return n < 1e12 ? n * 1000 : n; }
|
||||
if (/^\d+$/.test(v)) { var n = Number(v); return n < 1e12 ? n * 1000 : n; }
|
||||
var s = /T/.test(v) ? v : String(v).replace(' ', 'T') + 'Z';
|
||||
var ms = Date.parse(s);
|
||||
return isNaN(ms) ? null : ms;
|
||||
@@ -466,31 +440,31 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
|
||||
// ── 極簡 Markdown 渲染(先全 escape 再上格式,防 XSS;console 同款)──
|
||||
function mdInline(s) {
|
||||
return s
|
||||
.replace(/\\*\\*([^*]+)\\*\\*/g, '<b>$1</b>')
|
||||
.replace(/(^|[^\\\\])\\x60([^\\x60]+)\\x60/g, '$1<code>$2</code>');
|
||||
.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>')
|
||||
.replace(/(^|[^\\])\x60([^\x60]+)\x60/g, '$1<code>$2</code>');
|
||||
}
|
||||
function mdRender(src) {
|
||||
var lines = String(src || '').split(/\\r?\\n/);
|
||||
var lines = String(src || '').split(/\r?\n/);
|
||||
var out = [], inCode = false, listMode = '';
|
||||
function closeList() { if (listMode) { out.push(listMode === 'ul' ? '</ul>' : '</ol>'); listMode = ''; } }
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var raw = lines[i];
|
||||
if (/^\\s*\\x60\\x60\\x60/.test(raw)) {
|
||||
if (/^\s*\x60\x60\x60/.test(raw)) {
|
||||
closeList();
|
||||
if (inCode) { out.push('</code></pre>'); inCode = false; }
|
||||
else { out.push('<pre><code>'); inCode = true; }
|
||||
continue;
|
||||
}
|
||||
if (inCode) { out.push(esc(raw) + '\\n'); continue; }
|
||||
if (inCode) { out.push(esc(raw) + '\n'); continue; }
|
||||
var line = esc(raw);
|
||||
var h = raw.match(/^(#{1,4})\\s+(.*)$/);
|
||||
var h = raw.match(/^(#{1,4})\s+(.*)$/);
|
||||
if (h) { closeList(); out.push('<h' + h[1].length + '>' + mdInline(esc(h[2])) + '</h' + h[1].length + '>'); continue; }
|
||||
if (/^\\s*>\\s?/.test(line)) { closeList(); out.push('<blockquote>' + mdInline(line.replace(/^\\s*>\\s?/, '')) + '</blockquote>'); continue; }
|
||||
var li = raw.match(/^\\s*[-*+]\\s+(.*)$/);
|
||||
if (/^\s*>\s?/.test(line)) { closeList(); out.push('<blockquote>' + mdInline(line.replace(/^\s*>\s?/, '')) + '</blockquote>'); continue; }
|
||||
var li = raw.match(/^\s*[-*+]\s+(.*)$/);
|
||||
if (li) { if (listMode !== 'ul') { closeList(); out.push('<ul>'); listMode = 'ul'; } out.push('<li>' + mdInline(esc(li[1])) + '</li>'); continue; }
|
||||
var oli = raw.match(/^\\s*\\d+[.)]\\s+(.*)$/);
|
||||
var oli = raw.match(/^\s*\d+[.)]\s+(.*)$/);
|
||||
if (oli) { if (listMode !== 'ol') { closeList(); out.push('<ol>'); listMode = 'ol'; } out.push('<li>' + mdInline(esc(oli[1])) + '</li>'); continue; }
|
||||
if (/^\\s*$/.test(raw)) { closeList(); continue; }
|
||||
if (/^\s*$/.test(raw)) { closeList(); continue; }
|
||||
closeList();
|
||||
out.push('<p>' + mdInline(line) + '</p>');
|
||||
}
|
||||
@@ -506,23 +480,23 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
|
||||
function entrySource(e) { var m = entryMeta(e); return typeof m.source === 'string' ? m.source : ''; }
|
||||
// 來源回溯超連結:PORTAL_SOURCE_WEB_BASE 有設且 source 是 gitea:// 才給 href;
|
||||
// 其餘回空字串=維持純文字(行為與未設時一字不變)。chunk 錨點(#n)捨棄。
|
||||
var SOURCE_WEB_BASE = ${JSON.stringify(sourceWebBase)};
|
||||
var SOURCE_WEB_BASE = "";
|
||||
function srcHref(src) {
|
||||
if (!SOURCE_WEB_BASE || String(src).indexOf('gitea://') !== 0) return '';
|
||||
var p = String(src).slice(8).replace(/#\\d+$/, '');
|
||||
return SOURCE_WEB_BASE.replace(/\\/+$/, '') + '/' + p.split('/').map(encodeURIComponent).join('/');
|
||||
var p = String(src).slice(8).replace(/#\d+$/, '');
|
||||
return SOURCE_WEB_BASE.replace(/\/+$/, '') + '/' + p.split('/').map(encodeURIComponent).join('/');
|
||||
}
|
||||
function entryLib(e) { var m = entryMeta(e); return (typeof m.library === 'string' && m.library) ? m.library : 'general'; }
|
||||
function entryTitle(e) {
|
||||
if (e.page_name) return e.page_name;
|
||||
var first = String(e.content || '').split(/\\r?\\n/).find(function (l) { return l.trim(); }) || '';
|
||||
first = first.replace(/^#+\\s*/, '').replace(/^[-*>]\\s*/, '').trim();
|
||||
var first = String(e.content || '').split(/\r?\n/).find(function (l) { return l.trim(); }) || '';
|
||||
first = first.replace(/^#+\s*/, '').replace(/^[-*>]\s*/, '').trim();
|
||||
if (first.length > 60) first = first.slice(0, 60) + '…';
|
||||
return first || '(無標題・' + (e.entry_type || 'entry') + ')';
|
||||
}
|
||||
function entrySnippet(e) {
|
||||
var lines = String(e.content || '').split(/\\r?\\n/).filter(function (l) { return l.trim(); });
|
||||
var body = lines.slice(1).join(' ').replace(/\\s+/g, ' ').trim();
|
||||
var lines = String(e.content || '').split(/\r?\n/).filter(function (l) { return l.trim(); });
|
||||
var body = lines.slice(1).join(' ').replace(/\s+/g, ' ').trim();
|
||||
if (!body) body = lines.join(' ');
|
||||
if (body.length > 140) body = body.slice(0, 140) + '…';
|
||||
return body;
|
||||
@@ -542,7 +516,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
|
||||
});
|
||||
}
|
||||
function currentRoute() {
|
||||
var h = location.hash.replace(/^#\\/?/, '');
|
||||
var h = location.hash.replace(/^#\/?/, '');
|
||||
var seg = h.split('/');
|
||||
var ok = allowedViews();
|
||||
return { raw: seg[0] || '', view: ok.indexOf(seg[0]) >= 0 ? seg[0] : HOME, arg: seg.slice(1).join('/') };
|
||||
@@ -607,7 +581,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
|
||||
}
|
||||
function boot() {
|
||||
if (!S.token) { showAuth(); return; }
|
||||
fetch('/portal/session', { headers: authHeaders() })
|
||||
fetch(API_BASE + '/portal/session', { headers: authHeaders() })
|
||||
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
|
||||
.then(function (x) {
|
||||
if (!x.ok) { dropSession(); return; }
|
||||
@@ -628,7 +602,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
|
||||
if (!email || !password) { $('login-status').textContent = '請輸入 Email 與密碼'; return; }
|
||||
$('login-submit').disabled = true;
|
||||
$('login-status').textContent = '';
|
||||
fetch('/portal/login', {
|
||||
fetch(API_BASE + '/portal/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email, password: password })
|
||||
@@ -645,7 +619,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
|
||||
.catch(function (e) { $('login-submit').disabled = false; $('login-status').textContent = friendlyErr(e); });
|
||||
}
|
||||
$('st-logout').addEventListener('click', function () {
|
||||
fetch('/portal/logout', { method: 'POST', headers: authHeaders() }).catch(function () { /* 盡力而為 */ });
|
||||
fetch(API_BASE + '/portal/logout', { method: 'POST', headers: authHeaders() }).catch(function () { /* 盡力而為 */ });
|
||||
dropSession();
|
||||
});
|
||||
|
||||
@@ -674,7 +648,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
|
||||
$('se-results').style.display = '';
|
||||
$('se-count').textContent = '查詢中…';
|
||||
$('se-results').innerHTML = '';
|
||||
var url = '/portal/data/search?q=' + encodeURIComponent(q) + '&mode=' + (S.mode === 'semantic' ? 'semantic' : 'keyword');
|
||||
var url = API_BASE + '/portal/data/search?q=' + encodeURIComponent(q) + '&mode=' + (S.mode === 'semantic' ? 'semantic' : 'keyword');
|
||||
fetch(url, { headers: authHeaders() })
|
||||
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||||
.then(function (x) {
|
||||
@@ -719,7 +693,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
|
||||
if (!q) { toast('請先輸入問題'); return; }
|
||||
$('ai-go').disabled = true;
|
||||
$('ai-out').innerHTML = '<div class="muted" style="margin-top:12px">AI 查閱知識庫中…</div>';
|
||||
fetch('/portal/data/chat?question=' + encodeURIComponent(q), { headers: authHeaders() })
|
||||
fetch(API_BASE + '/portal/data/chat?question=' + encodeURIComponent(q), { headers: authHeaders() })
|
||||
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||||
.then(function (x) {
|
||||
$('ai-go').disabled = false;
|
||||
@@ -768,7 +742,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
|
||||
$('se-graphout').style.display = '';
|
||||
$('se-count').textContent = '查詢關聯中…';
|
||||
renderGraphEmpty('查詢關聯中…');
|
||||
fetch('/portal/data/graph/neighbors/' + encodeURIComponent(name), { headers: authHeaders() })
|
||||
fetch(API_BASE + '/portal/data/graph/neighbors/' + encodeURIComponent(name), { headers: authHeaders() })
|
||||
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||||
.then(function (x) {
|
||||
if (guard401(x.status)) return;
|
||||
@@ -831,7 +805,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
|
||||
function loadCard() {
|
||||
if (!S.cardId) { $('cd-main').innerHTML = '<div class="muted">沒有指定卡片——從「搜尋」點一張進來。</div>'; return; }
|
||||
$('cd-main').innerHTML = '<div class="muted">載入中…</div>';
|
||||
fetch('/portal/data/entries/' + encodeURIComponent(S.cardId), { headers: authHeaders() })
|
||||
fetch(API_BASE + '/portal/data/entries/' + encodeURIComponent(S.cardId), { headers: authHeaders() })
|
||||
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||||
.then(function (x) {
|
||||
if (guard401(x.status)) return;
|
||||
@@ -873,7 +847,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
|
||||
$('map-md-link').innerHTML = SOURCE_WEB_BASE
|
||||
? ':<a href="' + esc(SOURCE_WEB_BASE + '/system-dev/wiki/00-MAP.md') + '" target="_blank" rel="noopener" style="color:var(--amber)">00-MAP.md ↗</a>'
|
||||
: '存在知識庫的 00-MAP.md';
|
||||
fetch('/portal/data/graph/overview', { headers: authHeaders() })
|
||||
fetch(API_BASE + '/portal/data/graph/overview', { headers: authHeaders() })
|
||||
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||||
.then(function (x) {
|
||||
if (guard401(x.status)) return;
|
||||
@@ -979,7 +953,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
|
||||
// ── 工作流(唯讀)──
|
||||
function loadWorkflows() {
|
||||
$('wf-list').innerHTML = '<div class="muted">載入中…</div>';
|
||||
fetch('/portal/data/workflows', { headers: authHeaders() })
|
||||
fetch(API_BASE + '/portal/data/workflows', { headers: authHeaders() })
|
||||
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||||
.then(function (x) {
|
||||
if (guard401(x.status)) return;
|
||||
@@ -1031,7 +1005,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
|
||||
}
|
||||
function uploadOneFile(file) {
|
||||
var row = uploadStatusRow(file.name);
|
||||
if (!/\\.(md|txt)$/i.test(file.name)) {
|
||||
if (!/\.(md|txt)$/i.test(file.name)) {
|
||||
setRowStatus(row, false, '已略過', '只收 .md / .txt 檔。');
|
||||
return;
|
||||
}
|
||||
@@ -1039,7 +1013,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
|
||||
reader.onerror = function () { setRowStatus(row, false, '讀檔失敗', '瀏覽器讀不到這個檔案。'); };
|
||||
reader.onload = function () {
|
||||
setRowStatus(row, null, '上傳中…', '');
|
||||
fetch('/portal/data/upload', {
|
||||
fetch(API_BASE + '/portal/data/upload', {
|
||||
method: 'POST',
|
||||
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
|
||||
body: JSON.stringify({ filename: file.name, content_b64: b64FromDataUrl(reader.result) })
|
||||
@@ -1075,6 +1049,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
|
||||
var adminLibs = []; // 庫目錄快取(渲染每帳號的庫勾選用)
|
||||
|
||||
function adminApi(method, path, body) {
|
||||
path = API_BASE + path;
|
||||
var opt = { method: method, headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()) };
|
||||
if (body !== undefined) opt.body = JSON.stringify(body);
|
||||
return fetch(path, opt).then(function (r) {
|
||||
@@ -1357,7 +1332,7 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
|
||||
if (newPw !== newPw2) { st.textContent = '兩次輸入的新密碼不一致'; return; }
|
||||
$('st-pw-save').disabled = true;
|
||||
st.textContent = '';
|
||||
fetch('/portal/me/password', {
|
||||
fetch(API_BASE + '/portal/me/password', {
|
||||
method: 'POST',
|
||||
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
|
||||
body: JSON.stringify({ current: oldPw, 'new': newPw })
|
||||
@@ -1379,12 +1354,4 @@ function renderPortalHtml(brand: string, sourceWebBase = ''): string {
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// GET /portal — Portal HTML 殼(無 auth:回的是純殼,資料全在 /portal/data/* session 閘後)。
|
||||
// 未 bootstrap 任何 portal_user 時登入必失敗(「尚未啟用」的誠實形態),不影響 console。
|
||||
portalUiRouter.get('/portal', (c) => {
|
||||
const brand = c.env.CONSOLE_BRAND || 'Arcrun';
|
||||
return c.html(renderPortalHtml(brand, c.env.PORTAL_SOURCE_WEB_BASE || ''));
|
||||
});
|
||||
</html>
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* console-ui build — 把 cypher-executor 的三支 UI renderer 在「建置時」跑一次,
|
||||
* 產出純靜態 HTML 到 public/,交給 Cloudflare Pages 託管。
|
||||
*
|
||||
* 為什麼這樣做(cypher-ui-split 第一刀):
|
||||
* 原本 console/portal/dashboard 的 HTML 由 cypher-executor Worker 在「每次請求時」
|
||||
* 用 template literal 組出來 → 5,240 行 UI 字串永遠躺在 Worker bundle 裡(748KB),
|
||||
* 連 /health 這種什麼都不做的請求都要付 5-7ms CPU(免費層上限 10ms)。
|
||||
* UI 是靜態的(單檔 HTML+原生 JS、零外部資源),本來就該待在 Pages。
|
||||
*
|
||||
* 保持原特性(leo 反覆強調簡化):
|
||||
* - 零打包工具、零 npm 依賴:本檔只用 node 內建 fs/path,正則抽出 renderer 的
|
||||
* template literal 後求值。不引入 esbuild/vite/rollup。
|
||||
* - 產出仍是「單檔 HTML+原生 JS hash routing、零外部資源」。
|
||||
*
|
||||
* 唯一的行為差異=API base:
|
||||
* 原本 UI 與 API 同源,fetch 全用相對路徑('/kbdb/search')。搬上 Pages 後跨網域,
|
||||
* 故注入 window.ARCRUN_API_BASE,並把 fetch 的相對路徑改成 API_BASE + path。
|
||||
* 見下方 rewriteFetchPaths()。
|
||||
*/
|
||||
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..');
|
||||
const SRC = join(ROOT, '..', 'cypher-executor', 'src');
|
||||
const OUT = join(ROOT, 'public');
|
||||
|
||||
// ── 建置期組態(原本是 Worker 的 env var,現在是建置參數)────────────────
|
||||
// Pages 是靜態站,沒有 per-request env;品牌/profile 這類「一個部署一個值」的
|
||||
// 設定改在建置時決定(要換值=重跑 build 再部署,符合靜態站模型)。
|
||||
const CFG = {
|
||||
brand: process.env.CONSOLE_BRAND || 'Arcrun',
|
||||
profile: process.env.CONSOLE_PROFILE || 'full',
|
||||
registryBase: process.env.REGISTRY_BASE || 'https://registry.arcrun.dev',
|
||||
sourceWebBase: process.env.PORTAL_SOURCE_WEB_BASE || '',
|
||||
// API base 走 runtime 注入(見 public/config.js),這裡只放預設值
|
||||
apiBase: process.env.ARCRUN_API_BASE || '',
|
||||
};
|
||||
|
||||
/**
|
||||
* 讀 TS 原始碼並取出整個 renderer 函式的**函式主體**(不只 template literal)。
|
||||
*
|
||||
* 取整個 body 而非只取反引號區塊,是因為 renderer 在 return 之前會先算區域變數
|
||||
* (如 console.ts 的 rag/views/home 由 profile 推導)。只搬模板=把那段推導邏輯
|
||||
* 複製一份到本檔=雙份真相會漂移。連 body 一起求值 → 推導邏輯永遠只有一份。
|
||||
*/
|
||||
function extractRendererBody(file, fnName) {
|
||||
const code = readFileSync(join(SRC, file), 'utf8');
|
||||
const start = code.indexOf(`function ${fnName}(`);
|
||||
if (start < 0) throw new Error(`找不到 ${fnName} in ${file}`);
|
||||
const braceStart = code.indexOf('{', code.indexOf(')', start));
|
||||
if (braceStart < 0) throw new Error(`${fnName} 找不到函式主體`);
|
||||
// 掃到配對的收尾大括號;需略過字串/template literal/註解裡的括號
|
||||
let i = braceStart + 1;
|
||||
let depth = 1;
|
||||
let mode = null; // null | "'" | '"' | '`' | 'line' | 'block'
|
||||
let tplDepth = 0;
|
||||
while (i < code.length && depth > 0) {
|
||||
const ch = code[i];
|
||||
const nx = code[i + 1];
|
||||
if (mode === null) {
|
||||
if (ch === '\\') { i += 2; continue; }
|
||||
if (ch === '/' && nx === '/') { mode = 'line'; i += 2; continue; }
|
||||
if (ch === '/' && nx === '*') { mode = 'block'; i += 2; continue; }
|
||||
if (ch === "'" || ch === '"') { mode = ch; i++; continue; }
|
||||
if (ch === '`') { mode = '`'; tplDepth = 0; i++; continue; }
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') depth--;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (mode === 'line') { if (ch === '\n') mode = null; i++; continue; }
|
||||
if (mode === 'block') { if (ch === '*' && nx === '/') { mode = null; i += 2; continue; } i++; continue; }
|
||||
if (ch === '\\') { i += 2; continue; }
|
||||
if (mode === '`') {
|
||||
// template literal 內的 ${ … } 是真程式碼,其中的引號/括號要照常計數才不會誤判收尾
|
||||
if (ch === '$' && nx === '{') { tplDepth++; i += 2; continue; }
|
||||
if (ch === '}' && tplDepth > 0) { tplDepth--; i++; continue; }
|
||||
if (ch === '`' && tplDepth === 0) { mode = null; i++; continue; }
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === mode) mode = null;
|
||||
i++;
|
||||
}
|
||||
// 去掉 TS 的型別註記(本 body 只有 `const x: T =` 這種簡單形態)
|
||||
return code.slice(braceStart + 1, i - 1).replace(/\bconst\s+(\w+):\s*[\w<>[\]|]+\s*=/g, 'const $1 =');
|
||||
}
|
||||
|
||||
/** 取出 lib/taipei-time.ts 匯出的 TAIPEI_CLIENT_JS 字串常數(UI 內嵌的客戶端時間工具)。 */
|
||||
function extractTaipeiClientJs() {
|
||||
const code = readFileSync(join(SRC, 'lib', 'taipei-time.ts'), 'utf8');
|
||||
// 形態=字串陣列 .join('\n')(見 lib/taipei-time.ts),直接求值該陣列表達式
|
||||
const m = code.match(/export const TAIPEI_CLIENT_JS\s*=\s*(\[[\s\S]*?\]\.join\('\\n'\));/);
|
||||
if (!m) throw new Error('找不到 TAIPEI_CLIENT_JS');
|
||||
return new Function(`return ${m[1]};`)();
|
||||
}
|
||||
|
||||
/**
|
||||
* 求值 renderer 函式主體。用 new Function 而非 eval——只餵建置期組態,
|
||||
* 輸入是本 repo 自己的原始碼(非使用者輸入),無注入面。
|
||||
*/
|
||||
function render(body, vars) {
|
||||
const names = Object.keys(vars);
|
||||
const fn = new Function(...names, body);
|
||||
return fn(...names.map((n) => vars[n]));
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 UI 內原生 JS 的相對路徑 fetch 改成打 API base。
|
||||
*
|
||||
* 只改 `fetch('/...` 與 `fetch("/...`(開頭是單斜線=同源絕對路徑)這一種形態,
|
||||
* 其餘(fetch(url, …) 這類變數形式)另由各檔的 url 組法在下面單獨處理。
|
||||
*/
|
||||
function rewriteFetchPaths(html, file) {
|
||||
// ① fetch('/xxx → fetch(API_BASE + '/xxx
|
||||
let out = html.replace(/fetch\((['"])\/(?!\/)/g, 'fetch(API_BASE + $1/');
|
||||
// ② 變數式 fetch(url, ...):url 由上方 var url = '/kbdb/search?...' 組成 →
|
||||
// 把這類「以單斜線開頭的路徑字面值指派」也補上 API_BASE
|
||||
out = out.replace(/(\bvar\s+url\s*=\s*)(['"])\/(?!\/)/g, '$1API_BASE + $2/');
|
||||
// ③ portal 的 adminApi(method, path, body):path 由呼叫端傳字面值進來,①②
|
||||
// 都掃不到(8 個呼叫點)。在 helper 內部補前綴=一處修好全部,不必改 8 個呼叫點。
|
||||
out = out.replace(
|
||||
/(function adminApi\(method, path, body\) \{)/,
|
||||
'$1\n path = API_BASE + path;'
|
||||
);
|
||||
|
||||
// 防呆:搬完後不該再有「直接 fetch 同源相對路徑」的殘留。掃到就讓建置失敗,
|
||||
// 免得漏網的呼叫點在 Pages 上打到 Pages 自己(404)才被發現。
|
||||
// 註:adminApi 的呼叫端仍是相對路徑字面值——那是對的,前綴由 helper 內部(③)加。
|
||||
const unprefixed = [...out.matchAll(/fetch\((['"])\/(?!\/)[^'"]*/g)].map((m) => m[0]);
|
||||
if (unprefixed.length) {
|
||||
throw new Error(
|
||||
`${file}:有 ${unprefixed.length} 個相對路徑 fetch 沒被改寫成 API_BASE:\n ` +
|
||||
[...new Set(unprefixed)].join('\n ')
|
||||
);
|
||||
}
|
||||
// adminApi 形態存在時,必須確認 helper 已被加上前綴(否則 8 個呼叫點全會打錯家)
|
||||
if (/function adminApi\(method, path, body\)/.test(out) && !/path = API_BASE \+ path;/.test(out)) {
|
||||
throw new Error(`${file}:偵測到 adminApi helper 但前綴注入失敗`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 在頁面 <head> 注入 config.js(runtime 決定 API base),並定義 API_BASE 供內嵌 JS 用。 */
|
||||
function injectApiBase(html) {
|
||||
const snippet = `<script src="/config.js"></script>
|
||||
<script>window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase) || ${JSON.stringify(CFG.apiBase)};</script>`;
|
||||
const withCfg = html.replace('</head>', `${snippet}\n</head>`);
|
||||
// 內嵌的 IIFE 裡宣告 API_BASE(各頁的主 <script> 都是 (function(){ … })() 形態)
|
||||
return withCfg.replace(
|
||||
/<script>\s*\(function\s*\(\)\s*\{/,
|
||||
'<script>\n(function () {\n var API_BASE = window.ARCRUN_API_BASE || \'\';'
|
||||
);
|
||||
}
|
||||
|
||||
function build(name, file, fnName, vars) {
|
||||
const body = extractRendererBody(file, fnName);
|
||||
let html = render(body, vars);
|
||||
html = rewriteFetchPaths(html, name);
|
||||
html = injectApiBase(html);
|
||||
const dest = join(OUT, name);
|
||||
mkdirSync(dirname(dest), { recursive: true });
|
||||
writeFileSync(dest, html, 'utf8');
|
||||
console.log(` ${name.padEnd(24)} ${(Buffer.byteLength(html) / 1024).toFixed(1)} KB`);
|
||||
}
|
||||
|
||||
const TAIPEI_CLIENT_JS = extractTaipeiClientJs();
|
||||
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
console.log('console-ui build →', OUT);
|
||||
|
||||
// /console — Admin Console 完整版(console.ts renderConsoleHtml)
|
||||
build('console/index.html', 'routes/console.ts', 'renderConsoleHtml', {
|
||||
registryBase: CFG.registryBase,
|
||||
brand: CFG.brand,
|
||||
profile: CFG.profile,
|
||||
TAIPEI_CLIENT_JS,
|
||||
});
|
||||
|
||||
// /portal — RAG Portal(portal-ui.ts renderPortalHtml)
|
||||
build('portal/index.html', 'routes/portal-ui.ts', 'renderPortalHtml', {
|
||||
brand: CFG.brand,
|
||||
sourceWebBase: CFG.sourceWebBase,
|
||||
TAIPEI_CLIENT_JS,
|
||||
});
|
||||
|
||||
// /console/dashboard — 駕駛艙(console-dashboard.ts renderDashboardHtml)
|
||||
build('console/dashboard/index.html', 'routes/console-dashboard.ts', 'renderDashboardHtml', {
|
||||
brand: CFG.brand,
|
||||
TAIPEI_CLIENT_JS,
|
||||
});
|
||||
|
||||
// config.js:部署後可直接改這一檔切 API 目標,不必重 build
|
||||
writeFileSync(
|
||||
join(OUT, 'config.js'),
|
||||
`// Arcrun UI runtime 組態——改這一行就能切 API 目標,不必重新 build。
|
||||
window.ARCRUN_CONFIG = { apiBase: ${JSON.stringify(CFG.apiBase)} };
|
||||
`,
|
||||
'utf8'
|
||||
);
|
||||
console.log(' config.js');
|
||||
console.log('done.');
|
||||
@@ -0,0 +1,3 @@
|
||||
name = "arcrun-console-ui"
|
||||
pages_build_output_dir = "public"
|
||||
compatibility_date = "2026-07-21"
|
||||
@@ -20,19 +20,32 @@ import { resumeRouter } from './routes/resume';
|
||||
import { executionsRouter } from './routes/executions';
|
||||
import { initSeedRouter } from './routes/init-seed';
|
||||
import { kbdbProxyRouter } from './routes/kbdb-proxy';
|
||||
import { consoleRouter } from './routes/console';
|
||||
import { consoleAuthRouter } from './routes/console-auth';
|
||||
import { consoleDashboardRouter } from './routes/console-dashboard';
|
||||
import { portalRouter } from './routes/portal';
|
||||
import { portalDataRouter } from './routes/portal-data';
|
||||
import { portalUiRouter } from './routes/portal-ui';
|
||||
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
// 全域 CORS(允許 arcrun.dev landing page 帶 credentials 存取)
|
||||
//
|
||||
// 2026-07-21 cypher-ui-split:console/portal UI 搬到 Cloudflare Pages 後,前端與本 API
|
||||
// **不再同源**,故 UI 站的 origin 必須進白名單,否則所有 fetch 會被瀏覽器擋。
|
||||
// 允許來源=上面兩個 landing + UI_ORIGINS(wrangler.toml [vars] 逗號分隔,實例自填
|
||||
// 自己的 Pages 網域,如 https://arcrun-console-ui.pages.dev)。
|
||||
// 刻意不用萬用字元:credentials:true 與 `*` 在 CORS 規格上互斥,且 Authorization/
|
||||
// X-Arcrun-API-Key 是憑證等級標頭,開全域等於誰都能代打。
|
||||
const STATIC_ORIGINS = ['https://arcrun.dev', 'https://www.arcrun.dev'];
|
||||
|
||||
app.use('*', cors({
|
||||
origin: ['https://arcrun.dev', 'https://www.arcrun.dev'],
|
||||
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
origin: (origin, c) => {
|
||||
const extra = (c.env.UI_ORIGINS || '')
|
||||
.split(',')
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean);
|
||||
return [...STATIC_ORIGINS, ...extra].includes(origin) ? origin : null;
|
||||
},
|
||||
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowHeaders: ['Content-Type', 'Authorization', 'X-Arcrun-API-Key'],
|
||||
credentials: true,
|
||||
}));
|
||||
@@ -54,12 +67,10 @@ app.route('/', resumeRouter);
|
||||
app.route('/', executionsRouter); // LI SDD M2.1: /executions/* + /workflows/:name/executions
|
||||
app.route('/', initSeedRouter); // 薄殼原則:seed recipe 是 API 行為(rule 07,壓測 §4.1)
|
||||
app.route('/', kbdbProxyRouter); // kbdb-base 9.5:KBDB 資料層 proxy(讓 CLI 透過 cypher 達 KBDB,純轉發)
|
||||
app.route('/', consoleRouter); // Arcrun#3:搜尋/控制台頁 v0(單檔 HTML+原生 JS,薄殼)
|
||||
app.route('/', consoleAuthRouter); // Arcrun#3 發現②:console 專用簡單 email+password 登入(單一管理員帳密,非多租戶)
|
||||
app.route('/', consoleDashboardRouter); // T-cockpit ②:駕駛艙 dashboard(聚合 KBDB dash_* entries,無需登入唯讀)
|
||||
app.route('/', portalRouter); // portal-auth P2(#24/#25):RAG Portal 多人授權——用戶模型+認證 API
|
||||
app.route('/', portalDataRouter); // portal-auth P3:/portal/data/* server-side enforce(owner_id+library 注入,安全核心)
|
||||
app.route('/', portalUiRouter); // portal-auth P3:GET /portal 單檔 HTML 殼(登入/搜尋/設定;獨立於 console)
|
||||
|
||||
// Worker 導出(fetch + scheduled)
|
||||
// scheduled handler 對應 wrangler.toml [triggers].crons,每分鐘 tick;
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
* arcrun console 駕駛艙 dashboard(T-cockpit ②,Arcrun#3 console 系,2026-07-04 總管派工;
|
||||
* 2026-07-07 fix/console-dashboard-live-data:stale 資料整修,總管交辦)
|
||||
*
|
||||
* ⚠️ 2026-07-21 cypher-ui-split 第一刀:本檔只剩 **API 端點**。原本的
|
||||
* `GET /console/dashboard`(單檔 HTML)已搬到 `console-ui/`(Cloudflare Pages 靜態站),
|
||||
* 頁面改由 Pages 託管、資料仍打本檔的 `/console/dashboard-data`。
|
||||
*
|
||||
* 端點皆「無需登入」(唯讀、不吐機敏值——只回聚合後的狀態燈/任務標題/計數):
|
||||
* - GET /console/dashboard-data:聚合 JSON。
|
||||
* - GET /console/dashboard:單檔 HTML(同 console.ts 薄殼風格),每 60 秒自動刷新。
|
||||
* - GET /console/kb-scale-data:精耕層規模(wiki 卡/三元組/已嵌入;2026-07-07 leo 裁
|
||||
* 「遺產庫不用顯示」後 console 頭部統計改讀這裡)。
|
||||
* - GET /console/settings-data:設定頁誠實系統值(MCP token TTL 佔位)。
|
||||
@@ -69,7 +72,6 @@ import {
|
||||
taipeiDayKey,
|
||||
} from '../lib/console-dashboard-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 }>();
|
||||
|
||||
@@ -522,301 +524,3 @@ consoleDashboardRouter.post('/console/triage-check', async (c) => {
|
||||
return c.json({ success: true, entry_id: entryId, action, status: action === 'restore' ? 'new' : 'done' });
|
||||
});
|
||||
|
||||
function renderDashboardHtml(brand: string): string {
|
||||
return `<!doctype html>
|
||||
<html lang="zh-Hant">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${brand} 駕駛艙</title>
|
||||
<script>
|
||||
// 主題預載(防閃色):預設淺色(leo 2026-07-04 明示),與 /console 共用同一 localStorage key
|
||||
document.documentElement.setAttribute('data-theme', (function () {
|
||||
try { return localStorage.getItem('arcrun_console_theme') === 'dark' ? 'dark' : 'light'; } catch (e) { return 'light'; }
|
||||
})());
|
||||
</script>
|
||||
<style>
|
||||
/* Mira Console 定稿視覺(紙感「2a」,Mira Style Guide 2026-07-04):
|
||||
紙紋底 repeating-linear-gradient、明體標題級聯、琥珀強調、呼吸狀態球嵌單字。
|
||||
2026-07-04 二輪:CSS custom properties 兩份色板——預設淺色(宣紙米白+墨字),深色=原定稿暖黑不動。 */
|
||||
* { box-sizing: border-box; }
|
||||
:root {
|
||||
--paper-a: #f4eddc; --paper-b: #f1e9d6;
|
||||
--ink: #2f2a20; --ink-rgb: 30,24,14;
|
||||
--amber: #8a5f1e; --amber-rgb: 138,95,30;
|
||||
--ok: #1d7a48; --ok-rgb: 29,122,72;
|
||||
--err: #b03a26; --err-rgb: 176,58,38;
|
||||
--track: rgba(30,24,14,.12);
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--paper-a: #191410; --paper-b: #1b1611;
|
||||
--ink: #ede4d3; --ink-rgb: 237,228,211;
|
||||
--amber: #e8b45a; --amber-rgb: 232,180,90;
|
||||
--ok: #7fe0a8; --ok-rgb: 63,190,120;
|
||||
--err: #e58575; --err-rgb: 217,95,76;
|
||||
--track: rgba(255,255,255,.08);
|
||||
}
|
||||
html, body { margin: 0; background: repeating-linear-gradient(0deg,var(--paper-a) 0px,var(--paper-a) 3px,var(--paper-b) 3px,var(--paper-b) 4px); color: var(--ink);
|
||||
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(var(--amber-rgb),.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(var(--ink-rgb),.5); }
|
||||
.pagehead .date { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 14px; color: rgba(var(--ink-rgb),.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(var(--ink-rgb),.6); line-height: 1.55; }
|
||||
@keyframes breatheGreen { 0%,100% { box-shadow: 0 0 24px 6px rgba(var(--ok-rgb),.35); } 50% { box-shadow: 0 0 42px 14px rgba(var(--ok-rgb),.55); } }
|
||||
@keyframes breatheAmber { 0%,100% { box-shadow: 0 0 24px 6px rgba(var(--amber-rgb),.35); } 50% { box-shadow: 0 0 42px 14px rgba(var(--amber-rgb),.6); } }
|
||||
@keyframes breatheRed { 0%,100% { box-shadow: 0 0 24px 6px rgba(var(--err-rgb),.4); } 50% { box-shadow: 0 0 44px 16px rgba(var(--err-rgb),.65); } }
|
||||
.bricks { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.brick { padding: 16px; border-radius: 12px; }
|
||||
.brick.amber { background: rgba(var(--amber-rgb),.07); border: 1px solid rgba(var(--amber-rgb),.22); }
|
||||
.brick.plain { background: rgba(var(--ink-rgb),.04); border: 1px solid rgba(var(--ink-rgb),.14); }
|
||||
.brick .lbl { font-size: 13.5px; color: rgba(var(--ink-rgb),.55); margin-bottom: 6px; }
|
||||
.brick .num { font-family: ui-monospace, Menlo, monospace; font-size: 26px; color: var(--amber); }
|
||||
.brick .num small { font-size: 15px; color: rgba(var(--ink-rgb),.5); }
|
||||
.bar { margin-top: 10px; height: 6px; border-radius: 3px; background: var(--track); }
|
||||
.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(var(--ok-rgb),.3); background: rgba(var(--ok-rgb),.05); }
|
||||
.wait-box.has { border-color: rgba(var(--amber-rgb),.45); background: rgba(var(--amber-rgb),.05); }
|
||||
.wait-head { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 16px; letter-spacing: .2em; color: rgba(var(--ink-rgb),.6); margin-bottom: 10px; text-align: center; }
|
||||
.wait-none { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 20px; color: var(--ok); 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(var(--amber-rgb),.1); border: 1px solid rgba(var(--amber-rgb),.3); font-size: 16px; line-height: 1.5; }
|
||||
.wait-item .dm { color: var(--amber); font-size: 17px; flex: none; }
|
||||
.wait-meta { margin-top: 10px; text-align: center; font-size: 12.5px; color: rgba(var(--ink-rgb),.45); line-height: 1.7; }
|
||||
.wait-meta .warn { color: var(--err); }
|
||||
.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(var(--ink-rgb),.6); }
|
||||
.subhead .m { font-size: 13px; color: rgba(var(--ink-rgb),.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(var(--ink-rgb),.045); border: 1px solid transparent; font-size: 16px; line-height: 1.4; }
|
||||
ul.route li.doing { background: rgba(var(--amber-rgb),.09); border-color: rgba(var(--amber-rgb),.3); }
|
||||
ul.route li .ic { flex: none; font-size: 15px; margin-top: 2px; }
|
||||
ul.route li.done { color: rgba(var(--ink-rgb),.65); }
|
||||
ul.route li.done .ic { color: var(--ok); }
|
||||
ul.route li.doing .ic { color: var(--amber); }
|
||||
ul.route li.todo { color: rgba(var(--ink-rgb),.6); }
|
||||
ul.route li.todo .ic { color: rgba(var(--ink-rgb),.35); }
|
||||
ul.route li.blocked .ic { color: var(--err); }
|
||||
ul.route.faded li { opacity: .55; }
|
||||
.sys { margin-top: 6px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.sys .row { display: flex; justify-content: space-between; align-items: baseline; padding: 10px 14px; border-radius: 10px; background: rgba(var(--ink-rgb),.04); border: 1px solid rgba(var(--ink-rgb),.12); font-size: 14.5px; }
|
||||
.sys .row .k { color: rgba(var(--ink-rgb),.6); }
|
||||
.sys .row .v { font-family: ui-monospace, Menlo, monospace; font-size: 14px; }
|
||||
.sys .ok { color: var(--ok); }
|
||||
.sys .bad { color: var(--err); }
|
||||
.sys .off { color: rgba(var(--ink-rgb),.5); }
|
||||
.muted { color: rgba(var(--ink-rgb),.45); font-size: 14px; }
|
||||
.err { color: var(--err); font-size: 14px; }
|
||||
.stamp { margin: 16px 0 8px; text-align: center; font-size: 12.5px; color: rgba(var(--ink-rgb),.35); line-height: 1.8; }
|
||||
.enter { display: block; text-align: center; font-size: 13.5px; color: rgba(var(--amber-rgb),.75); text-decoration: none; margin-top: 6px; }
|
||||
.theme-btn { flex: none; margin-left: 12px; width: 34px; height: 34px; border-radius: 50%; border: 1px solid rgba(var(--ink-rgb),.25); background: none; color: rgba(var(--ink-rgb),.65); font-size: 16px; cursor: pointer; line-height: 1; align-self: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="pagehead">
|
||||
<div class="title serif">${brand}<small> 駕駛艙</small></div>
|
||||
<div style="display:flex;align-items:baseline">
|
||||
<div class="date serif" id="date-str"></div>
|
||||
<button class="theme-btn" id="theme-btn" title="切換深/淺色">☾</button>
|
||||
</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="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="wait-box" id="wait-box">
|
||||
<div class="wait-head">等你的事</div>
|
||||
<div id="wait-body" class="wait-none">載入中…</div>
|
||||
<div class="wait-meta" id="wait-meta"></div>
|
||||
</div>
|
||||
<div class="subhead"><span class="t">今日路線</span><span class="m" id="route-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="subhead"><span class="t">系統狀況</span><span class="m">live 健康信號</span></div>
|
||||
<div class="sys" id="sys-list"><div class="row"><span class="k">載入中…</span></div></div>
|
||||
<div class="stamp" id="stamp">每 60 秒自動刷新</div>
|
||||
<a class="enter" href="/console">進入完整控制台 ›</a>
|
||||
</main>
|
||||
<script>
|
||||
(function () {
|
||||
// 台北時間 helper(lib/taipei-time.ts 注入,與 server 判定同一套——顯示不隨看的裝置時區漂移)
|
||||
${TAIPEI_CLIENT_JS}
|
||||
const $ = (id) => document.getElementById(id);
|
||||
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
function taskLine(t) {
|
||||
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>';
|
||||
}
|
||||
function humanAge(m) {
|
||||
if (m == null || m < 0) return '時間不明';
|
||||
if (m < 60) return m + ' 分鐘前';
|
||||
if (m < 2880) return Math.round(m / 60) + ' 小時前';
|
||||
return Math.round(m / 1440) + ' 天前';
|
||||
}
|
||||
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 nowTpe = taipeiMonthDay(Date.now());
|
||||
$('date-str').textContent = CNUM[nowTpe.month] + '月' + cnDay(nowTpe.day) + '日';
|
||||
// 深/淺切換(與 /console 共用 arcrun_console_theme;預設淺色)
|
||||
function syncThemeBtn() { $('theme-btn').textContent = document.documentElement.getAttribute('data-theme') === 'dark' ? '☀' : '☾'; }
|
||||
$('theme-btn').addEventListener('click', () => {
|
||||
const next = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', next);
|
||||
try { localStorage.setItem('arcrun_console_theme', next); } catch (e) { /* 私密模式忽略 */ }
|
||||
syncThemeBtn();
|
||||
});
|
||||
syncThemeBtn();
|
||||
// fetch 失敗(斷網)的裸訊息 → 友善誠實文案;60 秒定時器常駐,網路恢復自動刷回
|
||||
function friendlyErr(e) {
|
||||
const m = e && e.message ? String(e.message) : String(e);
|
||||
return /failed to fetch|load failed|networkerror|network request failed/i.test(m) ? '連線中斷' : m;
|
||||
}
|
||||
function sysRow(k, v, cls) {
|
||||
return '<div class="row"><span class="k">' + esc(k) + '</span><span class="v ' + cls + '">' + esc(v) + '</span></div>';
|
||||
}
|
||||
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 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 : '')
|
||||
: '尚無心跳資料') + (d.light !== 'green' && d.light_reason ? '(' + d.light_reason + ')' : '');
|
||||
const done = d.today_done || 0, total = d.today_total || 0;
|
||||
$('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;
|
||||
// ── 等你的事:來源 + 維護時間攤開講,stale 一定警示 ──
|
||||
const wb = $('wait-box'), body = $('wait-body'), wmeta = $('wait-meta');
|
||||
const wm = d.waiting_meta || {};
|
||||
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">' + (w.urgency ? esc(w.urgency) : '◆') + '</span><span>' +
|
||||
(w.id ? '<b>#' + esc(w.id) + '</b> ' : '') + esc(w.title) + '</span></div>').join('');
|
||||
} else {
|
||||
wb.classList.remove('has');
|
||||
body.className = 'wait-none';
|
||||
body.textContent = wm.source === 'none' ? '(管線未接)' : '無,你不用做任何事';
|
||||
}
|
||||
let metaTxt = '';
|
||||
if (wm.source === 'gitea_sprint') {
|
||||
metaTxt = '來源:sprint 等leo清單(' + esc((wm.sprint_files || []).join('、')) + ')・清單維護於 ' + humanAge(wm.updated_ago_minutes);
|
||||
if (wm.stale) metaTxt += '<br><span class="warn">⚠ 清單超過 2 天沒維護,可能過時</span>';
|
||||
} else if (wm.source === 'kbdb_dash_wait') {
|
||||
metaTxt = '<span class="warn">⚠ ' + esc(wm.note || 'dash_wait 殘資料') + '・上次寫入 ' + humanAge(wm.updated_ago_minutes) + ',可能過時</span>';
|
||||
} else {
|
||||
metaTxt = '<span class="warn">管線未接:Gitea sprint 清單與 dash_wait 皆無資料</span>';
|
||||
}
|
||||
wmeta.innerHTML = metaTxt;
|
||||
// ── 今日路線:sprint 任務板優先(來源攤開講);dash_task fallback 沿舊誠實降級 ──
|
||||
const rm = d.route_meta || {};
|
||||
const today = (d.tasks || []).filter((t) => t.scope === 'today');
|
||||
const week = (d.tasks || []).filter((t) => t.scope === 'week');
|
||||
if (rm.source === 'gitea_sprint_board') {
|
||||
$('route-m').textContent = '來源 sprint 任務板・更新於 ' + humanAge(rm.updated_ago_minutes);
|
||||
$('today-list').className = 'route';
|
||||
const staleHead = rm.is_today ? '' :
|
||||
'<li class="todo"><span class="ic">○</span><span class="muted">⚠ 今日任務板未更新(最後 ' + humanAge(rm.updated_ago_minutes) + ')——以下是板上現況</span></li>';
|
||||
$('today-list').innerHTML = staleHead + (today.length
|
||||
? today.map(taskLine).join('')
|
||||
: '<li class="todo"><span class="ic">○</span><span class="muted">任務板上沒有可解析的事項</span></li>');
|
||||
} else if (rm.is_today) {
|
||||
$('route-m').textContent = '更新於 ' + humanAge(rm.updated_ago_minutes);
|
||||
$('today-list').className = 'route';
|
||||
$('today-list').innerHTML = today.length ? today.map(taskLine).join('') : '<li class="todo"><span class="ic">○</span><span class="muted">今日無排定項目</span></li>';
|
||||
} else if (today.length) {
|
||||
$('route-m').textContent = '最後路線・' + humanAge(rm.updated_ago_minutes) + '寫入';
|
||||
$('today-list').className = 'route faded';
|
||||
$('today-list').innerHTML =
|
||||
'<li class="todo"><span class="ic">○</span><span class="muted">今日尚無路線寫入——以下是 ' + humanAge(rm.updated_ago_minutes) +
|
||||
'的殘留路線(sprint 任務板→dashboard 投影管線未接,等leo清單#15 裁決中)</span></li>' + today.map(taskLine).join('');
|
||||
} else {
|
||||
$('route-m').textContent = '';
|
||||
$('today-list').className = 'route';
|
||||
$('today-list').innerHTML = '<li class="todo"><span class="ic">○</span><span class="muted">無資料——dash_task 管線未接</span></li>';
|
||||
}
|
||||
$('week-head').style.display = week.length ? '' : 'none';
|
||||
$('week-list').innerHTML = week.map(taskLine).join('');
|
||||
// ── 系統狀況 + 總庫規模(全 live,讀不到就標讀不到)──
|
||||
const sys = d.system || {}, kb = d.kb || {};
|
||||
const rows = [];
|
||||
rows.push(sysRow('KBDB 基本盤', sys.kbdb_ok ? '● 正常' : '● 打不通', sys.kbdb_ok ? 'ok' : 'bad'));
|
||||
if (sys.embed) {
|
||||
rows.push(sys.embed.enabled
|
||||
? sysRow('語意嵌入', '● 啟用(已嵌 ' + (sys.embed.embedded ?? '?') + '・待嵌 ' + (sys.embed.pending ?? '?') + ')', 'ok')
|
||||
: sysRow('語意嵌入', '○ 停用(已嵌 ' + (sys.embed.embedded ?? '?') + '・待嵌 ' + (sys.embed.pending ?? '?') + ')', 'bad'));
|
||||
} else {
|
||||
rows.push(sysRow('語意嵌入', '狀態讀不到', 'off'));
|
||||
}
|
||||
rows.push(sys.graph && sys.graph.ok
|
||||
? sysRow('知識圖譜', '● 正常・三元組 ' + (sys.graph.triplets == null ? '?' : sys.graph.triplets), 'ok')
|
||||
: sysRow('知識圖譜', '● 打不通', 'bad'));
|
||||
rows.push(sysRow('工作流', sys.workflow_total == null ? '讀不到' : sys.workflow_total + ' 條', sys.workflow_total == null ? 'off' : ''));
|
||||
// 精耕層 wiki 卡(leo 2026-07-07 裁:14-E 遺產總數 deprecated 不再顯示,只顯示真的新的;
|
||||
// 三元組/已嵌入 已各有一列)
|
||||
rows.push(sysRow('精耕層 wiki 卡', kb.wiki_card_total == null ? '讀不到' : kb.wiki_card_total + ' 張', kb.wiki_card_total == null ? 'off' : ''));
|
||||
$('sys-list').innerHTML = rows.join('');
|
||||
$('stamp').innerHTML = '每 60 秒自動刷新・上次 ' + esc(taipeiTimeStr(Date.parse(d.generated_at))) + '(台北)<br>此頁不含機敏內容,免登入';
|
||||
} catch (e) {
|
||||
$('orb-char').textContent = '?';
|
||||
$('orb-title').textContent = '讀不到狀態';
|
||||
$('orb-sub').innerHTML = '<span class="err">' + esc(friendlyErr(e)) + '・每 60 秒自動重試</span>';
|
||||
}
|
||||
}
|
||||
load();
|
||||
setInterval(load, 60000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
// GET /console/dashboard — 駕駛艙頁(無需登入;純渲染 dashboard-data,無互動、無說明文字)
|
||||
// 品牌字樣(Arcrun#21):引擎預設 Arcrun,實例可用 CONSOLE_BRAND 覆蓋(如 "Arcrun RAG")
|
||||
// CONSOLE_PROFILE=rag(console-profile-trim):駕駛艙不屬企業版頁面 → 302 回 /console。
|
||||
// 選 302 不選 404:舊書籤/外鏈直接落回產品頁,不給死路(只裁 UI 頁面,資料端點行為不動)。
|
||||
consoleDashboardRouter.get('/console/dashboard', (c) => {
|
||||
if ((c.env.CONSOLE_PROFILE || 'full') === 'rag') return c.redirect('/console', 302);
|
||||
return c.html(renderDashboardHtml(c.env.CONSOLE_BRAND || 'Arcrun'));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user