Files
Arcrun/console-ui/public/console/index.html
T
uncle6me-web a5e4caf5cb fix(portal): 讀不到就說讀不到——總圖不再把「讀不到」畫成「你沒有」(Arcrun#100)
leo 2026-08-12 打開總圖看到:「0 個實體 · 0 條關聯/知識庫還沒有任何關聯——
上傳文件後 AI 會自動織網」。**而他庫裡有 1854 條三元組。**
那句話會叫他去做一件不需要做的事。

三個獨立的洞疊起來才變成那句謊:
  ① console-dashboard.ts 打 kbdb-graph-plugin 沒帶認證(同段落打 kbdb 的兩支都有帶)
  ② 那顆 worker 不在更新的部署清單裡 ⇒ token 一換它就落單
  ③ **畫面把 null 畫成 0**——後端已經誠實回 null 了,是前端把它變成謊話

為什麼一直沒被發現:graph plugin 原本身上沒有 token ⇒ 門開著 ⇒ 沒帶也進得去。
2026-08-12 輪替後它有了 token,門關上,401 才浮出來。

📍 repo:matrix/arcrun(cypher-executor/src/routes/、console-ui/public/)
📍 票:Leo/Arcrun#100
2026-08-12 13:33:53 +08:00

1577 lines
101 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Arcrun Console</title>
<script>
// 主題預載(防閃色):預設淺色(leo 2026-07-04 明示),選擇存 localStorage
document.documentElement.setAttribute('data-theme', (function () {
try { return localStorage.getItem('arcrun_console_theme') === 'dark' ? 'dark' : 'light'; } catch (e) { return 'light'; }
})());
</script>
<style>
* { box-sizing: border-box; }
/* ── 主題色板(一套結構兩份色值):預設淺色=宣紙米白紙紋+墨色文字;深色=定稿紙感暖黑 2a 原值不動 ── */
: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; --ok-soft: #1d6b40;
--err: #b03a26; --err-rgb: 176,58,38;
--well: rgba(30,24,14,.06); --well2: rgba(30,24,14,.08);
--bar-bg: #efe7d3; --track: rgba(30,24,14,.12); --knob: #ffffff;
}
: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; --ok-soft: #a8e8c4;
--err: #e58575; --err-rgb: 217,95,76;
--well: rgba(0,0,0,.25); --well2: rgba(0,0,0,.3);
--bar-bg: #17120e; --track: rgba(255,255,255,.08); --knob: #ede4d3;
}
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; }
input, textarea, button { font-family: inherit; }
::placeholder { color: rgba(var(--ink-rgb),.35); }
input:focus, textarea:focus { outline: 2px solid rgba(var(--amber-rgb),.5); outline-offset: 1px; }
a { color: var(--amber); }
.serif { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; }
.mono { font-family: ui-monospace, Menlo, monospace; }
.muted { color: rgba(var(--ink-rgb),.55); }
.dim { color: rgba(var(--ink-rgb),.4); }
.err { color: var(--err); }
.ok { color: var(--ok); }
@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); } }
/* ── auth 全屏 ── */
.authwrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; }
.authbox { width: 100%; max-width: 400px; display: flex; flex-direction: column; gap: 24px; text-align: center; }
.brand { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 44px; letter-spacing: .24em; color: var(--amber); }
.brand-sub { margin-top: 10px; font-size: 15px; color: rgba(var(--ink-rgb),.55); letter-spacing: .1em; }
/* ── 版面骨架 ── */
#shell { display: none; min-height: 100vh; }
#shell.on { display: flex; }
#sidenav { display: none; }
main { flex: 1; min-width: 0; padding-bottom: 96px; }
.page { max-width: 980px; margin: 0 auto; padding: 0 20px 40px; }
.page.narrow { max-width: 760px; }
.page.wide { max-width: 1080px; }
.pagehead { padding: 22px 2px 14px; border-bottom: 2px solid rgba(var(--amber-rgb),.4); display: flex; justify-content: space-between; align-items: baseline; gap: 10px; }
.pagehead .t { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 23px; letter-spacing: .2em; white-space: nowrap; }
.pagehead .m { font-size: 13.5px; color: rgba(var(--ink-rgb),.45); }
#tabbar { position: fixed; left: 0; right: 0; bottom: 0; background: var(--bar-bg); border-top: 2px solid rgba(var(--amber-rgb),.4); display: none; z-index: 50; padding-bottom: env(safe-area-inset-bottom); }
#tabbar.on { display: flex; }
#tabbar .tab { flex: 1; text-align: center; padding: 15px 0 13px; font-size: 14.5px; cursor: pointer; min-height: 44px; color: rgba(var(--ink-rgb),.5); border-top: 2px solid transparent; margin-top: -2px; }
#tabbar .tab.on { color: var(--amber); font-weight: 600; border-top-color: var(--amber); }
@media (min-width: 1024px) {
#tabbar.on { display: none; }
main { padding-bottom: 20px; }
#sidenav { display: flex; width: 216px; flex: none; position: sticky; top: 0; height: 100vh; border-right: 2px solid rgba(var(--amber-rgb),.35); padding: 28px 0; flex-direction: column; gap: 4px; }
#sidenav .logo { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 26px; letter-spacing: .22em; color: var(--amber); padding: 0 24px 20px; }
#sidenav .nav { padding: 13px 24px; font-size: 16px; cursor: pointer; letter-spacing: .08em; color: rgba(var(--ink-rgb),.6); border-right: 2px solid transparent; }
#sidenav .nav.on { color: var(--amber); background: rgba(var(--amber-rgb),.08); border-right-color: var(--amber); font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-weight: 600; }
#sidenav .foot { margin-top: auto; padding: 0 24px; font-size: 12.5px; color: rgba(var(--ink-rgb),.35); line-height: 1.7; }
}
.view { display: none; }
.view.on { display: block; }
/* 登入/首次設定=flex 置中(.view.on 的 display:block 會蓋掉 .authwrap 的 flex → 內容靠左,leo 實測回饋修正) */
.authwrap.view.on { display: flex; }
/* ── 元件 ── */
.btn { padding: 13px; font-size: 16px; font-weight: 600; border-radius: 10px; border: none; background: linear-gradient(90deg,#b98330,#e8b45a); color: #241804; cursor: pointer; }
.btn:disabled { opacity: .45; cursor: default; }
.btn2 { padding: 11px 16px; font-size: 15px; border-radius: 10px; border: 1px solid rgba(var(--amber-rgb),.6); background: none; color: var(--amber); cursor: pointer; }
.btn3 { padding: 11px 16px; font-size: 15px; border-radius: 10px; border: 1px solid rgba(var(--ink-rgb),.25); background: none; color: rgba(var(--ink-rgb),.7); cursor: pointer; }
.btn-danger { padding: 12px 18px; font-size: 15px; border-radius: 10px; border: 1px solid rgba(var(--err-rgb),.5); background: none; color: var(--err); cursor: pointer; }
.btn-danger:disabled { opacity: .4; cursor: default; }
.txt { width: 100%; padding: 13px 15px; font-size: 16px; border-radius: 10px; border: 1px solid rgba(var(--ink-rgb),.2); background: rgba(var(--ink-rgb),.05); color: var(--ink); }
textarea.txt { resize: vertical; font-family: ui-monospace, Menlo, monospace; font-size: 14.5px; }
.panel { padding: 18px; border-radius: 13px; background: rgba(var(--ink-rgb),.04); border: 1px solid rgba(var(--ink-rgb),.12); }
.card-item { border-radius: 13px; background: rgba(var(--ink-rgb),.045); border: 1px solid rgba(var(--ink-rgb),.1); }
.card-item.hl { border-color: rgba(var(--amber-rgb),.3); }
.tag { display: inline-block; padding: 3px 9px; border-radius: 5px; background: rgba(var(--amber-rgb),.12); color: var(--amber); font-size: 12.5px; }
.tag.green { background: rgba(var(--ok-rgb),.12); color: var(--ok); }
.chip { flex: none; padding: 9px 16px; font-size: 14.5px; border-radius: 999px; cursor: pointer; border: 1px solid rgba(var(--ink-rgb),.18); background: none; color: rgba(var(--ink-rgb),.6); }
.chip.on { border-color: rgba(var(--amber-rgb),.6); background: rgba(var(--amber-rgb),.14); color: var(--amber); font-weight: 600; }
.hscroll { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 4px; }
.subhead { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 16px; letter-spacing: .2em; color: rgba(var(--ink-rgb),.6); }
.honest { padding: 22px; border-radius: 13px; border: 1px dashed rgba(var(--amber-rgb),.4); background: rgba(var(--amber-rgb),.06); text-align: center; }
.honest .h { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 19px; color: var(--amber); margin-bottom: 8px; }
.honest .b { font-size: 15px; color: rgba(var(--ink-rgb),.6); line-height: 1.7; }
#toast { position: fixed; left: 50%; bottom: 110px; transform: translateX(-50%); z-index: 99; padding: 13px 22px; border-radius: 11px; background: #e8b45a; color: #241804; font-size: 15.5px; font-weight: 600; box-shadow: 0 6px 24px rgba(0,0,0,.5); white-space: nowrap; display: none; }
/* ── 駕駛艙 ── */
.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; }
.cockgrid { display: grid; grid-template-columns: 1fr; gap: 0 40px; }
@media (min-width: 1024px) { .cockgrid { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); } }
.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); cursor: pointer; }
.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; }
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); }
/* ── 搜尋 ── */
.searchrow { display: flex; gap: 10px; align-items: stretch; }
.searchrow input { flex: 1; min-width: 0; padding: 16px 18px; font-size: 18px; border-radius: 13px; border: 1.5px solid rgba(var(--amber-rgb),.35); background: rgba(var(--ink-rgb),.05); color: var(--ink); }
.sembtn { flex: none; padding: 0 20px; font-size: 16px; border-radius: 13px; cursor: pointer; letter-spacing: .1em; border: 1.5px solid rgba(var(--ink-rgb),.2); background: none; color: rgba(var(--ink-rgb),.55); }
.sembtn.on { border-color: var(--amber); background: rgba(var(--amber-rgb),.15); color: var(--amber); font-weight: 600; }
.kgrid { display: grid; grid-template-columns: 1fr; gap: 12px; padding-bottom: 8px; }
@media (min-width: 700px) { .kgrid { grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); } }
.kcard { padding: 18px; border-radius: 13px; background: rgba(var(--ink-rgb),.045); border: 1px solid rgba(var(--ink-rgb),.1); cursor: pointer; display: flex; flex-direction: column; gap: 9px; }
.kcard:hover { border-color: rgba(var(--amber-rgb),.45); background: rgba(var(--amber-rgb),.06); }
.kcard .kt { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 18.5px; font-weight: 600; line-height: 1.45; word-break: break-word; }
.kcard .ks { font-size: 15px; color: rgba(var(--ink-rgb),.65); line-height: 1.65; word-break: break-word; }
.kcard .km { display: flex; gap: 10px; align-items: center; margin-top: auto; font-size: 13px; flex-wrap: wrap; }
/* ── 卡片詳頁 ── */
.cardwrap { display: flex; flex-wrap: wrap; gap: 32px; padding: 24px 0 40px; }
.cardmain { flex: 1 1 540px; min-width: 0; }
.cardside { flex: 1 1 360px; min-width: 0; }
.cardtitle { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 28px; font-weight: 600; line-height: 1.4; word-break: break-word; }
.md { margin-top: 18px; }
.md p { font-size: 16.5px; line-height: 1.85; color: rgba(var(--ink-rgb),.82); margin: 8px 0; word-break: break-word; }
.md h1, .md h2, .md h3, .md h4 { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-weight: 600; color: var(--amber); margin: 18px 0 6px; line-height: 1.4; }
.md h1 { font-size: 22px; } .md h2 { font-size: 20px; } .md h3 { font-size: 18px; } .md h4 { font-size: 17px; }
.md ul, .md ol { margin: 8px 0; padding-left: 24px; }
.md li { font-size: 16.5px; line-height: 1.8; color: rgba(var(--ink-rgb),.82); margin: 4px 0; word-break: break-word; }
.md blockquote { margin: 10px 0; padding: 12px 18px; border-left: 3px solid rgba(var(--amber-rgb),.5); font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 17px; line-height: 1.8; color: rgba(var(--ink-rgb),.7); }
.md code { font-family: ui-monospace, Menlo, monospace; font-size: 14.5px; background: var(--well2); border-radius: 4px; padding: 1px 6px; word-break: break-all; }
.md pre { background: var(--well2); border: 1px solid rgba(var(--ink-rgb),.1); border-radius: 10px; padding: 14px; overflow-x: auto; }
.md pre code { background: none; padding: 0; }
.srcline { margin-top: 24px; display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-radius: 11px; background: rgba(var(--ink-rgb),.045); border: 1px solid rgba(var(--ink-rgb),.12); }
.graphbox { position: relative; height: 340px; border-radius: 14px; background: var(--well); border: 1px solid rgba(var(--ink-rgb),.1); overflow: hidden; }
.gnode { position: absolute; transform: translate(-50%,-50%); max-width: 96px; padding: 8px 11px; border-radius: 999px; text-align: center; font-size: 12.5px; line-height: 1.3; cursor: pointer; word-break: break-word; background: rgba(var(--ok-rgb),.1); border: 1px solid rgba(var(--ok-rgb),.35); color: var(--ok-soft); }
.gcenter { position: absolute; left: 50%; top: 50%; transform: translate(-50%,-50%); width: 104px; height: 104px; border-radius: 50%; background: radial-gradient(circle at 36% 30%,rgba(242,209,148,.95),rgba(232,180,90,.9) 55%,rgba(138,95,30,.95)); display: grid; place-items: center; text-align: center; padding: 8px; box-shadow: 0 0 30px rgba(var(--amber-rgb),.3); }
.gcenter span { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 13.5px; font-weight: 600; line-height: 1.35; color: #241804; word-break: break-word; }
.nbrow { display: flex; align-items: center; gap: 11px; padding: 12px 14px; border-radius: 10px; cursor: pointer; background: rgba(var(--ink-rgb),.045); border: 1px solid rgba(var(--ink-rgb),.1); font-size: 15.5px; }
/* ── 工作流 / 憑證 / 分流台 清單 ── */
.listcol { display: flex; flex-direction: column; gap: 10px; padding: 16px 0 8px; }
.rowhead { display: flex; align-items: center; gap: 12px; padding: 16px 18px; cursor: pointer; }
.rowbody { padding: 0 18px 18px; border-top: 1px solid rgba(var(--ink-rgb),.1); }
.pinbtn { flex: none; width: 34px; height: 34px; border-radius: 8px; border: none; cursor: pointer; font-size: 17px; background: rgba(var(--ink-rgb),.06); color: rgba(var(--ink-rgb),.4); }
.pinbtn.on { background: rgba(var(--amber-rgb),.15); color: var(--amber); }
.urlbox { display: flex; align-items: center; gap: 10px; padding: 12px 14px; border-radius: 10px; background: var(--well); border: 1px solid rgba(var(--ink-rgb),.1); min-width: 0; }
.urlbox .u { font-family: ui-monospace, Menlo, monospace; font-size: 13.5px; color: var(--amber); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.avatar { width: 38px; height: 38px; border-radius: 10px; background: rgba(var(--amber-rgb),.1); border: 1px solid rgba(var(--amber-rgb),.25); display: grid; place-items: center; font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 17px; color: var(--amber); flex: none; }
.maskline { display: flex; align-items: center; gap: 10px; padding: 14px; border-radius: 10px; background: var(--well); border: 1px solid rgba(var(--ink-rgb),.1); }
/* 分流台(Arcrun#9):三欄 80/15/5 手機直排;欄色點 ai=綠 collab=琥珀 leo=紅 */
.tri-sec { margin-top: 24px; }
.tri-sechead { display: flex; align-items: baseline; gap: 9px; }
.tri-sechead .dot { flex: none; width: 9px; height: 9px; border-radius: 50%; align-self: center; }
.tri-sec.ai .dot { background: var(--ok); } .tri-sec.collab .dot { background: var(--amber); } .tri-sec.leo .dot { background: var(--err); }
.tri-sechead .nm { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 17px; letter-spacing: .14em; }
.tri-sechead .pc { font-size: 12.5px; color: rgba(var(--ink-rgb),.4); }
.tri-sechead .ct { margin-left: auto; font-family: ui-monospace, Menlo, monospace; font-size: 14px; color: rgba(var(--ink-rgb),.55); }
.tri-item { padding: 14px 16px; border-radius: 12px; margin-top: 9px; }
.tri-item.new { background: rgba(var(--amber-rgb),.07); border: 1px solid rgba(var(--amber-rgb),.28); }
.tri-sec.leo .tri-item.new { background: rgba(var(--err-rgb),.06); border-color: rgba(var(--err-rgb),.3); }
.tri-item.done { background: rgba(var(--ink-rgb),.04); border: 1px solid rgba(var(--ink-rgb),.1); opacity: .7; }
.tri-item.done .tx { text-decoration: line-through; color: rgba(var(--ink-rgb),.6); }
.tri-item .tx { font-size: 16.5px; line-height: 1.6; word-break: break-word; }
.tri-item .mt { margin-top: 9px; display: flex; gap: 7px; align-items: center; flex-wrap: wrap; font-size: 12.5px; color: rgba(var(--ink-rgb),.45); }
.tri-item .row { display: flex; gap: 12px; align-items: flex-start; }
.tri-item .row .tx { flex: 1; min-width: 0; }
/* 勾掉鈕:≥44px 圓形(手機好按),綠框空心=待勾;按下去由後端回寫才算數(誠實,不先假裝成功) */
.tri-check { flex: none; width: 44px; height: 44px; border-radius: 50%; cursor: pointer; font-size: 19px; line-height: 1;
border: 1.5px solid rgba(var(--ok-rgb),.55); background: rgba(var(--ok-rgb),.06); color: var(--ok); }
.tri-check:active { background: rgba(var(--ok-rgb),.22); }
.tri-check:disabled { opacity: .45; cursor: default; }
/* 還原鈕(誤勾救濟,只出現在「顯示已完成」清單):小顆但仍 44px 高好按 */
.tri-restore { flex: none; min-height: 32px; padding: 5px 14px; border-radius: 999px; cursor: pointer; font-size: 13px;
border: 1px solid rgba(var(--amber-rgb),.5); background: none; color: var(--amber); }
.tri-restore:disabled { opacity: .45; cursor: default; }
.tag.red { background: rgba(var(--err-rgb),.12); color: var(--err); }
.tag.dim { background: rgba(var(--ink-rgb),.08); color: rgba(var(--ink-rgb),.55); }
/* ── 設定 ── */
.setcol { display: flex; flex-direction: column; gap: 14px; padding: 20px 0 40px; }
.switch { flex: none; margin-left: auto; width: 56px; height: 32px; border-radius: 999px; padding: 3px; background: rgba(var(--ink-rgb),.15); transition: background .25s; }
.switch.on { background: rgba(var(--ok-rgb),.5); }
.switch i { display: block; width: 26px; height: 26px; border-radius: 50%; background: var(--knob); box-shadow: 0 1px 3px rgba(0,0,0,.25); transition: transform .25s; }
.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>
// 2026-08-01arcrun-rag#10 同族):拔掉寫死中央位址的 fallback。
// apiBase 由 worker 動態產生的 /config.js 注入;缺它就讓它明顯壞掉,
// **不要靜默把請求(可能含金鑰)送去中央實例**。
window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase) || "";
</script>
</head>
<body>
<!-- 登入 -->
<div class="authwrap view" id="v-login">
<div class="authbox">
<div>
<div class="brand">Arcrun</div>
<div class="brand-sub">私人系統・僅供擁有者進入</div>
</div>
<div style="display:flex;flex-direction:column;gap:12px;text-align:left">
<input type="email" id="login-email" class="txt" placeholder="Email" style="padding:15px 16px;font-size:17px;border-radius:11px">
<input type="password" id="login-password" class="txt" placeholder="密碼" style="padding:15px 16px;font-size:17px;border-radius:11px">
<button class="btn" id="login-submit" style="margin-top:6px;padding:15px;font-size:17px;letter-spacing:.2em">進入</button>
<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>
<button class="btn3 themelabel" data-themetoggle style="align-self:center;padding:8px 16px;font-size:13.5px;border-radius:999px">☾ 切深色</button>
</div>
</div>
<!-- 首次設定 -->
<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">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>
<div style="display:flex;flex-direction:column;gap:12px">
<input type="email" id="setup-email" class="txt" placeholder="你的 Email" style="padding:15px 16px;font-size:17px;border-radius:11px">
<input type="password" id="setup-password" class="txt" placeholder="設定密碼(至少 8 碼)" style="padding:15px 16px;font-size:17px;border-radius:11px">
<input type="password" id="setup-password2" class="txt" placeholder="再輸入一次密碼" style="padding:15px 16px;font-size:17px;border-radius:11px">
<button class="btn" id="setup-submit" style="margin-top:6px;padding:15px;font-size:17px;letter-spacing:.12em">完成設定並進入</button>
<div id="setup-status" class="err" style="font-size:14px;min-height:1.2em"></div>
</div>
<button class="btn3 themelabel" data-themetoggle style="align-self:center;padding:8px 16px;font-size:13.5px;border-radius:999px">☾ 切深色</button>
</div>
</div>
<!-- 主應用 -->
<div id="shell">
<div id="sidenav">
<div class="logo">Arcrun</div>
<div class="nav" data-nav="search">總庫搜尋</div>
<div class="nav" data-nav="workflows">工作流</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>
</div>
<main>
<!-- 駕駛艙 -->
<div class="view page" id="v-cockpit">
<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">
<div class="orb" id="ck-orb" style="background:radial-gradient(circle at 36% 30%,#8fe8b4,#3fbe78 55%,#22754a 100%)"><span id="ck-char"></span></div>
<div>
<div class="orb-title" id="ck-title">載入中</div>
<div class="orb-sub" id="ck-sub"></div>
</div>
</div>
<div class="bricks">
<div class="brick amber">
<div class="lbl">今日完成</div>
<div class="num"><span id="ck-done"></span><small> / <span id="ck-total"></span></small></div>
<div class="bar"><i id="ck-bar" style="width:0%"></i></div>
</div>
<div class="brick plain" data-nav="inbox">
<div class="lbl">收件匣未處理</div>
<div class="num"><span id="ck-inbox"></span><small></small></div>
<div class="lbl" style="margin:10px 0 0">來自 Telegram</div>
</div>
</div>
<div class="wait-box" id="ck-waitbox">
<div class="wait-head">等你的事</div>
<div id="ck-wait" class="wait-none">載入中…</div>
<div id="ck-waitmeta" style="margin-top:10px;text-align:center;font-size:12.5px;color:rgba(var(--ink-rgb),.45);line-height:1.7"></div>
</div>
</div>
<div>
<div style="display:flex;justify-content:space-between;align-items:baseline;margin:24px 0 10px">
<span class="subhead">今日路線</span><span id="ck-route-src" style="font-size:13px;color:rgba(var(--ink-rgb),.4)">狀態即時同步</span>
</div>
<ul class="route" id="ck-today"><li class="todo"><span class="ic"></span><span class="muted">載入中…</span></li></ul>
<div class="subhead" id="ck-weekhead" style="display:none;margin:18px 0 10px">本週</div>
<ul class="route" id="ck-week"></ul>
<div style="margin:16px 0 8px;text-align:center;font-size:12.5px;color:rgba(var(--ink-rgb),.35);line-height:1.8" id="ck-stamp">每 60 秒自動刷新</div>
</div>
</div>
</div>
<!-- 總庫搜尋(library-map SDD M5/R4:搜尋框上方=藏書地圖——先看全館有哪些庫,點庫卡片帶 library filter 進庫搜尋) -->
<div class="view page wide" id="v-search">
<div class="pagehead"><span class="t">總庫搜尋</span><span class="m mono" id="se-scale"></span></div>
<div style="padding-top:20px">
<div id="lm-section" style="margin-bottom:24px">
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:10px">
<span class="subhead">藏書地圖</span><span id="lm-meta" style="font-size:13px;color:rgba(var(--ink-rgb),.4)"></span>
</div>
<div class="kgrid" id="lm-cards"><div class="muted" style="font-size:14px">地圖載入中…</div></div>
<div id="lm-bridges"></div>
</div>
<div class="searchrow">
<input id="se-q" placeholder="搜尋知識…" autocomplete="off">
<button class="sembtn" id="se-sem">語意</button>
</div>
<div id="se-libbar"></div>
<div class="hscroll" id="se-chips" style="margin-top:12px"></div>
<div id="se-banner"></div>
<div id="se-count" style="margin:20px 0 12px;font-size:14px;color:rgba(var(--ink-rgb),.5)"></div>
<div class="kgrid" id="se-results"></div>
</div>
</div>
<!-- 卡片詳頁 -->
<div class="view page wide" id="v-card">
<div class="pagehead" style="justify-content:flex-start;gap:14px">
<button class="btn2" style="border:none;padding:0;font-size:16px" data-nav="search"> 搜尋</button>
<span class="serif" style="font-size:17px;letter-spacing:.14em;color:rgba(var(--ink-rgb),.55)">知識卡片</span>
</div>
<div class="cardwrap">
<div class="cardmain" id="cd-main"><div class="muted" style="padding:24px 0">載入中…</div></div>
<div class="cardside">
<div class="subhead" style="margin:6px 0 12px;display:block">關聯視圖</div>
<div id="cd-graph"></div>
<div id="cd-neighbors" style="margin-top:16px;display:flex;flex-direction:column;gap:7px"></div>
</div>
</div>
</div>
<!-- 工作流 -->
<div class="view page narrow" id="v-workflows">
<div class="pagehead"><span class="t">工作流</span><span class="m" id="wf-count"></span></div>
<input id="wf-q" class="txt" placeholder="搜尋工作流…" style="margin-top:18px" autocomplete="off">
<div class="listcol" id="wf-list"><div class="muted">載入中…</div></div>
<div style="margin-top:26px">
<div class="subhead" style="display:block;margin-bottom:10px">零件與 Recipes(框架資源)</div>
<div class="searchrow">
<input id="cr-q" class="txt" placeholder="如「telegram send」「gsheets append」" style="font-size:15px;padding:12px 14px;border-radius:11px;border-width:1px">
<button class="btn2" id="cr-go" style="flex:none"></button>
</div>
<div id="cr-out" style="margin-top:10px"></div>
</div>
</div>
<!-- 憑證管理 -->
<div class="view page narrow" id="v-creds">
<div class="pagehead"><span class="t">憑證管理</span><button class="btn2" id="cred-addbtn" style="padding:9px 16px;font-size:14.5px"> 新增憑證</button></div>
<div style="margin-top:16px;padding:13px 16px;border-radius:11px;background:rgba(var(--ok-rgb),.05);border:1px solid rgba(var(--ok-rgb),.2);font-size:14px;line-height:1.7;color:rgba(var(--ink-rgb),.65)">
<span class="ok">🛡 保險箱原則</span>:系統只保存目錄與加密後的值,<b style="color:var(--ink)">任何頁面都看不到密文內容</b>。只能整筆替換或刪除。
</div>
<div id="cred-form" style="display:none;margin-top:14px" class="panel">
<div class="serif" style="font-size:17px;color:var(--amber);margin-bottom:10px">新增憑證</div>
<div style="display:flex;flex-direction:column;gap:10px">
<input id="cred-name" class="txt" placeholder="名稱(英數與底線,例:openai_main_key">
<input id="cred-service" class="txt" placeholder="服務(例:OpenAI">
<textarea id="cred-secret" class="txt" placeholder="密文值(保存後不再顯示)" rows="2"></textarea>
<button class="btn" id="cred-save">加密保存</button>
<div id="cred-form-status" style="font-size:14px;min-height:1.2em"></div>
</div>
</div>
<div class="listcol" id="cred-list"><div class="muted">載入中…</div></div>
</div>
<!-- 分流台(原收件匣,Arcrun#9 改裝=全部待辦分流台) -->
<div class="view page narrow" id="v-inbox">
<div class="pagehead"><span class="t">分流台</span><span class="m">全部待辦・80/15/5</span></div>
<div class="hscroll" id="tg-chips" style="margin-top:14px"></div>
<div id="tg-out" style="padding-top:2px"><div class="muted" style="padding:14px 0">載入中…</div></div>
</div>
<!-- 設定 -->
<div class="view page narrow" id="v-settings">
<div class="pagehead"><span class="t">設定</span></div>
<div class="setcol">
<div class="panel">
<div style="display:flex;align-items:center;gap:14px">
<div style="min-width:0">
<div style="font-size:17px;font-weight:600">深色模式</div>
<div style="margin-top:4px;font-size:14px;line-height:1.65;color:rgba(var(--ink-rgb),.55)">預設淺色(紙感)。切換立即生效,選擇記在這台裝置(localStorage)。</div>
</div>
<div class="switch" id="st-theme-switch" data-themetoggle style="cursor:pointer"><i></i></div>
</div>
</div>
<div class="panel">
<!-- t36leo 2026-07-25 定案:語意搜尋預設開啟、不做開關):
這裡原本是一個「長得像開關、其實不能按」的唯讀狀態燈(title 自承「不能遠端改」),
旁邊還教用戶去部署端改 config.yaml 跑 CLI——對一鍵安裝進來的用戶那是天書,
而且安裝器現在裝機時就把語意索引開好了,那段指示已經不成立。
改成單純的狀態列:能用就說能用,不能用就說原因,不擺一個按不動的開關讓人誤會。 -->
<div style="font-size:17px;font-weight:600">語意搜尋</div>
<div id="st-vec" style="margin-top:4px;font-size:14px;line-height:1.65;color:rgba(var(--ink-rgb),.55)">狀態偵測中…</div>
<div id="st-vec-hint" style="margin-top:12px;padding:12px 14px;border-radius:10px;background:rgba(var(--amber-rgb),.07);border:1px dashed rgba(var(--amber-rgb),.35);font-size:14px;line-height:1.7;color:rgba(var(--ink-rgb),.65);display:none"></div>
</div>
<div class="panel">
<div style="font-size:17px;font-weight:600">MCP token 有效期(TTL</div>
<div id="st-mcp-ttl" style="margin-top:4px;font-size:14px;line-height:1.65;color:rgba(var(--ink-rgb),.55)">讀取中…</div>
<div style="margin-top:12px;padding:12px 14px;border-radius:10px;background:rgba(var(--amber-rgb),.07);border:1px dashed rgba(var(--amber-rgb),.35);font-size:14px;line-height:1.7;color:rgba(var(--ink-rgb),.65)">
誠實佔位:此頁<b style="color:var(--amber)">還不能改</b>這個值——可調功能=Arcrun#19(設定頁改→存 KBDB→發 token 時讀)。實作前要調整請在部署端 env <code style="font-size:12.5px">MCP_TOKEN_TTL</code>mcp worker)設定。
</div>
</div>
<div class="panel">
<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 style="margin-top:14px;display:flex;flex-direction:column;gap:10px">
<input type="password" id="st-pw-old" class="txt" placeholder="舊密碼">
<input type="email" id="st-pw-email" class="txt" placeholder="Email(可沿用或換新)">
<input type="password" id="st-pw-new" class="txt" placeholder="新密碼(至少 8 碼)">
<button class="btn" id="st-pw-save">更新密碼</button>
<div id="st-pw-status" style="font-size:14px;min-height:1.2em"></div>
</div>
</div>
<div class="panel">
<div style="font-size:17px;font-weight:600">Portal 帳號密碼救援</div>
<div style="margin-top:4px;font-size:14px;line-height:1.65;color:rgba(var(--ink-rgb),.55)">忘記某個 Portal(RAG 搜尋頁)帳號的密碼,包含你自己那組管理員帳號——不需要先登進 Portal。輸入該帳號的 Email,會產生一組新密碼,只顯示這一次,請立刻抄下並拿去 Portal 登入頁使用。</div>
<div style="margin-top:14px;display:flex;flex-direction:column;gap:10px">
<input type="email" id="st-portal-recover-email" class="txt" placeholder="Portal 帳號 Email">
<button class="btn" id="st-portal-recover-btn">產生新密碼</button>
<div id="st-portal-recover-status" style="font-size:14px;min-height:1.2em"></div>
</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>
</div>
<button class="btn3" id="st-logout" style="padding:14px;font-size:16px;border-radius:11px">登出</button>
</div>
</div>
</main>
</div>
<!-- 手機底部導覽 -->
<div id="tabbar">
<div class="tab" data-nav="search">搜尋</div>
<div class="tab" data-nav="workflows">工作流</div>
<div class="tab" data-nav="settings">設定</div>
</div>
<div id="toast"></div>
<script>
(function () {
var API_BASE = window.ARCRUN_API_BASE || '';
'use strict';
// 台北時間 helperlib/taipei-time.ts 注入,與駕駛艙 #36 台北日判定同一套)——
// console 所有日期/時間顯示固定 Asia/Taipei,不隨看的裝置時區漂移
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); };
// ── 狀態 ──
var S = {
token: localStorage.getItem('arcrun_console_session') || '',
tenant: '',
chip: '', // entry_type filter'' = 全部)
library: '', // library filter'' = 全館;藏書地圖點庫卡片設定,M5/R4)
semantic: false,
cardId: '',
graphCenter: '',
wfCache: null,
pins: (function () { try { return JSON.parse(localStorage.getItem('arcrun_console_pins') || '{}'); } catch (e) { return {}; } })()
};
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function toast(msg) {
var el = $('toast');
el.textContent = msg;
el.style.display = 'block';
clearTimeout(toast._t);
toast._t = setTimeout(function () { el.style.display = 'none'; }, 2000);
}
function apiHeaders() { return { 'X-Arcrun-API-Key': S.tenant }; }
// fetch 失敗(斷網)的裸 TypeError 訊息(Failed to fetch / Load failed)→ 友善誠實文案
function friendlyErr(e) {
var m = e && e.message ? String(e.message) : String(e);
if (/failed to fetch|load failed|networkerror|network request failed/i.test(m)) return '連線中斷——請檢查網路後重試';
return m;
}
// ── 主題(深/淺):預設淺色,選擇存 localStorage(head 預載腳本已先套用防閃色)──
function getTheme() { return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light'; }
function setTheme(t) {
document.documentElement.setAttribute('data-theme', t);
try { localStorage.setItem('arcrun_console_theme', t); } catch (e) { /* 私密模式存不了就只影響本次 */ }
syncThemeUI();
}
function syncThemeUI() {
var dark = getTheme() === 'dark';
document.querySelectorAll('.themelabel').forEach(function (el) { el.textContent = dark ? '☀ 切淺色' : '☾ 切深色'; });
var sw = $('st-theme-switch');
if (sw) sw.classList.toggle('on', dark);
}
document.addEventListener('click', function (ev) {
var t = ev.target.closest('[data-themetoggle]');
if (t) setTheme(getTheme() === 'dark' ? 'light' : 'dark');
});
syncThemeUI();
// created_at 容錯:epoch 秒 / 毫秒 / ISO / 'YYYY-MM-DD HH:MM:SS'
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; }
var s = /T/.test(v) ? v : String(v).replace(' ', 'T') + 'Z';
var ms = Date.parse(s);
return isNaN(ms) ? null : ms;
}
// 顯示一律台北時間(bug 修正:原本用瀏覽器本地 getFullYear/getHours——顯示結果
// 隨看的裝置時區漂移,且與後端「台北日」判定不同套;憑證頁 7/6 22:57(台北)建立的
// credential 在非 +8 裝置上會顯示成另一天)
function fmtDate(v) {
var ms = parseAtMs(v);
return ms == null ? '' : taipeiDateStr(ms);
}
function fmtDateTime(v) {
var ms = parseAtMs(v);
return ms == null ? '' : taipeiDateTimeStr(ms);
}
// ── 極簡 Markdown 渲染(先全 escape 再上格式,防 XSS)──
function mdInline(s) {
return s
.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 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)) {
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; }
var line = esc(raw);
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*&gt;\s?/.test(line)) { closeList(); out.push('<blockquote>' + mdInline(line.replace(/^\s*&gt;\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+(.*)$/);
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; }
closeList();
out.push('<p>' + mdInline(line) + '</p>');
}
closeList();
if (inCode) out.push('</code></pre>');
return out.join('');
}
// ── 路由 ──
// VIEWS / HOME 由 server 依 CONSOLE_PROFILE 注入(full=全套 7 頁落地駕駛艙;rag=4 頁落地搜尋)。
// 不在 VIEWS 裡的 hash(含被 profile 裁掉的頁)一律導回 HOME——手打 #/cockpit 也進不去。
var VIEWS = ["search","card","workflows","settings"];
var HOME = "search";
function currentRoute() {
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('/') };
}
function nav(view, arg) {
location.hash = '#/' + view + (arg ? '/' + encodeURIComponent(arg) : '');
}
function showView(name) {
VIEWS.forEach(function (v) { $('v-' + v).classList.toggle('on', v === name); });
var activeKey = name === 'card' ? 'search' : name;
document.querySelectorAll('#sidenav .nav, #tabbar .tab').forEach(function (el) {
el.classList.toggle('on', el.getAttribute('data-nav') === activeKey);
});
window.scrollTo(0, 0);
}
var LOADERS = { cockpit: loadCockpit, search: initSearchOnce, card: loadCard, workflows: loadWorkflows, creds: loadCreds, inbox: loadTriage, settings: loadSettings };
function route() {
var r = currentRoute();
// 隱藏頁/不存在頁 guardhash 不在 VIEWS → 改寫成 HOMEhashchange 會再進來一次正常渲染)
if (r.raw && r.raw !== r.view) { location.hash = '#/' + r.view; return; }
if (r.view === 'card' && r.arg) S.cardId = decodeURIComponent(r.arg);
showView(r.view);
if (LOADERS[r.view]) LOADERS[r.view]();
}
window.addEventListener('hashchange', route);
document.addEventListener('click', function (ev) {
var t = ev.target.closest('[data-nav]');
if (t) nav(t.getAttribute('data-nav'));
});
// ── 認證 ──
function showAuth(which) {
$('v-login').classList.toggle('on', which === 'login');
$('v-setup').classList.toggle('on', which === 'setup');
$('shell').classList.remove('on');
$('tabbar').classList.remove('on');
}
function showApp() {
$('v-login').classList.remove('on');
$('v-setup').classList.remove('on');
$('shell').classList.add('on');
$('tabbar').classList.add('on');
$('side-foot').innerHTML = '租戶 ' + esc(S.tenant) + '<br>' + esc(location.host);
if (!location.hash) location.hash = '#/' + HOME;
route();
}
function init() {
if (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 () {
S.token = ''; localStorage.removeItem('arcrun_console_session');
checkConfigured();
});
} else {
checkConfigured();
}
}
function checkConfigured() {
fetch(API_BASE + '/console/auth-status')
.then(function (r) { return r.json(); })
.then(function (d) { showAuth(d.configured ? 'login' : 'setup'); })
.catch(function () { showAuth('login'); });
}
function handleAuthResponse(d) {
S.token = d.session_token;
localStorage.setItem('arcrun_console_session', S.token);
S.tenant = d.tenant || '';
showApp();
}
$('login-submit').addEventListener('click', function () {
var email = $('login-email').value.trim(), password = $('login-password').value;
$('login-status').textContent = '';
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); });
});
$('login-password').addEventListener('keydown', function (ev) { if (ev.key === 'Enter') $('login-submit').click(); });
$('setup-submit').addEventListener('click', function () {
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(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); });
});
// ── 駕駛艙 ──
var 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' }
};
var TASK_ICONS = { done: '✓', doing: '◐', todo: '○', blocked: '●' };
var 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] : '')); }
function taskLine(t) {
var cls = TASK_ICONS[t.status] ? t.status : 'blocked';
return '<li class="' + cls + '"><span class="ic">' + (TASK_ICONS[t.status] || '●') + '</span><span>' + esc(t.title) + '</span></li>';
}
function ckAge(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) + ' 天前';
}
function loadCockpit() {
var nowTpe = taipeiMonthDay(Date.now()); // 頁首日期=台北日,不吃裝置時區
$('ck-date').textContent = CNUM[nowTpe.month] + '月' + cnDay(nowTpe.day) + '日';
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;
var orb = $('ck-orb');
orb.style.background = cfg.grad;
orb.style.animation = cfg.anim + ' 3.4s ease-in-out infinite';
$('ck-char').textContent = cfg.ch;
$('ck-title').textContent = cfg.title;
$('ck-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 + '' : '');
var done = d.today_done || 0, total = d.today_total || 0;
$('ck-done').textContent = done; $('ck-total').textContent = total;
$('ck-bar').style.width = (total ? Math.round(done / total * 100) : 0) + '%';
$('ck-inbox').textContent = d.inbox_new || 0;
// 等你的事:來源與維護時間攤開講(fix/console-dashboard-live-data)——
// Gitea sprint 等leo清單是活資料源;fallback dash_wait 一定標 stale,不假裝是今天的
var wb = $('ck-waitbox'), body = $('ck-wait'), wmeta = $('ck-waitmeta');
var wm = d.waiting_meta || {};
if (d.waiting && d.waiting.length) {
wb.classList.add('has'); body.className = '';
body.innerHTML = d.waiting.map(function (w) {
return '<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' ? '(管線未接)' : '無,你不用做任何事';
}
if (wm.source === 'gitea_sprint') {
wmeta.innerHTML = '來源:sprint 等leo清單・維護於 ' + ckAge(wm.updated_ago_minutes) +
(wm.stale ? '<br><span class="err">⚠ 清單超過 2 天沒維護,可能過時</span>' : '');
} else if (wm.source === 'kbdb_dash_wait') {
wmeta.innerHTML = '<span class="err">⚠ ' + esc(wm.note || 'dash_wait 殘資料') + '・上次寫入 ' + ckAge(wm.updated_ago_minutes) + ',可能過時</span>';
} else if (wm.source === 'none') {
wmeta.innerHTML = '<span class="err">管線未接:Gitea sprint 清單與 dash_wait 皆無資料</span>';
} else {
wmeta.textContent = '';
}
// 今日路線:sprint 任務板優先(來源與更新時間攤開講);dash_task fallback 沿舊誠實降級
var rm = d.route_meta || {};
var today = (d.tasks || []).filter(function (t) { return t.scope === 'today'; });
var week = (d.tasks || []).filter(function (t) { return t.scope === 'week'; });
if (rm.source === 'gitea_sprint_board') {
$('ck-route-src').textContent = '來源 sprint 任務板・更新於 ' + ckAge(rm.updated_ago_minutes);
var staleHead = rm.is_today ? '' :
'<li class="todo"><span class="ic">○</span><span class="muted">⚠ 今日任務板未更新(最後 ' + ckAge(rm.updated_ago_minutes) + ')——以下是板上現況</span></li>';
$('ck-today').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 || !today.length) {
$('ck-route-src').textContent = '來源 dash_task';
$('ck-today').innerHTML = today.length ? today.map(taskLine).join('') : '<li class="todo"><span class="ic">○</span><span class="muted">今日無排定項目——dash_task 今天沒有寫入</span></li>';
} else {
$('ck-route-src').textContent = '來源 dash_task';
$('ck-today').innerHTML = '<li class="todo"><span class="ic">○</span><span class="muted">今日尚無路線寫入——以下是 ' + ckAge(rm.updated_ago_minutes) + '的殘留路線</span></li>' + today.map(taskLine).join('');
}
$('ck-weekhead').style.display = week.length ? '' : 'none';
$('ck-week').innerHTML = week.map(taskLine).join('');
$('ck-stamp').textContent = '每 60 秒自動刷新・上次 ' + taipeiTimeStr(Date.parse(d.generated_at)) + '(台北)';
})
.catch(function (e) {
$('ck-char').textContent = '';
$('ck-title').textContent = '讀不到狀態';
$('ck-sub').innerHTML = '<span class="err">' + esc(friendlyErr(e)) + '・每 60 秒自動重試</span>';
});
}
// 60 秒定時器無條件常駐:斷網期間照跑,網路恢復後下一輪自動刷新回正常畫面
setInterval(function () { if (currentRoute().view === 'cockpit' && $('shell').classList.contains('on')) loadCockpit(); }, 60000);
// ── 總庫搜尋 ──
// chips = KBDB 真能篩的 entry_type(誠實:不編造「電腦筆記/手機筆記」等 KBDB 沒有的分類軸)
var CHIPS = [
{ label: '全部', v: '' },
{ label: '筆記 note', v: 'note' },
{ label: '區塊 block', v: 'block' },
{ label: '數值 value', v: 'value' },
{ label: '工作流 workflow', v: 'workflow' }
];
var searchInited = false;
function initSearchOnce() {
if (searchInited) { return; }
searchInited = true;
renderChips();
loadScale();
loadLibraryMap();
}
// ── 藏書地圖(library-map SDD M5/R4)──
// 資料源:GET /kbdb/mapcypher 代轉 KBDB 基本盤,kbdb-proxy.ts——同 /kbdb/search 慣例)。
// 兩段式渲染:先用全館列表(top_entities=名字 top3)立即出卡片,再逐庫拉 /kbdb/map/:library
// 補 degreebridgescommit_hash(庫數少;詳圖拉不到就維持名字版——加分項不擋渲染)。
// 空陣列/錯誤=誠實空狀態,只佔地圖區塊,不擋下方搜尋。
var LM = { libs: null, details: {} }; // 地圖快取:點卡片重繪「搜尋中」標記時不用重打 API
function lmHonest(head, body) {
$('lm-cards').innerHTML = '<div class="honest" style="grid-column:1/-1"><div class="h">' + esc(head) + '</div><div class="b">' + body + '</div></div>';
$('lm-bridges').innerHTML = '';
$('lm-meta').textContent = '';
}
function lmEntityLine(ents) {
// ents:詳圖版 [{name, degree}] 或全館列表版 ['name']——兩形都渲染,degree 有才顯示
return (ents || []).slice(0, 5).map(function (t) {
var name = typeof t === 'string' ? t : (t && t.name) || '';
var deg = t && typeof t === 'object' && typeof t.degree === 'number' ? t.degree : null;
return '<span class="tag green">' + esc(name) + (deg != null ? '<span style="opacity:.65">・' + deg + '</span>' : '') + '</span>';
}).join(' ');
}
function renderLmCards(libs, details) {
$('lm-meta').textContent = libs.length + ' 個庫・點卡片進該庫搜尋';
$('lm-cards').innerHTML = libs.map(function (l) {
var d = details[l.library];
var ents = d && d.top_entities && d.top_entities.length ? d.top_entities : l.top_entities;
// 更新時間感:commit_hash(詳圖有才顯示,短碼)+ updated_at(epoch 秒,台北時間)
var stamp = (d && d.commit_hash ? '<span class="mono">' + esc(String(d.commit_hash).slice(0, 7)) + '</span>・' : '') +
'更新 ' + esc(fmtDateTime(l.updated_at) || '—');
return '<div class="kcard" data-lib="' + esc(l.library) + '">' +
'<div class="kt">' + esc(l.library) + (S.library === l.library ? ' <span class="tag" style="font-size:11.5px;vertical-align:middle">搜尋中</span>' : '') + '</div>' +
'<div class="ks">' + (l.narrative ? esc(l.narrative) : '<span class="dim">(此庫尚無 narrative——recompute 時可帶入)</span>') + '</div>' +
(ents && ents.length ? '<div style="display:flex;gap:6px;flex-wrap:wrap">' + lmEntityLine(ents) + '</div>' : '') +
'<div class="km"><span class="mono" style="color:var(--amber)">' + (Number(l.triplet_count) || 0) + ' 三元組</span>' +
'<span class="dim" style="margin-left:auto;font-size:12.5px">' + stamp + '</span></div></div>';
}).join('');
}
function renderLmBridges(details) {
// 跨庫橋:詳圖 bridges[{entity, libraries[]}],多庫詳圖去重後列一小區;沒資料就整區不出
var seen = {}, rows = [];
Object.keys(details).forEach(function (k) {
(details[k].bridges || []).forEach(function (b) {
if (!b || !b.entity || seen[b.entity]) return;
seen[b.entity] = true;
rows.push(b);
});
});
if (!rows.length) { $('lm-bridges').innerHTML = ''; return; }
$('lm-bridges').innerHTML = '<div style="margin-top:14px;padding:14px 16px;border-radius:12px;background:rgba(var(--ok-rgb),.05);border:1px solid rgba(var(--ok-rgb),.2)">' +
'<div style="font-size:13px;letter-spacing:.14em;color:rgba(var(--ink-rgb),.5);margin-bottom:8px">跨庫橋(同一 entity 出現在多個庫)</div>' +
'<div style="display:flex;gap:8px;flex-wrap:wrap">' + rows.slice(0, 12).map(function (b) {
return '<span class="tag green">' + esc(b.entity) + '</span><span class="dim" style="font-size:12.5px;align-self:center">' + esc((b.libraries || []).join(' ↔ ')) + '</span>';
}).join('<span style="width:6px"></span>') + '</div></div>';
}
function loadLibraryMap() {
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) {
lmHonest('讀不到藏書地圖', esc(x.d.error || ('HTTP ' + x.status)) + '<br>不影響下方搜尋,可直接搜全庫。');
return;
}
var libs = x.d.libraries || [];
if (!libs.length) {
lmHonest('還沒有藏書地圖', '這個租戶目前沒有任何三元組資料(地圖是查詢時即時核對重算的,不是要人手動 backfill——資料一進來下次載入就會出現)。<br>不影響下方搜尋,可直接搜全庫。');
return;
}
LM.libs = libs; LM.details = {};
renderLmCards(libs, {});
Promise.allSettled(libs.map(function (l) {
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 = {};
rs.forEach(function (r) {
if (r.status === 'fulfilled' && r.value && r.value.map && r.value.map.library) details[r.value.map.library] = r.value.map;
});
LM.details = details;
renderLmCards(libs, details);
renderLmBridges(details);
});
})
.catch(function (e) { lmHonest('地圖服務不可達', esc(friendlyErr(e)) + '<br>不影響下方搜尋,可直接搜全庫。'); });
}
// 點庫卡片=設 library filter/kbdb/search 既有 library 參數,多值逗號分隔——此處單庫)進庫搜尋
$('lm-cards').addEventListener('click', function (ev) {
var t = ev.target.closest('[data-lib]');
if (!t) return;
S.library = t.getAttribute('data-lib');
renderLibBar();
if (LM.libs) renderLmCards(LM.libs, LM.details);
$('se-q').focus();
if ($('se-q').value.trim()) doSearch();
toast('已鎖定庫「' + S.library + '」——輸入關鍵字搜這個庫');
});
function renderLibBar() {
$('se-libbar').innerHTML = S.library
? '<div style="margin-top:10px;display:flex;align-items:center;gap:8px;font-size:14px">' +
'<span class="tag">庫 ' + esc(S.library) + '</span>' +
'<button class="chip" id="se-libclear" style="padding:5px 12px;font-size:13px">✕ 清除庫篩選(搜全館)</button></div>'
: '';
var clear = document.getElementById('se-libclear');
if (clear) clear.addEventListener('click', function () {
S.library = '';
renderLibBar();
if (LM.libs) renderLmCards(LM.libs, LM.details);
if ($('se-q').value.trim()) doSearch();
});
}
function renderChips() {
$('se-chips').innerHTML = CHIPS.map(function (c, i) {
return '<button class="chip' + (S.chip === c.v ? ' on' : '') + '" data-chip="' + i + '">' + esc(c.label) + '</button>';
}).join('');
}
$('se-chips').addEventListener('click', function (ev) {
var t = ev.target.closest('[data-chip]');
if (!t) return;
S.chip = CHIPS[Number(t.getAttribute('data-chip'))].v;
renderChips();
if ($('se-q').value.trim()) doSearch();
});
// 頭部統計=精耕層 live 數字(leo 2026-07-07 裁:45.8 萬 14-E 搬遷遺產已 deprecated、
// 之後要刪——不再拿遺產總數撐場面,只顯示真的新的)。搜尋本身仍搜全庫,資料不藏。
function loadScale() {
fetch(API_BASE + '/console/kb-scale-data')
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
// #100:讀不到就明說讀不到(原本靜默 return,會把上一輪的舊數字留在畫面上)
if (!d) { $('se-scale').textContent = '精耕層 讀不到(規模統計讀取失敗,不影響搜尋)'; return; }
var n = function (v) { return v == null ? '讀不到' : v.toLocaleString(); };
var parts = ['wiki 卡 ' + n(d.wiki_card_total), '三元組 ' + n(d.triplets_total), '已嵌入 ' + n(d.embedded)];
var latest = d.wiki_card_latest_ago_minutes;
$('se-scale').textContent = '精耕層 ' + parts.join('・') +
(latest != null && latest >= 0 ? '・最近寫入 ' + ckAge(latest) : '');
})
.catch(function () { $('se-scale').textContent = '精耕層 讀不到(規模統計讀取失敗,不影響搜尋)'; });
}
$('se-sem').addEventListener('click', function () {
S.semantic = !S.semantic;
$('se-sem').classList.toggle('on', S.semantic);
if ($('se-q').value.trim()) doSearch();
});
$('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();
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();
if (!body) body = lines.join(' ');
if (body.length > 140) body = body.slice(0, 140) + '…';
return body;
}
function doSearch() {
var q = $('se-q').value.trim();
if (!q) { $('se-count').textContent = '請輸入查詢字'; return; }
$('se-banner').innerHTML = '';
$('se-count').textContent = '查詢中…';
$('se-results').innerHTML = '';
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);
fetch(url, { 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) { $('se-count').innerHTML = '<span class="err">' + esc(x.d.error || ('查詢失敗(HTTP ' + x.status + '')) + '</span>'; return; }
var d = x.d;
if (S.semantic && d.mode === 'keyword') {
// 2026-08-09 leo:語意搜尋是安裝即提供的功能,降級=故障,不說「尚未啟用」。
$('se-banner').innerHTML = '<div class="honest" style="margin-top:18px"><div class="h">語意搜尋目前故障</div><div class="b">' + esc(d.capability_hint || '語意搜尋目前故障(實例缺 Vectorize/AI 設定),以下先給關鍵字結果,不假裝是語意結果。') + '<br>維運資訊:' + esc(d.admin_hint || '(此版本後端未回報細節)') + '</div></div>';
}
var entries = d.entries || [];
$('se-count').textContent = '命中 ' + entries.length + ' 筆・模式 ' + (d.mode || 'keyword') +
'・搜尋範圍=' + (S.library ? '庫「' + S.library + '」' : '全庫(含 14-E 遺產)');
if (!entries.length) {
$('se-results').innerHTML = '<div class="muted" style="padding:30px 10px;text-align:center;grid-column:1/-1">找不到「' + esc(q) + '」——換個關鍵字試試。</div>';
return;
}
$('se-results').innerHTML = entries.map(function (e) {
// 精耕層(wiki_card)綠標突顯;其餘照 entry_type 原樣(不誤標——block/value/note
// 大宗是 14-E 遺產,但 entry_type 本身不足以斷言,僅精耕層正向標記)
var typeTag = e.entry_type === 'wiki_card'
? '<span class="tag green">wiki_card・精耕</span>'
: '<span class="tag">' + esc(e.entry_type || 'entry') + '</span>';
return '<div class="kcard" data-card="' + esc(e.id) + '">' +
'<div class="kt">' + esc(entryTitle(e)) + '</div>' +
'<div class="ks">' + esc(entrySnippet(e)) + '</div>' +
'<div class="km">' + typeTag +
(e.source ? '<span class="dim">' + esc(e.source) + '</span>' : '') +
'<span class="dim mono" style="margin-left:auto">' + esc(fmtDate(e.created_at)) + '</span></div></div>';
}).join('');
})
.catch(function (e) { $('se-count').innerHTML = '<span class="err">請求失敗:' + esc(friendlyErr(e)) + '</span>'; });
}
$('se-results').addEventListener('click', function (ev) {
var t = ev.target.closest('[data-card]');
if (t) nav('card', t.getAttribute('data-card'));
});
// ── 卡片詳頁 + 關聯 ──
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(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; }
var e = x.d.entry;
var title = entryTitle(e);
var html = '<div class="cardtitle">' + esc(title) + '</div>' +
'<div style="display:flex;gap:10px;align-items:center;margin-top:12px;font-size:13.5px;flex-wrap:wrap">' +
'<span class="tag">' + esc(e.entry_type || 'entry') + '</span>' +
(e.page_name ? '<span class="tag green">' + esc(e.page_name) + '</span>' : '') +
'<span class="muted mono">' + esc(fmtDateTime(e.created_at)) + '</span></div>' +
'<div class="md">' + mdRender(e.content || '(此條目沒有內文)') + '</div>' +
(e.source
? '<div class="srcline"><span style="font-size:13px;letter-spacing:.14em;color:rgba(var(--ink-rgb),.5);flex:none">來源回溯</span><span class="mono" style="font-size:14px;color:var(--amber);word-break:break-all">' + esc(e.source) + '</span></div>'
: '');
$('cd-main').innerHTML = html;
var nodeName = e.page_name || title;
S.graphCenter = nodeName;
loadNeighbors(nodeName);
})
.catch(function (er) { $('cd-main').innerHTML = '<div class="err" style="padding:24px 0">請求失敗:' + esc(friendlyErr(er)) + '</div>'; renderGraphEmpty('請求失敗'); });
}
function renderGraphEmpty(msg) {
$('cd-graph').innerHTML = '<div class="graphbox" style="display:grid;place-items:center;height:160px"><span class="muted" style="font-size:14px;padding:0 20px;text-align:center">' + esc(msg) + '</span></div>';
$('cd-neighbors').innerHTML = '';
}
function loadNeighbors(name) {
renderGraphEmpty('查詢關聯中…');
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; }
var neighbors = x.d.neighbors || [];
var edges = x.d.edges || [];
if (!neighbors.length) { renderGraphEmpty('尚無關聯資料——這張卡還沒有 triplet 連進知識圖譜。'); return; }
renderGraph(name, neighbors.slice(0, 10));
$('cd-neighbors').innerHTML = edges.slice(0, 12).map(function (t) {
var other = t.subject === name ? t.object : t.subject;
return '<div class="nbrow" data-gnode="' + esc(other) + '">' +
'<span class="tag green" style="flex:none">' + esc(t.predicate || '關聯') + '</span>' +
'<span style="word-break:break-word">' + esc(other) + '</span>' +
'<span style="margin-left:auto;color:rgba(var(--amber-rgb),.6);flex:none"></span></div>';
}).join('');
})
.catch(function (e) { renderGraphEmpty('關聯服務不可達:' + friendlyErr(e)); });
}
function renderGraph(center, nodes) {
var n = nodes.length || 1;
var lines = '', chips = '';
for (var i = 0; i < n; i++) {
var a = (-90 + i * (360 / n)) * Math.PI / 180;
var x = 50 + 39 * Math.cos(a), y = 50 + 37 * Math.sin(a);
lines += '<line x1="50" y1="50" x2="' + x.toFixed(2) + '" y2="' + y.toFixed(2) + '" stroke="rgba(var(--ok-rgb),.3)" stroke-width="1" vector-effect="non-scaling-stroke" stroke-dasharray="4 4"/>';
chips += '<div class="gnode" style="left:' + x.toFixed(2) + '%;top:' + y.toFixed(2) + '%" data-gnode="' + esc(nodes[i]) + '">' + esc(nodes[i]) + '</div>';
}
var shortCenter = center.length > 16 ? center.slice(0, 16) + '…' : center;
$('cd-graph').innerHTML = '<div class="graphbox">' +
'<svg viewBox="0 0 100 100" preserveAspectRatio="none" style="position:absolute;inset:0;width:100%;height:100%">' + lines + '</svg>' +
'<div class="gcenter"><span>' + esc(shortCenter) + '</span></div>' + chips +
'<div style="position:absolute;right:12px;bottom:10px;font-size:11.5px;color:rgba(var(--ink-rgb),.35)">╌ 知識圖譜鄰居(點節點展開)</div></div>';
}
document.addEventListener('click', function (ev) {
var t = ev.target.closest('[data-gnode]');
if (!t) return;
var name = t.getAttribute('data-gnode');
S.graphCenter = name;
loadNeighbors(name);
});
// ── 工作流 ──
function loadWorkflows() {
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; }
S.wfCache = x.d.workflows || [];
$('wf-count').textContent = S.wfCache.length + ' 條自動化';
renderWorkflows();
})
.catch(function (e) { $('wf-list').innerHTML = '<div class="err">請求失敗:' + esc(friendlyErr(e)) + '</div>'; });
}
$('wf-q').addEventListener('input', function () { renderWorkflows(); });
function renderWorkflows(openName) {
var list = S.wfCache || [];
var q = $('wf-q').value.trim().toLowerCase();
if (q) list = list.filter(function (w) { return (w.name + ' ' + (w.description || '')).toLowerCase().indexOf(q) >= 0; });
list = list.slice().sort(function (a, b) { return (S.pins[b.name] ? 1 : 0) - (S.pins[a.name] ? 1 : 0); });
if (!list.length) {
$('wf-list').innerHTML = '<div class="honest"><div class="h">' + (q ? '沒有符合的工作流' : '還沒有任何工作流') + '</div><div class="b">' + (q ? '換個關鍵字試試。' : '用 acr push 部署第一條 workflow 後,這裡就會出現。') + '</div></div>';
return;
}
$('wf-list').innerHTML = list.map(function (w) {
var pinned = !!S.pins[w.name];
var open = w.name === openName;
return '<div class="card-item' + (pinned ? ' hl' : '') + '" data-wf="' + esc(w.name) + '">' +
'<div class="rowhead" data-wftoggle="' + esc(w.name) + '">' +
'<button class="pinbtn' + (pinned ? ' on' : '') + '" data-wfpin="' + esc(w.name) + '" title="釘選">' + (pinned ? '★' : '☆') + '</button>' +
'<div style="min-width:0"><div style="font-size:17px;font-weight:600;line-height:1.4;word-break:break-word">' + esc(w.name) + '</div>' +
'<div style="margin-top:3px;font-size:13.5px;color:rgba(var(--ink-rgb),.5)">' + esc(w.cron_expr ? '排程 ' + w.cron_expr : '事件觸發') + (w.created_at ? '・建立 ' + esc(fmtDate(w.created_at)) : '') + '</div></div>' +
'<span style="margin-left:auto;color:rgba(var(--amber-rgb),.6);flex:none">' + (open ? '▾' : '') + '</span></div>' +
(open ? renderWfBody(w) : '') + '</div>';
}).join('');
if (openName) fetchWfRuns(openName);
}
function renderWfBody(w) {
return '<div class="rowbody">' +
'<div style="margin-top:14px;font-size:15.5px;line-height:1.7;color:rgba(var(--ink-rgb),.7)">' + (w.description ? esc(w.description) : '<span class="dim">(未填描述——re-deploy 時補一句「能做什麼」它才搜得到)</span>') + '</div>' +
'<div class="urlbox" style="margin-top:14px"><span style="font-size:12.5px;letter-spacing:.1em;color:rgba(var(--ink-rgb),.45);flex:none">觸發網址</span>' +
'<span class="u">' + esc(w.webhook_url || '') + '</span>' +
'<button class="btn2" style="margin-left:auto;flex:none;padding:7px 13px;font-size:13.5px" data-wfcopy="' + esc(w.webhook_url || '') + '">複製</button></div>' +
'<div style="margin-top:14px;font-size:13px;letter-spacing:.14em;color:rgba(var(--ink-rgb),.5)">最近執行</div>' +
'<div id="wf-runs" style="margin-top:8px;display:flex;flex-direction:column;gap:6px"><span class="dim" style="font-size:14px">載入中…</span></div>' +
'<button class="btn" style="margin-top:16px;width:100%" data-wfrun="' + esc(w.name) + '">手動觸發</button>' +
'<div id="wf-runmsg" style="margin-top:8px;font-size:14px;min-height:1.2em"></div></div>';
}
var wfOpen = '';
$('wf-list').addEventListener('click', function (ev) {
var pin = ev.target.closest('[data-wfpin]');
if (pin) {
var name = pin.getAttribute('data-wfpin');
if (S.pins[name]) delete S.pins[name]; else S.pins[name] = true;
localStorage.setItem('arcrun_console_pins', JSON.stringify(S.pins));
renderWorkflows(wfOpen);
ev.stopPropagation();
return;
}
var copy = ev.target.closest('[data-wfcopy]');
if (copy) {
var url = copy.getAttribute('data-wfcopy');
(navigator.clipboard ? navigator.clipboard.writeText(url) : Promise.reject()).then(
function () { toast('已複製觸發網址'); },
function () { toast('複製失敗——手動選取網址'); }
);
ev.stopPropagation();
return;
}
var run = ev.target.closest('[data-wfrun]');
if (run) { triggerWf(run.getAttribute('data-wfrun'), run); ev.stopPropagation(); return; }
var tog = ev.target.closest('[data-wftoggle]');
if (tog) {
var n = tog.getAttribute('data-wftoggle');
wfOpen = wfOpen === n ? '' : n;
renderWorkflows(wfOpen);
}
});
function fetchWfRuns(name) {
var box = document.getElementById('wf-runs');
if (!box) return;
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) || [];
if (!runs.length) { box.innerHTML = '<span class="dim" style="font-size:14px">尚無執行紀錄</span>'; return; }
box.innerHTML = runs.map(function (r) {
var okRun = r.verdict === 'success';
return '<div style="display:flex;gap:10px;align-items:baseline;font-size:14.5px">' +
'<span style="flex:none;font-size:10px;color:' + (okRun ? 'var(--ok)' : 'var(--err)') + '">●</span>' +
'<span style="color:rgba(var(--ink-rgb),.7)">' + (okRun ? '成功' : '失敗') + (typeof r.duration_ms === 'number' ? '・' + r.duration_ms + 'ms' : '') + (r.message && !okRun ? '・' + esc(String(r.message).slice(0, 60)) : '') + '</span>' +
'<span class="dim mono" style="margin-left:auto;font-size:12.5px">' + esc(r.recorded_at ? fmtDateTime(r.recorded_at) : (r.timestamp ? fmtDateTime(Number(r.timestamp)) : '')) + '</span></div>';
}).join('');
})
.catch(function () { box.innerHTML = '<span class="dim" style="font-size:14px">執行紀錄讀取失敗</span>'; });
}
function triggerWf(name, btn) {
btn.disabled = true; btn.textContent = '執行中…';
var msg = document.getElementById('wf-runmsg');
fetch(API_BASE + '/webhooks/named/' + encodeURIComponent(name) + '/trigger', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, apiHeaders()),
body: '{}'
})
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
.then(function (x) {
btn.disabled = false; btn.textContent = '手動觸發';
if (x.ok && x.d.success !== false) {
if (msg) msg.innerHTML = '<span class="ok">✓ 執行成功' + (typeof x.d.duration_ms === 'number' ? '・' + x.d.duration_ms + 'ms' : '') + '</span>';
toast('「' + name + '」執行完成');
} else {
if (msg) msg.innerHTML = '<span class="err">✕ ' + esc(x.d.error || '執行失敗') + '</span>';
}
fetchWfRuns(name);
})
.catch(function (e) {
btn.disabled = false; btn.textContent = '手動觸發';
if (msg) msg.innerHTML = '<span class="err">請求失敗:' + esc(friendlyErr(e)) + '</span>';
});
}
// ── 零件 / recipes(v0 搜尋台保留區)──
$('cr-go').addEventListener('click', doCompSearch);
$('cr-q').addEventListener('keydown', function (ev) { if (ev.key === 'Enter') doCompSearch(); });
function doCompSearch() {
var q = $('cr-q').value.trim();
if (!q) return;
var out = $('cr-out');
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(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];
html += '<div class="dim" style="font-size:12.5px;letter-spacing:.1em;margin:10px 0 6px">COMPONENTS</div>';
if (comp.status === 'fulfilled' && comp.value.ok) {
var results = (comp.value.d.data && comp.value.d.data.results) || [];
html += results.length
? results.map(function (c) { return '<div class="card-item" style="padding:10px 14px;margin-bottom:6px;font-size:14px"><span class="tag">' + esc(c.canonical_id || c.id) + '</span> <span class="muted">' + esc(c.description || c.name || '') + '</span></div>'; }).join('')
: '<div class="muted" style="font-size:14px">沒查到符合的 component</div>';
} else {
html += '<div class="err" style="font-size:14px">components 查詢失敗(零件庫不可達)</div>';
}
var rec = rs[1];
html += '<div class="dim" style="font-size:12.5px;letter-spacing:.1em;margin:12px 0 6px">RECIPES</div>';
if (rec.status === 'fulfilled' && rec.value.ok) {
var d = rec.value.d;
if (d.found === false) {
html += '<div class="muted" style="font-size:14px">' + esc(d.hint || '沒查到符合的 recipe') + '</div>';
} else {
var recipes = d.recipes || [];
html += recipes.length
? recipes.map(function (r) { return '<div class="card-item" style="padding:10px 14px;margin-bottom:6px;font-size:14px"><span class="tag">' + esc(r.canonical_id) + '</span> <span class="muted">' + esc(r.description || r.display_name || '') + '</span></div>'; }).join('')
: '<div class="muted" style="font-size:14px">沒查到符合的 recipe</div>';
}
} else {
html += '<div class="err" style="font-size:14px">recipes 查詢失敗</div>';
}
out.innerHTML = html;
});
}
// ── 憑證管理 ──
$('cred-addbtn').addEventListener('click', function () {
var f = $('cred-form');
f.style.display = f.style.display === 'none' ? '' : 'none';
});
$('cred-save').addEventListener('click', function () {
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; }
st.textContent = '保存中…';
fetch(API_BASE + '/credentials', {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json' }, apiHeaders()),
body: JSON.stringify({ name: name, value: value, service: service || undefined })
})
.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) { st.innerHTML = '<span class="err">' + esc(x.d.error || '保存失敗') + '</span>'; return; }
st.textContent = '';
$('cred-name').value = ''; $('cred-service').value = ''; $('cred-secret').value = '';
$('cred-form').style.display = 'none';
toast('已加密保存');
loadCreds();
})
.catch(function (e) { st.innerHTML = '<span class="err">請求失敗:' + esc(friendlyErr(e)) + '</span>'; });
});
var credOpen = '';
var credCache = [];
function loadCreds() {
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) {
$('cred-list').innerHTML = '<div class="honest"><div class="h">目錄讀取失敗</div><div class="b">' + esc(x.d.error || '未知錯誤') + '</div></div>';
return;
}
credCache = x.d.credentials || [];
renderCreds();
})
.catch(function (e) { $('cred-list').innerHTML = '<div class="err">請求失敗:' + esc(friendlyErr(e)) + '</div>'; });
}
function renderCreds() {
if (!credCache.length) {
$('cred-list').innerHTML = '<div class="honest"><div class="h">還沒有任何憑證</div><div class="b">按「+ 新增憑證」加入第一筆。目錄只存名稱與服務,密文進 Workers Secrets、不可回看。</div></div>';
return;
}
$('cred-list').innerHTML = credCache.map(function (c) {
var open = credOpen === c.name;
var initial = (c.service || c.name || '?').slice(0, 1).toUpperCase();
return '<div class="card-item' + (open ? ' hl' : '') + '">' +
'<div class="rowhead" data-credtoggle="' + esc(c.name) + '">' +
'<span class="avatar">' + esc(initial) + '</span>' +
'<div style="min-width:0"><div style="font-size:16.5px;font-weight:600;word-break:break-all">' + esc(c.name) + '</div>' +
'<div style="margin-top:2px;font-size:13.5px;color:rgba(var(--ink-rgb),.5)">' + esc(c.service || '未填服務') + '・建立於 ' + esc(fmtDateTime(c.created_at) || '—') + '(台北)</div></div>' +
'<div style="margin-left:auto;text-align:right;font-size:12.5px;color:rgba(var(--ink-rgb),.4);flex:none">最後使用<br>' + esc(c.last_used_at ? fmtDateTime(c.last_used_at) : '尚未記錄') + '</div></div>' +
(open
? '<div class="rowbody">' +
'<div class="maskline" style="margin-top:14px"><span class="mono" style="font-size:16px;letter-spacing:.3em;color:rgba(var(--ink-rgb),.4)">••••••••••••••••</span><span style="margin-left:auto;font-size:12.5px;color:rgba(var(--ink-rgb),.4)">已加密・不可回看</span></div>' +
'<textarea class="txt" id="cred-newval" placeholder="貼上新值以整筆替換…" rows="2" style="margin-top:10px"></textarea>' +
'<div style="display:flex;gap:10px;margin-top:10px;flex-wrap:wrap">' +
'<button class="btn" style="flex:1;min-width:140px" data-credreplace="' + esc(c.name) + '">整筆替換</button>' +
'<button class="btn-danger" disabled title="刪除端點尚未接上新目錄(T9 範圍)">刪除・即將開通</button></div>' +
'<div id="cred-msg" style="margin-top:8px;font-size:14px;min-height:1.2em"></div></div>'
: '') +
'</div>';
}).join('');
}
$('cred-list').addEventListener('click', function (ev) {
var rep = ev.target.closest('[data-credreplace]');
if (rep) {
var name = rep.getAttribute('data-credreplace');
var ta = document.getElementById('cred-newval');
var msg = document.getElementById('cred-msg');
var val = ta ? ta.value : '';
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(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 })
})
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
.then(function (x) {
rep.disabled = false;
if (!x.ok || x.d.success === false) { if (msg) msg.innerHTML = '<span class="err">' + esc(x.d.error || '替換失敗') + '</span>'; return; }
credOpen = '';
toast('「' + name + '」已整筆替換');
loadCreds();
})
.catch(function (e) { rep.disabled = false; if (msg) msg.innerHTML = '<span class="err">請求失敗:' + esc(friendlyErr(e)) + '</span>'; });
return;
}
var tog = ev.target.closest('[data-credtoggle]');
if (tog) {
var n = tog.getAttribute('data-credtoggle');
credOpen = credOpen === n ? '' : n;
renderCreds();
}
});
// ── 分流台(原收件匣,Arcrun#9todoinbox 二源、三欄 80/15/5、per-project chips)──
// 頁面只渲染 /console/triage-data 的結果(分欄/計數判定在後端純函式);chips 切換與
// 「顯示已完成」是同一包資料的前端過濾,零額外請求(leo 手機看,省來回)。
var TG = { data: null, project: '', showDone: false };
var TG_TIERS = [
{ key: 'ai', nm: 'AI 會幫我搞定', pc: '80%', empty: '這欄空的——還沒有分給 AI 的待辦' },
{ key: 'collab', nm: '協作・AI 做完你接手', pc: '15%', empty: '這欄空的——沒有要你配合的事' },
{ key: 'leo', nm: '非我不可', pc: '5%', empty: '這欄空的——沒有非你不可的事' }
];
function triItem(m) {
var done = m.status === 'done';
var meta = '';
if (m.marker) meta += '<span class="tag dim">' + esc(m.marker) + '</span>';
meta += '<span class="tag">' + esc(m.project || '未分項') + '</span>';
if (m.source) meta += '<span>' + esc(m.source) + '</span>';
if (m.unclassified && m.origin === 'todo') meta += '<span class="tag red">未分流</span>';
// 誤勾救濟:done 清單裡給「還原」(status 回 new + 移除 checked_via/checked_at,端點同一個)
if (done) meta += '<button class="tri-restore" data-trirestore="' + esc(m.id) + '">還原</button>';
meta += '<span class="mono" style="margin-left:auto">' + esc(fmtDateTime(m.at)) + '</span>';
// 勾掉=雙向銷案的 console 端終局(checked_via:console,萃取端絕不復活);
// 後端回寫成功才改本地狀態移出三欄(done 預設隱藏),不樂觀假裝
var body = done
? '<div class="tx">' + esc(m.text) + '</div>'
: '<div class="row"><div class="tx">' + esc(m.text) + '</div><button class="tri-check" data-tricheck="' + esc(m.id) + '" title="勾掉(銷案)">✓</button></div>';
return '<div class="tri-item ' + (done ? 'done' : 'new') + '">' + body + '<div class="mt">' + meta + '</div></div>';
}
function triChip(label, val, on) {
return '<button class="chip' + (on ? ' on' : '') + '" data-triproj="' + esc(val) + '">' + esc(label) + '</button>';
}
function renderTriage() {
var d = TG.data || {};
var items = d.items || [];
var out = $('tg-out');
// per-project chips:全部+各 project+(有未分項才出)未分項
var chips = triChip('全部', '', TG.project === '');
(d.projects || []).forEach(function (p) { chips += triChip(p, p, TG.project === p); });
if (items.some(function (i) { return !i.project; })) chips += triChip('未分項', '__none__', TG.project === '__none__');
$('tg-chips').innerHTML = items.length ? chips : '';
if (!items.length) {
out.innerHTML = '<div class="honest" style="margin-top:14px"><div class="h">分流台是空的</div><div class="b">資料源=KBDB todo entriesLogseq 萃取,ingest 灌入中)+ inbox entriesTelegram)。<br>寫入方尚未灌資料時這裡誠實顯示空。</div></div>';
return;
}
var vis = items.filter(function (i) {
return TG.project === '' || (TG.project === '__none__' ? !i.project : i.project === TG.project);
});
var doneN = vis.filter(function (i) { return i.status === 'done'; }).length;
var html = '';
TG_TIERS.forEach(function (t) {
var news = vis.filter(function (i) { return i.tier === t.key && i.status !== 'done'; });
var dones = TG.showDone ? vis.filter(function (i) { return i.tier === t.key && i.status === 'done'; }) : [];
html += '<div class="tri-sec ' + t.key + '"><div class="tri-sechead"><span class="dot"></span><span class="nm serif">' + t.nm + '</span><span class="pc">' + t.pc + '</span><span class="ct">' + news.length + ' 件</span></div>';
if (!news.length && !dones.length) html += '<div class="muted" style="font-size:14px;padding:10px 2px 0">' + t.empty + '</div>';
html += news.map(triItem).join('') + dones.map(triItem).join('');
html += '</div>';
});
html += '<div style="margin:24px 0 20px;text-align:center"><button class="btn3" id="tg-donetoggle" style="padding:9px 18px;font-size:13.5px;border-radius:999px">' +
(TG.showDone ? '隱藏已完成' : '顯示已完成(' + doneN + '') + '</button></div>';
out.innerHTML = html;
$('tg-donetoggle').addEventListener('click', function () { TG.showDone = !TG.showDone; renderTriage(); });
}
$('tg-chips').addEventListener('click', function (ev) {
var t = ev.target.closest('[data-triproj]');
if (!t) return;
TG.project = t.getAttribute('data-triproj');
renderTriage();
});
// 勾掉/還原:POST /console/triage-checkconsole session 保護的小端點——瀏覽器沒有 KBDB
// tokenPATCH 由 server 端做,先 GET 原 entry 整串回寫防蓋掉別的欄位)。成功才動本地
// 狀態重繪(該筆立即移出/回到三欄),失敗誠實 toast、按鈕解鎖可重試。
function triageAct(id, action, btn) {
btn.disabled = true;
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 })
})
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
.then(function (x) {
if (!x.ok) { btn.disabled = false; toast(x.d.error || ('失敗(HTTP ' + x.status + '')); return; }
var items = (TG.data && TG.data.items) || [];
for (var i = 0; i < items.length; i++) {
if (items[i].id === id) items[i].status = (action === 'restore' ? 'new' : 'done');
}
renderTriage();
toast(action === 'restore' ? '已還原' : '已勾掉');
})
.catch(function (e) { btn.disabled = false; toast('請求失敗:' + friendlyErr(e)); });
}
$('tg-out').addEventListener('click', function (ev) {
var chk = ev.target.closest('[data-tricheck]');
if (chk) { triageAct(chk.getAttribute('data-tricheck'), 'check', chk); return; }
var rst = ev.target.closest('[data-trirestore]');
if (rst) triageAct(rst.getAttribute('data-trirestore'), 'restore', rst);
});
function loadTriage() {
var out = $('tg-out');
out.innerHTML = '<div class="muted" style="padding:14px 0">載入中…</div>';
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) {
out.innerHTML = '<div class="honest" style="margin-top:14px"><div class="h">讀不到分流台</div><div class="b">' + esc(x.d.error || ('HTTP ' + x.status)) + '</div></div>';
return;
}
TG.data = x.d;
// 記著的 project 篩選若已不存在(資料變了)→ 退回全部,別讓頁面空得不明不白
if (TG.project && TG.project !== '__none__' && (x.d.projects || []).indexOf(TG.project) < 0) TG.project = '';
renderTriage();
})
.catch(function (e) { out.innerHTML = '<div class="err" style="padding:14px 0">請求失敗:' + esc(friendlyErr(e)) + '</div>'; });
}
// ── 設定 ──
function loadSettings() {
// vectorize 狀態:探測既有 /kbdb/search 的 mode / capability_hint(無新端點,誠實讀降級訊號)
fetch(API_BASE + '/kbdb/search?q=mira&mode=semantic', { headers: apiHeaders() })
.then(function (r) { return r.json(); })
.then(function (d) {
// t36:狀態照實顯示(live 探測 mode,不是讀設定值)。啟用時不再顯示任何操作指示——
// 沒有東西要用戶操作;未啟用才給一句人話與下一步。
// 2026-08-09 leo:語意搜尋是安裝即提供的功能——探測到降級=這台實例壞了,
// 照實標「故障」,不說「尚未啟用」(那會把 bug 說成沒提供的功能)。
var on = d.mode === 'semantic';
$('st-vec').textContent = on
? '● 正常——搜尋頁切到「語意」就能用意思找資料。'
: '○ 故障——語意搜尋是內建功能,這台實例現在少了它(系統端問題,不是操作問題)。';
var hint = $('st-vec-hint');
if (on) {
hint.style.display = 'none';
} else {
hint.style.display = '';
hint.innerHTML = '修復方式:重新跑一次安裝流程(用原本的 Cloudflare 帳號),會把缺的語意索引設定補回來;已建好的資料不會重來。'
+ (d.admin_hint ? '<br>維運資訊:' + esc(d.admin_hint) : '');
}
})
.catch(function () {
$('st-vec').textContent = '狀態偵測失敗(知識庫服務目前連不上)';
var hint = $('st-vec-hint');
hint.style.display = '';
hint.textContent = '這通常是暫時的,稍後重新整理這一頁再看。';
});
// MCP token TTL(誠實佔位:只顯示目前生效值,不假裝能改)
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; }
var days = Math.round(d.mcp_token_ttl_seconds / 86400 * 10) / 10;
$('st-mcp-ttl').textContent = '目前生效:' + d.mcp_token_ttl_seconds.toLocaleString() + ' 秒(約 ' + days + ' 天)・' +
(d.mcp_token_ttl_source === 'env' ? '來自部署端 env MCP_TOKEN_TTL' : '未設 env,用預設值 30 天');
})
.catch(function () { $('st-mcp-ttl').textContent = '讀取失敗'; });
// 系統資訊:worker 自報 + 精耕層規模(14-E 遺產總數不再顯示,leo 2026-07-07 裁)
Promise.allSettled([
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;
var n = function (v) { return v == null ? '讀不到' : v.toLocaleString(); };
var rows = '';
rows += '<div class="kvline"><span class="muted">服務</span><span class="mono" style="font-size:14px">' + esc(svc.service || 'arcrun-cypher-executor') + '</span></div>';
rows += '<div class="kvline"><span class="muted">版本</span><span class="mono" style="color:var(--amber)">' + esc(svc.version || '—') + '</span></div>';
rows += '<div class="kvline"><span class="muted">部署位置</span><span class="mono" style="font-size:14px">' + esc(location.host) + '</span></div>';
rows += '<div class="kvline"><span class="muted">租戶</span><span class="mono">' + esc(S.tenant) + '</span></div>';
rows += '<div class="kvline"><span class="muted">精耕層</span><span class="mono" style="color:var(--amber)">' + (kb ? 'wiki 卡 ' + n(kb.wiki_card_total) + '・三元組 ' + n(kb.triplets_total) + '・已嵌入 ' + n(kb.embedded) : '讀取失敗') + '</span></div>';
$('st-info').innerHTML = rows;
});
}
$('st-pw-save').addEventListener('click', function () {
var oldPw = $('st-pw-old').value, email = $('st-pw-email').value.trim(), newPw = $('st-pw-new').value;
var st = $('st-pw-status');
if (!oldPw) { st.innerHTML = '<span class="err">請先輸入舊密碼</span>'; return; }
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(API_BASE + '/console/setup/reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ current_password: oldPw, email: email, password: newPw })
})
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
.then(function (x) {
if (!x.ok) { st.innerHTML = '<span class="err">' + esc(x.d.error || '更新失敗') + '</span>'; return; }
st.innerHTML = '<span class="ok">密碼已更新,下次登入用新帳密</span>';
$('st-pw-old').value = ''; $('st-pw-email').value = ''; $('st-pw-new').value = '';
toast('密碼已更新');
})
.catch(function (e) { st.innerHTML = '<span class="err">請求失敗:' + esc(friendlyErr(e)) + '</span>'; });
});
// arcrun-rag#25portal admin 密碼救援——只吃 console owner sessionS.token,本頁登入用的
// 那把),不吃 portal session,所以就算忘記 portal 密碼、進不去 portal 也走得通。
$('st-portal-recover-btn').addEventListener('click', function () {
var email = $('st-portal-recover-email').value.trim();
var st = $('st-portal-recover-status');
if (!email) { st.innerHTML = '<span class="err">請輸入 Email</span>'; return; }
st.textContent = '處理中…';
fetch(API_BASE + '/portal/admin/recover-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + S.token },
body: JSON.stringify({ email: email })
})
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
.then(function (x) {
if (!x.ok) { st.innerHTML = '<span class="err">' + esc(x.d.error || '失敗') + '</span>'; return; }
st.innerHTML = '<span class="ok">新密碼:<code style="font-size:15px;user-select:all">' + esc(x.d.password) + '</code>(只顯示這一次,請立刻抄下)</span>';
$('st-portal-recover-email').value = '';
toast('新密碼已產生,請立刻抄下');
})
.catch(function (e) { st.innerHTML = '<span class="err">請求失敗:' + esc(friendlyErr(e)) + '</span>'; });
});
// t36:原本這裡綁在那顆假開關上(點了只會 toast 一段 CLI 指示)。開關已移除,
// 這個 handler 也必須一起拿掉——留著會讓 $('st-vec-switch') 回 null、addEventListener
// 當場拋錯,把後面所有綁定(含登出)一起打斷。
$('st-logout').addEventListener('click', function () {
var t = S.token;
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 = '';
checkConfigured();
});
init();
})();
</script>
</body>
</html>