19c82df05f
唯一 admin 忘記 portal 密碼就永久卡死:requirePortalAdmin 系列端點全部要 先有 portal session 才進得去,bootstrap 又只能跑一次——登入頁只會叫他 「聯絡管理員」,而他自己就是管理員,沒有下一步。 新增 POST /portal/admin/recover-password,複用 bootstrap 已在用的 console owner session 當人閘(與 portal 密碼完全獨立存放的另一組帳密)。畫面入口: /console → 設定 → 「Portal 帳號密碼救援」;/portal 登入頁加一行連結指過去。 本機真瀏覽器 E2E 驗證(wrangler dev 18787/18788 + 本機靜態伺服,真的走一輪 forgot-password 狀態):first-time setup 建帳號 → 故意打錯密碼確認鎖死 (email 或密碼錯誤)→ 點連結進 /console → 用 console 密碼登入 → 設定頁輸入 portal email → 產生新密碼 BPq2Rs4p7dBWMd6d → 回 /portal 用新密碼登入成功。 cypher-executor 4 個新測試 + 既有 59/60 綠(唯一失敗是既有 pre-existing /portal HTML 殼 404,與本次無關,git stash 驗證過)。
1576 lines
101 KiB
HTML
1576 lines
101 KiB
HTML
<!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-01(arcrun-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">
|
||
<!-- t36(leo 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';
|
||
// 台北時間 helper(lib/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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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*>\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+(.*)$/);
|
||
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();
|
||
// 隱藏頁/不存在頁 guard:hash 不在 VIEWS → 改寫成 HOME(hashchange 會再進來一次正常渲染)
|
||
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/map(cypher 代轉 KBDB 基本盤,kbdb-proxy.ts——同 /kbdb/search 慣例)。
|
||
// 兩段式渲染:先用全館列表(top_entities=名字 top3)立即出卡片,再逐庫拉 /kbdb/map/:library
|
||
// 補 degree/bridges/commit_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) {
|
||
if (!d) 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-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#9:todo+inbox 二源、三欄 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 entries(Logseq 萃取,ingest 灌入中)+ inbox entries(Telegram)。<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-check(console session 保護的小端點——瀏覽器沒有 KBDB
|
||
// token,PATCH 由 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#25:portal admin 密碼救援——只吃 console owner session(S.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>
|