e28e19069f
leo 07-29:「要在雲端子庫顯示同步了幾個 wiki,既然這樣也同步顯示有幾個三元組,
這是為了政府專案驗收。」
kbdb 兩支統計端點:
- entries.ts: COUNT(DISTINCT page_name) AS card_count
⚠️ 必須 distinct——算 block 數會膨脹 3-5 倍,政府驗收看到假數字比沒數字更糟
- records.ts: COUNT(*) AS triplet_count(三元組本來就算全部)
portal.ts 聚合兩者掛進庫目錄;前端 index.html 顯示。
驗:卡數確為 COUNT(DISTINCT page_name)/前端內嵌 JS node --check 全通過
(07-29 白畫面事故教訓)/portal-admin 測試 34 passed(改動前 31 passed,
唯一的 1 failed 是既有債:GET /portal 回 404,改動前後相同,非本次造成)。
註:此為子 CC 完成後未 commit 的懸置工作,總管收工檢查時發現並補收。
1764 lines
107 KiB
HTML
1764 lines
107 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 Portal</title>
|
||
<script>
|
||
// 主題預載(防閃色):預設淺色,選擇存 localStorage(與 console 同設計語言、不同 key)
|
||
document.documentElement.setAttribute('data-theme', (function () {
|
||
try { return localStorage.getItem('arcrun_portal_theme') === 'dark' ? 'dark' : 'light'; } catch (e) { return 'light'; }
|
||
})());
|
||
</script>
|
||
<style>
|
||
* { box-sizing: border-box; }
|
||
/* ── 主題色板(console 設計語言複製子集:淺=宣紙米白紙紋+墨字;深=紙感暖黑 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 { 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); }
|
||
|
||
/* ── auth 全屏(.view.on 的 block 會蓋 flex → 補 .authwrap.view.on,console 同修)── */
|
||
.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; }
|
||
.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); }
|
||
#tabbar .tab.hide { display: none; }
|
||
@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 .nav.hide { display: none; }
|
||
#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; }
|
||
.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; }
|
||
.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; }
|
||
.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); }
|
||
.panel { padding: 18px; border-radius: 13px; background: rgba(var(--ink-rgb),.04); border: 1px solid rgba(var(--ink-rgb),.12); }
|
||
.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); }
|
||
.tag.dim { background: rgba(var(--ink-rgb),.08); color: rgba(var(--ink-rgb),.55); }
|
||
.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; }
|
||
|
||
/* ── 搜尋 ── */
|
||
.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); }
|
||
.modebar { display: flex; gap: 8px; margin-top: 12px; }
|
||
.modebtn { flex: none; padding: 9px 18px; 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); }
|
||
.modebtn.on { border-color: rgba(var(--amber-rgb),.6); background: rgba(var(--amber-rgb),.14); color: var(--amber); font-weight: 600; }
|
||
.modebtn.hide { display: none; }
|
||
.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; }
|
||
|
||
/* ── 卡片詳頁 ── */
|
||
.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); }
|
||
|
||
/* ── graph 模式(console 關聯視圖同款)── */
|
||
.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; }
|
||
/* 總圖(Arcrun#39 GUI 切片):沿用紙感語言——green 章印節點、amber 高度數節點 */
|
||
.mapbox { position: relative; border-radius: 14px; background: var(--well); border: 1px solid rgba(var(--ink-rgb),.1); overflow: hidden; }
|
||
.mapbox svg { display: block; width: 100%; height: auto; }
|
||
.mapnode { cursor: pointer; }
|
||
.mapnode circle { fill: rgba(var(--ok-rgb),.12); stroke: rgba(var(--ok-rgb),.45); stroke-width: 1.4; transition: stroke-width .15s; }
|
||
.mapnode.hub circle { fill: rgba(var(--amber-rgb),.2); stroke: rgba(var(--amber-rgb),.65); }
|
||
.mapnode:hover circle { stroke-width: 3; }
|
||
.mapnode text { font-size: 12.5px; fill: var(--ink); pointer-events: none; }
|
||
.mapedge { stroke: rgba(var(--ink-rgb),.18); stroke-width: 1; stroke-dasharray: 4 4; }
|
||
|
||
/* ── 管理頁(P4)── */
|
||
.sechead { font-family: 'Songti TC','LiSong Pro',PMingLiU,serif; font-size: 19px; letter-spacing: .14em; color: var(--amber); margin: 26px 0 12px; padding-bottom: 8px; border-bottom: 1px solid rgba(var(--amber-rgb),.3); }
|
||
.formrow { display: flex; gap: 10px; flex-wrap: wrap; }
|
||
.formrow .txt { flex: 1; min-width: 180px; }
|
||
.formrow select { padding: 12px 14px; font-size: 15.5px; border-radius: 10px; border: 1px solid rgba(var(--ink-rgb),.2); background: rgba(var(--ink-rgb),.05); color: var(--ink); }
|
||
.btnrow { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
|
||
.btn2 { padding: 9px 14px; font-size: 14px; border-radius: 9px; border: 1px solid rgba(var(--amber-rgb),.45); background: rgba(var(--amber-rgb),.08); color: var(--amber); cursor: pointer; }
|
||
.btn2.warn { border-color: rgba(var(--err-rgb),.45); background: rgba(var(--err-rgb),.06); color: var(--err); }
|
||
.btn2:disabled { opacity: .45; cursor: default; }
|
||
.libgrid { display: flex; gap: 8px 14px; flex-wrap: wrap; margin-top: 10px; }
|
||
.libgrid label { display: flex; align-items: center; gap: 7px; font-size: 14.5px; cursor: pointer; padding: 6px 10px; border-radius: 8px; background: rgba(var(--ink-rgb),.05); border: 1px solid rgba(var(--ink-rgb),.12); }
|
||
.libgrid input[type=checkbox] { accent-color: var(--amber); width: 16px; height: 16px; }
|
||
.otpbox { margin-top: 14px; padding: 18px; border-radius: 13px; border: 2px solid rgba(var(--amber-rgb),.55); background: rgba(var(--amber-rgb),.08); }
|
||
.otpbox .pw { font-family: ui-monospace, Menlo, monospace; font-size: 22px; letter-spacing: .12em; color: var(--amber); word-break: break-all; user-select: all; margin: 10px 0; }
|
||
|
||
/* ── 清單 / 設定 ── */
|
||
.listcol { display: flex; flex-direction: column; gap: 10px; padding: 16px 0 8px; }
|
||
.card-item { border-radius: 13px; background: rgba(var(--ink-rgb),.045); border: 1px solid rgba(var(--ink-rgb),.1); padding: 16px 18px; }
|
||
.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>window.ARCRUN_API_BASE = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.apiBase) || "https://cypher.arcrun.dev";</script>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- 登入(未登入只見這個殼;本頁不含任何資料)-->
|
||
<div class="authwrap view" id="v-login">
|
||
<div class="authbox">
|
||
<div>
|
||
<div class="brand">Arcrun</div>
|
||
<div class="brand-sub">知識入口・Portal</div>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:12px;text-align:left">
|
||
<input type="email" id="login-email" class="txt" placeholder="Email" autocomplete="username" style="padding:15px 16px;font-size:17px;border-radius:11px">
|
||
<input type="password" id="login-password" class="txt" placeholder="密碼" autocomplete="current-password" 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">帳號由管理員發放。忘記密碼請聯絡管理員重設。</div>
|
||
<button class="btn3 themelabel" data-themetoggle style="align-self:center;padding:8px 16px;font-size:13.5px;border-radius:999px">☾ 切深色</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 首次設定(t49,leo 07-25:一鍵安裝的用戶打開專屬網址,第一件事就是在這裡建帳號,
|
||
不再需要回安裝器那頁。auth-status 說 configured:false 才會出現。)-->
|
||
<div class="authwrap view" id="v-firstsetup">
|
||
<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="fs-email" class="txt" placeholder="你的 Email" autocomplete="username" style="padding:15px 16px;font-size:17px;border-radius:11px">
|
||
<input type="password" id="fs-password" class="txt" placeholder="設定密碼(至少 8 碼)" autocomplete="new-password" style="padding:15px 16px;font-size:17px;border-radius:11px">
|
||
<input type="password" id="fs-password2" class="txt" placeholder="再輸入一次密碼" autocomplete="new-password" style="padding:15px 16px;font-size:17px;border-radius:11px">
|
||
<button class="btn" id="fs-submit" style="margin-top:6px;padding:15px;font-size:17px;letter-spacing:.12em">建立帳號並進入</button>
|
||
<div id="fs-status" class="err" style="font-size:14px;min-height:1.2em"></div>
|
||
<!-- leo 07-25 實撞:已設定過(409)時這頁是死路——必須有回登入的路 -->
|
||
<button class="btn3" id="fs-tologin" style="padding:9px 16px;font-size:14px;border-radius:999px;align-self:center;cursor:pointer">已經有帳號?改用登入</button>
|
||
</div>
|
||
<div style="font-size:13.5px;color:rgba(var(--ink-rgb),.4);line-height:1.7">這組帳密就是之後登入知識庫用的,只需要設定這一次。</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 主應用 -->
|
||
<div id="shell">
|
||
<div id="sidenav">
|
||
<div class="logo">Arcrun</div>
|
||
<div class="nav" data-nav="search">搜尋</div>
|
||
<div class="nav hide" id="nav-map" data-nav="map">總圖</div>
|
||
<div class="nav hide" id="nav-upload" data-nav="upload">上傳</div>
|
||
<div class="nav hide" id="nav-workflows" data-nav="workflows">工作流</div>
|
||
<div class="nav hide" id="nav-admin" data-nav="admin">管理</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-search">
|
||
<div class="pagehead"><span class="t">搜尋</span><span class="m" id="se-scope"></span></div>
|
||
<div style="padding-top:20px">
|
||
<div class="searchrow">
|
||
<input id="se-q" placeholder="搜尋知識…" autocomplete="off">
|
||
<button class="btn" id="se-go" style="flex:none;padding:0 22px;letter-spacing:.2em">搜</button>
|
||
</div>
|
||
<div class="modebar">
|
||
<button class="modebtn on" data-mode="keyword">關鍵字</button>
|
||
<button class="modebtn" data-mode="semantic">語意</button>
|
||
<button class="modebtn hide" id="mode-graph" data-mode="graph">圖譜</button>
|
||
</div>
|
||
<!-- AI 問答(portal-demo-suite)。設計哲學:AI 檢索=用戶手動搜尋同一套——
|
||
/portal/data/chat 與 /portal/data/search 走同一個 session 閘、同一個租戶資料面,
|
||
AI 只是代查再作答,不多開任何權限。此實例未安裝問答 workflow 時 server 誠實 404。-->
|
||
<div style="margin-top:14px;padding:14px;border-radius:13px;background:rgba(var(--amber-rgb),.05);border:1px solid rgba(var(--amber-rgb),.25)">
|
||
<div style="font-size:13.5px;letter-spacing:.1em;color:rgba(var(--ink-rgb),.5);margin-bottom:9px">AI 問答・答案附出處,可回搜尋驗證</div>
|
||
<div class="searchrow">
|
||
<input id="ai-q" placeholder="用一句話問知識庫…" autocomplete="off" style="padding:12px 15px;font-size:16px">
|
||
<button class="btn" id="ai-go" style="flex:none;padding:0 20px">問 AI</button>
|
||
</div>
|
||
<div id="ai-out"></div>
|
||
</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 id="se-graphout" style="display:none">
|
||
<div id="gr-box"></div>
|
||
<div id="gr-edges" style="margin-top:16px;display:flex;flex-direction:column;gap:7px"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 總圖(Arcrun#39 藏書地圖 GUI 切片;graph 粗閘後可見):全庫三元組一張網。
|
||
力導向佈局內聯手刻(demo 量級 ≤ 數百節點),不引外部套件(頁面自足原則)。-->
|
||
<div class="view page" id="v-map">
|
||
<div class="pagehead"><span class="t">總圖</span><span class="m" id="map-meta"></span></div>
|
||
<div style="padding-top:20px">
|
||
<div id="map-box"></div>
|
||
<div style="margin-top:12px;font-size:13.5px;color:rgba(var(--ink-rgb),.5)">
|
||
點節點可跳到該實體的圖譜搜尋。這張圖由知識庫的三元組即時算出——同一份地圖的文字版
|
||
(給 AI 注入用的總庫目錄)<span id="map-md-link"></span>。
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 卡片詳頁 -->
|
||
<div class="view page" id="v-card">
|
||
<div class="pagehead" style="justify-content:flex-start;gap:14px">
|
||
<button class="btn3" style="border:none;padding:0;font-size:16px;color:var(--amber)" data-nav="search">‹ 搜尋</button>
|
||
<span class="serif" style="font-size:17px;letter-spacing:.14em;color:rgba(var(--ink-rgb),.55)">知識卡片</span>
|
||
</div>
|
||
<div id="cd-main" style="padding:24px 0 40px"><div class="muted">載入中…</div></div>
|
||
</div>
|
||
|
||
<!-- 上傳(portal-demo-suite:upload bindings 齊全才顯示 nav——/portal/session 的
|
||
upload_enabled;藏只是 UX,真閘在 /portal/data/upload 路由層 404)-->
|
||
<div class="view page narrow" id="v-upload">
|
||
<div class="pagehead"><span class="t">上傳</span><span class="m">Markdown / 純文字</span></div>
|
||
<div style="margin-top:16px;padding:13px 16px;border-radius:11px;background:rgba(var(--amber-rgb),.06);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(--ink)">約 1 分鐘後可在搜尋頁找到</b>;AI 整理後會以 wiki 卡形式出現。支援 .md / .txt(一律以 .md 收件)。
|
||
</div>
|
||
<div id="up-drop" style="margin-top:16px;padding:44px 20px;border-radius:13px;border:2px dashed rgba(var(--amber-rgb),.4);background:rgba(var(--amber-rgb),.04);text-align:center;cursor:pointer">
|
||
<div style="font-size:16px;color:rgba(var(--ink-rgb),.7)">把 .md / .txt 檔案拖放到這裡</div>
|
||
<div class="muted" style="margin-top:8px;font-size:14px">或</div>
|
||
<button class="btn2" id="up-pick" style="margin-top:10px;padding:11px 22px;font-size:15px">選擇檔案(可多選)</button>
|
||
<input type="file" id="up-file" accept=".md,.txt" multiple style="display:none">
|
||
</div>
|
||
<div class="listcol" id="up-list"></div>
|
||
</div>
|
||
|
||
<!-- 工作流(D-8:預設 admin 才看得到;唯讀,不開 trigger)-->
|
||
<div class="view page narrow" id="v-workflows">
|
||
<div class="pagehead"><span class="t">工作流</span><span class="m">唯讀・系統狀態</span></div>
|
||
<div style="margin-top:16px;padding:13px 16px;border-radius:11px;background:rgba(var(--amber-rgb),.06);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(--ink)">不能觸發執行</b>(觸發屬系統擁有者權限)。
|
||
</div>
|
||
<div class="listcol" id="wf-list"><div class="muted">載入中…</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="font-size:17px;font-weight:600;margin-bottom:10px">我的帳號</div>
|
||
<div id="st-me"><div class="muted">載入中…</div></div>
|
||
</div>
|
||
<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)">預設淺色(紙感)。切換立即生效,選擇記在這台裝置。</div>
|
||
</div>
|
||
<div class="switch" id="st-theme-switch" data-themetoggle style="cursor:pointer"><i></i></div>
|
||
</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)">需輸入舊密碼驗證身分;新密碼至少 8 碼</div>
|
||
<div style="margin-top:14px;display:flex;flex-direction:column;gap:10px">
|
||
<input type="password" id="st-pw-old" class="txt" placeholder="舊密碼" autocomplete="current-password">
|
||
<input type="password" id="st-pw-new" class="txt" placeholder="新密碼(至少 8 碼)" autocomplete="new-password">
|
||
<input type="password" id="st-pw-new2" class="txt" placeholder="再輸入一次新密碼" autocomplete="new-password">
|
||
<button class="btn" id="st-pw-save">更新密碼</button>
|
||
<div id="st-pw-status" style="font-size:14px;min-height:1.2em"></div>
|
||
</div>
|
||
</div>
|
||
<!-- 07-27 leo:金鑰與下載原本只在一次性的「還差 N 步」卡片裡,按了「稍後再說」
|
||
或裝完就再也找不到。換金鑰/換電腦重新下載都需要常駐入口,故搬進設定頁。 -->
|
||
<div class="panel">
|
||
<!-- t87 07-28 leo:乾淨網址(只有 origin,不含 /portal/# 後綴),供小幫手連線用 -->
|
||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:14px;font-size:13.5px">
|
||
<span style="color:rgba(var(--ink-rgb),.6);white-space:nowrap">你的知識庫網址(小幫手連線用)</span>
|
||
<code id="st-origin-url" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;color:var(--ink)"></code>
|
||
<button class="btn3" id="st-copy-url" style="padding:5px 12px;font-size:13px;white-space:nowrap;flex:none">複製</button>
|
||
</div>
|
||
<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)">把你電腦上的資料夾變成知識庫。換電腦或重裝時可以再下載一次。</div>
|
||
<div style="margin-top:12px">
|
||
<a class="btn3" id="st-daemon-dl" href="#" style="display:inline-block;padding:11px 18px;border-radius:10px;text-decoration:none">下載 Mac 版</a>
|
||
<div style="margin-top:8px;font-size:13px;line-height:1.7;color:rgba(var(--ink-rgb),.5)">封測版未簽章,第一次請右鍵→打開。裝好第一次開啟時,貼上這個網址+你的帳號密碼就連上了。</div>
|
||
</div>
|
||
</div>
|
||
<!-- t131 合併 AI 設定(Gemini API Key 必填;Claude 加強版視 daemon 回報 enable) -->
|
||
<div class="panel" id="st-ai-panel">
|
||
<div style="font-size:17px;font-weight:600">AI 設定</div>
|
||
<div style="margin-top:14px;display:flex;flex-direction:column;gap:16px">
|
||
<!-- 第一格:Gemini API Key(必填,聊天+萃取共用) -->
|
||
<div>
|
||
<div style="font-size:14px;font-weight:500;margin-bottom:4px">Gemini API Key</div>
|
||
<div style="font-size:13px;line-height:1.6;color:rgba(var(--ink-rgb),.55);margin-bottom:8px">聊天問答與文件萃取都用這一把。<a href="https://aistudio.google.com/apikey" target="_blank" rel="noopener">免費申請</a>,金鑰只存在你自己的知識庫裡。</div>
|
||
<input type="password" id="st-ai-key" class="txt" placeholder="貼上 Gemini API Key" autocomplete="off">
|
||
</div>
|
||
<!-- 第二格:Claude 加強版(選填,依 daemon 回報 enable) -->
|
||
<div style="padding-top:12px;border-top:1px solid rgba(var(--ink-rgb),.08)">
|
||
<div style="font-size:14px;font-weight:500;margin-bottom:6px">讓知識卡整理得更好(選填)</div>
|
||
<label style="display:flex;gap:8px;align-items:flex-start;cursor:pointer">
|
||
<input type="checkbox" id="st-ai-use-claude" style="margin-top:3px;flex:none" disabled>
|
||
<span style="font-size:14px">本地萃取改用 Claude Code</span>
|
||
</label>
|
||
<div id="st-ai-claude-desc" style="margin-top:8px;font-size:13px;line-height:1.65;color:rgba(var(--ink-rgb),.55)">
|
||
讀你文件、整理成知識卡的那個 AI,換成更強的模型。<b style="color:var(--ink)">卡片會更抓得到重點、關聯也連得更準</b>,之後搜尋和問答的品質跟著提升。需要你的電腦已安裝 Claude Code。不填就用上面那把 Gemini,一樣能用。
|
||
</div>
|
||
<div id="st-ai-claude-hint" style="margin-top:6px;font-size:13px;color:rgba(var(--ink-rgb),.4);display:none"></div>
|
||
</div>
|
||
<div>
|
||
<button class="btn" id="st-ai-save">儲存 AI 設定</button>
|
||
<div id="st-ai-status" style="font-size:14px;min-height:1.2em;margin-top:8px"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button class="btn3" id="st-logout" style="padding:14px;font-size:16px;border-radius:11px">登出</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 管理(P4,admin-only:nav 只有 admin 看得到;真閘在 /portal/admin/* role 閘 403)-->
|
||
<div class="view page" id="v-admin">
|
||
<div class="pagehead"><span class="t">管理</span><span class="m">帳號與知識庫授權</span></div>
|
||
|
||
<div class="sechead">帳號管理</div>
|
||
<div class="panel">
|
||
<div style="font-size:16px;font-weight:600;margin-bottom:4px">新增同仁帳號</div>
|
||
<div style="font-size:13.5px;color:rgba(var(--ink-rgb),.55);margin-bottom:12px">建立後會產生一組一次性密碼——只顯示這一次,請當場轉交同仁(同仁可在「設定」自行改密)。</div>
|
||
<div class="formrow">
|
||
<input type="email" id="ad-nu-email" class="txt" placeholder="Email" autocomplete="off">
|
||
<input type="text" id="ad-nu-name" class="txt" placeholder="顯示名(可留空=用 Email)" autocomplete="off">
|
||
<select id="ad-nu-role"><option value="user">一般用戶</option><option value="admin">管理員</option></select>
|
||
<button class="btn" id="ad-nu-create" style="flex:none;padding:0 20px">建立</button>
|
||
</div>
|
||
<div id="ad-nu-status" class="err" style="font-size:14px;min-height:1.2em;margin-top:8px"></div>
|
||
</div>
|
||
<div id="ad-otp"></div>
|
||
<div class="listcol" id="ad-users"><div class="muted">載入中…</div></div>
|
||
|
||
<div class="sechead">庫目錄管理</div>
|
||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;font-size:13.5px">
|
||
<span style="color:rgba(var(--ink-rgb),.6);white-space:nowrap">你的知識庫網址(小幫手連線用)</span>
|
||
<code id="ad-origin-url" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;color:var(--ink)"></code>
|
||
<button class="btn3" id="ad-copy-url" style="padding:5px 12px;font-size:13px;white-space:nowrap;flex:none">複製</button>
|
||
</div>
|
||
<div style="margin-bottom:12px;padding:12px 15px;border-radius:11px;background:rgba(var(--amber-rgb),.06);border:1px dashed rgba(var(--amber-rgb),.35);font-size:13.5px;line-height:1.7;color:rgba(var(--ink-rgb),.65)">
|
||
<!-- 07-27 leo:拿掉「登記新庫」——庫由「同步小幫手」看守資料夾時自動蓋章產生,
|
||
人工登記只會製造對不上的空庫。這裡只做「已存在的庫」的管理。 -->
|
||
裝好<b style="color:var(--ink)">同步小幫手</b>並選好要看守的資料夾之後,每個資料夾會自動成為一個「庫」出現在下面——<b style="color:var(--ink)">不需要人工新增</b>。下面還是空的,代表小幫手還沒裝好或還沒選資料夾。
|
||
</div>
|
||
<div class="listcol" id="ad-libs"><div class="muted">載入中…</div></div>
|
||
</div>
|
||
|
||
</main>
|
||
</div>
|
||
|
||
<!-- 手機底部導覽 -->
|
||
<div id="tabbar">
|
||
<div class="tab" data-nav="search">搜尋</div>
|
||
<div class="tab hide" id="tab-map" data-nav="map">總圖</div>
|
||
<div class="tab hide" id="tab-upload" data-nav="upload">上傳</div>
|
||
<div class="tab hide" id="tab-workflows" data-nav="workflows">工作流</div>
|
||
<div class="tab hide" id="tab-admin" data-nav="admin">管理</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 注入,console 同套——顯示不隨裝置時區漂移)
|
||
var TAIPEI_OFFSET_MS = 28800000; // UTC+8,台北無 DST
|
||
function tpePad(n) { n = String(n); return n.length < 2 ? '0' + n : n; }
|
||
function taipeiDayKey(ms) { return new Date(ms + TAIPEI_OFFSET_MS).toISOString().slice(0, 10); }
|
||
function taipeiDateStr(ms) { return taipeiDayKey(ms); }
|
||
function taipeiDateTimeStr(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return taipeiDayKey(ms) + ' ' + tpePad(d.getUTCHours()) + ':' + tpePad(d.getUTCMinutes()); }
|
||
function taipeiTimeStr(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return tpePad(d.getUTCHours()) + ':' + tpePad(d.getUTCMinutes()) + ':' + tpePad(d.getUTCSeconds()); }
|
||
function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { month: d.getUTCMonth() + 1, day: d.getUTCDate() }; }
|
||
var $ = function (id) { return document.getElementById(id); };
|
||
|
||
// ── 狀態:只有 session token;**沒有租戶字串、沒有 API key**(design §3.3)──
|
||
var S = {
|
||
token: localStorage.getItem('arcrun_portal_session') || '',
|
||
profile: null, // /portal/session 回應(display_name/role/libraries/graph_allowed/workflows_visible)
|
||
mode: 'keyword',
|
||
cardId: ''
|
||
};
|
||
|
||
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);
|
||
}
|
||
// safeJson:把回應當 JSON 解析,**解析不了就回空物件而不是拋例外**(t75 ①,2026-07-28)。
|
||
//
|
||
// 為什麼需要:原本各處都直接 r.json(),但伺服器不一定回 JSON——404 頁、Cloudflare 錯誤頁、
|
||
// 反向代理的 HTML 都是純文字。JSON.parse 一爆,錯誤就沿著 .catch 走到 friendlyErr,
|
||
// **技術訊息原文被噴到畫面上**。leo 同事實測看到的就是:
|
||
// 「Unexpected non-whitespace character after JSON at position 4」
|
||
// 真正的原因其實是 `/portal/admin/chat-key` 回 404(實例的 cypher 是舊版沒這端點),
|
||
// 但使用者只看得到一句看不懂的英文 ⇒ 無從判斷是自己打錯還是系統壞了。
|
||
function safeJson(r) {
|
||
return r.text().then(function (t) {
|
||
if (!t) return {};
|
||
try { return JSON.parse(t); } catch (e) { return {}; }
|
||
});
|
||
}
|
||
|
||
// friendlyErr:給使用者看的訊息,**不外洩技術細節**(t75 ①)。
|
||
// 原本最後一行是 `return m` =把任何例外訊息原樣顯示,包含 JSON parse 錯誤、
|
||
// stack 片段這種對使用者毫無意義、只會嚇到人的東西。
|
||
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 '連線中斷——請檢查網路後重試';
|
||
// JSON 解析類=伺服器回了非預期內容(多半是 404/代理錯誤頁),對使用者說人話。
|
||
if (/json|unexpected token|unexpected non-whitespace/i.test(m)) return '伺服器回應異常,請稍後再試一次(若持續發生請回報)';
|
||
// 其餘:只在含中文(=我們自己寫的訊息)時原樣顯示;純英文技術訊息一律收斂。
|
||
if (/[\u4e00-\u9fff]/.test(m)) return m;
|
||
return '操作失敗,請稍後再試一次';
|
||
}
|
||
function authHeaders() { return S.token ? { 'Authorization': 'Bearer ' + S.token } : {}; }
|
||
|
||
// ── 主題(console 同款,key 獨立)──
|
||
function getTheme() { return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light'; }
|
||
function setTheme(t) {
|
||
document.documentElement.setAttribute('data-theme', t);
|
||
try { localStorage.setItem('arcrun_portal_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),顯示一律台北時間(console 同款)
|
||
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;
|
||
}
|
||
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;console 同款)──
|
||
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('');
|
||
}
|
||
|
||
// ── entry 顯示 helpers(source/library 從 metadata_json 取——server 已 enforce 過權限)──
|
||
function entryMeta(e) {
|
||
try { var m = JSON.parse(e.metadata_json || 'null'); return m && typeof m === 'object' ? m : {}; } catch (er) { return {}; }
|
||
}
|
||
function entrySource(e) { var m = entryMeta(e); return typeof m.source === 'string' ? m.source : ''; }
|
||
// 來源回溯超連結:PORTAL_SOURCE_WEB_BASE 有設且 source 是 gitea:// 才給 href;
|
||
// 其餘回空字串=維持純文字(行為與未設時一字不變)。chunk 錨點(#n)捨棄。
|
||
var SOURCE_WEB_BASE = "";
|
||
function srcHref(src) {
|
||
if (!SOURCE_WEB_BASE || String(src).indexOf('gitea://') !== 0) return '';
|
||
var p = String(src).slice(8).replace(/#\d+$/, '');
|
||
return SOURCE_WEB_BASE.replace(/\/+$/, '') + '/' + p.split('/').map(encodeURIComponent).join('/');
|
||
}
|
||
function entryLib(e) { var m = entryMeta(e); return (typeof m.library === 'string' && m.library) ? m.library : 'general'; }
|
||
function entryTitle(e) {
|
||
if (e.page_name) return e.page_name;
|
||
var first = String(e.content || '').split(/\r?\n/).find(function (l) { return l.trim(); }) || '';
|
||
first = first.replace(/^#+\s*/, '').replace(/^[-*>]\s*/, '').trim();
|
||
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;
|
||
}
|
||
|
||
// ── 路由 ──
|
||
var VIEWS = ['search', 'map', 'card', 'upload', 'workflows', 'admin', 'settings'];
|
||
var HOME = 'search';
|
||
function allowedViews() {
|
||
// 工作流/管理/上傳頁按 session 能力顯示(真閘在 server:/portal/data/workflows 403/404、
|
||
// /portal/admin/* role 閘 403、/portal/data/upload 未啟用 404——前端藏只是 UX,curl 直打也繞不過)
|
||
return VIEWS.filter(function (v) {
|
||
if (v === 'workflows') return !!(S.profile && S.profile.workflows_visible);
|
||
if (v === 'admin') return !!(S.profile && S.profile.role === 'admin');
|
||
if (v === 'upload') return !!(S.profile && S.profile.upload_enabled);
|
||
return true;
|
||
});
|
||
}
|
||
function currentRoute() {
|
||
var h = location.hash.replace(/^#\/?/, '');
|
||
var seg = h.split('/');
|
||
var ok = allowedViews();
|
||
return { raw: seg[0] || '', view: ok.indexOf(seg[0]) >= 0 ? seg[0] : HOME, arg: seg.slice(1).join('/') };
|
||
}
|
||
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 = { card: loadCard, map: loadMap, workflows: loadWorkflows, admin: loadAdmin, settings: loadSettings };
|
||
function route() {
|
||
var r = currentRoute();
|
||
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() {
|
||
$('v-firstsetup').classList.remove('on');
|
||
$('v-login').classList.add('on');
|
||
$('shell').classList.remove('on');
|
||
$('tabbar').classList.remove('on');
|
||
// t49:全新實例(還沒有任何帳號)→ 換顯示「首次設定」,不讓用戶對著登入殼發呆。
|
||
// 探測走 console/auth-status(configured 布林,console 首設同一顆;失敗就保持登入殼=誠實降級)。
|
||
fetch(API_BASE + '/console/auth-status')
|
||
.then(function (r) { return safeJson(r); })
|
||
.then(function (d) {
|
||
if (d && d.configured === false) {
|
||
$('v-login').classList.remove('on');
|
||
$('v-firstsetup').classList.add('on');
|
||
}
|
||
})
|
||
.catch(function () { /* 探測不到就維持登入殼 */ });
|
||
}
|
||
function showApp() {
|
||
$('v-login').classList.remove('on');
|
||
$('v-firstsetup').classList.remove('on');
|
||
$('shell').classList.add('on');
|
||
$('tabbar').classList.add('on');
|
||
// t53(leo 07-25:「進站要做的事——給金鑰、下載 daemon——不然這個安裝沒完成」):
|
||
// 常駐「完成安裝」清單卡,三件做完才消失(localStorage 記進度;admin 才看得到——
|
||
// 金鑰與 daemon 設定是裝機者的事,同事帳號不顯示)。
|
||
try { renderSetupChecklist(); } catch (e) { /* 清單卡失敗不擋主流程 */ }
|
||
var p = S.profile || {};
|
||
$('side-foot').innerHTML = esc(p.display_name || '') + '<br>' + esc(location.host);
|
||
$('nav-workflows').classList.toggle('hide', !p.workflows_visible);
|
||
$('tab-workflows').classList.toggle('hide', !p.workflows_visible);
|
||
// 上傳 nav:一般用戶可見,但只在實例啟用上傳(bindings 齊全)時顯示(未啟用的實例零影響)
|
||
$('nav-upload').classList.toggle('hide', !p.upload_enabled);
|
||
$('tab-upload').classList.toggle('hide', !p.upload_enabled);
|
||
$('nav-admin').classList.toggle('hide', p.role !== 'admin');
|
||
$('tab-admin').classList.toggle('hide', p.role !== 'admin');
|
||
$('mode-graph').classList.toggle('hide', !p.graph_allowed);
|
||
$('nav-map').classList.toggle('hide', !p.graph_allowed);
|
||
$('tab-map').classList.toggle('hide', !p.graph_allowed);
|
||
var libs = p.libraries || [];
|
||
$('se-scope').textContent = libs.indexOf('*') >= 0 ? '範圍:全部知識庫' : ('範圍:' + libs.join('・'));
|
||
if (!location.hash) location.hash = '#/' + HOME;
|
||
route();
|
||
}
|
||
function dropSession() {
|
||
S.token = '';
|
||
S.profile = null;
|
||
try { localStorage.removeItem('arcrun_portal_session'); } catch (e) { /* noop */ }
|
||
showAuth();
|
||
}
|
||
function boot() {
|
||
if (!S.token) { showAuth(); return; }
|
||
fetch(API_BASE + '/portal/session', { headers: authHeaders() })
|
||
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||
.then(function (x) {
|
||
if (!x.ok) { dropSession(); return; }
|
||
S.profile = x.d;
|
||
showApp();
|
||
})
|
||
.catch(function () {
|
||
// 斷網時不清 session(誠實:連不上 ≠ session 失效),顯示登入殼並提示
|
||
showAuth();
|
||
$('login-status').textContent = '連線中斷——請檢查網路後重新整理';
|
||
});
|
||
}
|
||
$('login-submit').addEventListener('click', doLogin);
|
||
$('login-password').addEventListener('keydown', function (ev) { if (ev.key === 'Enter') doLogin(); });
|
||
function doLogin() {
|
||
var email = $('login-email').value.trim();
|
||
var password = $('login-password').value;
|
||
if (!email || !password) { $('login-status').textContent = '請輸入 Email 與密碼'; return; }
|
||
$('login-submit').disabled = true;
|
||
$('login-status').textContent = '';
|
||
fetch(API_BASE + '/portal/login', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ email: email, password: password })
|
||
})
|
||
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||
.then(function (x) {
|
||
$('login-submit').disabled = false;
|
||
if (!x.ok) { $('login-status').textContent = x.d.error || '登入失敗'; return; }
|
||
S.token = x.d.session_token;
|
||
try { localStorage.setItem('arcrun_portal_session', S.token); } catch (e) { /* 私密模式只活本次 */ }
|
||
$('login-password').value = '';
|
||
boot();
|
||
})
|
||
.catch(function (e) { $('login-submit').disabled = false; $('login-status').textContent = friendlyErr(e); });
|
||
}
|
||
// t72 OS 分流(2026-07-27,leo:「客戶是用 windows 的」)——在此之前四處寫死「下載 Mac 版」,
|
||
// Windows 客戶按下去會拿到 Mac 的 .app=按了連到錯的東西(leo 判準:那不算友善)。
|
||
// 判不出 OS 時**兩個都給**,不替用戶猜;且無論判成哪個,頁面都留「不是這個系統?」的另一版連結
|
||
// ——UA 會判錯,判錯時用戶要有路可走(施工圖 §3 注意事項 1、3)。
|
||
var DAEMON_BASE_DEFAULT = 'https://raw.githubusercontent.com/youlinhsieh/arcrun-rag-bundles/main/daemon/';
|
||
var DAEMON_MAC = 'ArcrunRAG-mac-unsigned.zip';
|
||
var DAEMON_WIN = 'ArcrunRAG-win-unsigned.zip';
|
||
// Mac 那顆 21MB > jsDelivr 單檔 20MB 上限(實測回 "File size exceeded...")→ 一律走 raw;
|
||
// Windows 13MB 雖在限內,同走 raw 保持單一來源、少一個會壞的地方。
|
||
function daemonBase() {
|
||
var cfg = (window.ARCRUN_CONFIG || {});
|
||
if (cfg.daemonBase) return String(cfg.daemonBase).replace(/\/?$/, '/');
|
||
// 舊 key 相容(施工圖 §3 注意事項 2):daemonDownload 是「單一檔案網址」,
|
||
// 有設就尊重它當 Mac 版網址,並由它推出目錄給 Windows 版用。
|
||
if (cfg.daemonDownload) {
|
||
var s = String(cfg.daemonDownload);
|
||
return s.slice(0, s.lastIndexOf('/') + 1) || DAEMON_BASE_DEFAULT;
|
||
}
|
||
return DAEMON_BASE_DEFAULT;
|
||
}
|
||
function daemonPick() {
|
||
var base = daemonBase();
|
||
var ua = navigator.userAgent || '';
|
||
// 手機/平板先排除:iOS 的 UA 含 "Mac OS X",不排除會把 iPhone 判成 Mac,
|
||
// 讓手機用戶下載一個裝不起來的桌面 app(測到才發現,2026-07-27)。
|
||
// 手機=判不出桌面 OS → 落到「兩個都給」,用戶回桌機再選。
|
||
var isMobile = /iPhone|iPad|iPod|Android|Mobile/i.test(ua);
|
||
var isWin = !isMobile && /Windows NT/i.test(ua);
|
||
var isMac = !isMobile && /Macintosh|Mac OS X/i.test(ua) && !/Windows/i.test(ua);
|
||
var mac = { os: 'mac', label: '下載 Mac 版', url: base + DAEMON_MAC };
|
||
var win = { os: 'win', label: '下載 Windows 版', url: base + DAEMON_WIN };
|
||
if (isWin) return { pick: win, other: mac, sure: true };
|
||
if (isMac) return { pick: mac, other: win, sure: true };
|
||
return { pick: null, other: null, sure: false, mac: mac, win: win };
|
||
}
|
||
// 封測期第一次開啟的擋關提示(未簽章)——Mac/Windows 攔法不同,話術也不同。
|
||
function daemonHint(os) {
|
||
if (os === 'win') return '(封測版未簽章,Windows 第一次會跳藍色視窗擋下來——點「更多資訊」→「仍要執行」就好)';
|
||
return '(封測版未簽章,第一次請右鍵→打開)';
|
||
}
|
||
// 07-27:設定頁的常駐入口(下載小幫手/換 AI 金鑰)——與一次性卡片同一組 API
|
||
(function () {
|
||
var dl = $('st-daemon-dl');
|
||
if (dl) {
|
||
var d = daemonPick();
|
||
if (d.sure) {
|
||
dl.setAttribute('href', d.pick.url);
|
||
dl.textContent = d.pick.label;
|
||
// 判對了也要留另一版的路(UA 會判錯)
|
||
var alt = document.createElement('span');
|
||
alt.className = 'muted';
|
||
alt.style.cssText = 'font-size:12.5px;margin-left:8px';
|
||
alt.innerHTML = '不是這個系統?<a href="' + d.other.url + '">' + d.other.label + '</a>';
|
||
if (dl.parentNode) dl.parentNode.insertBefore(alt, dl.nextSibling);
|
||
} else {
|
||
// 判不出來=兩個都給,不預設 Mac
|
||
dl.setAttribute('href', d.mac.url);
|
||
dl.textContent = d.mac.label;
|
||
var both = document.createElement('span');
|
||
both.className = 'muted';
|
||
both.style.cssText = 'font-size:12.5px;margin-left:8px';
|
||
both.innerHTML = '或 <a href="' + d.win.url + '">' + d.win.label + '</a>';
|
||
if (dl.parentNode) dl.parentNode.insertBefore(both, dl.nextSibling);
|
||
}
|
||
}
|
||
})();
|
||
|
||
// t131 AI 設定(合併 Gemini API Key+Claude 加強版)
|
||
(function () {
|
||
// 進入設定頁時讀取現有設定(GET /portal/admin/ai)
|
||
function loadAiConfig() {
|
||
if (!(S.profile && S.profile.role === 'admin')) return;
|
||
fetch(API_BASE + '/portal/admin/ai', { headers: authHeaders() })
|
||
.then(function (r) { return r.ok ? safeJson(r) : null; })
|
||
.then(function (d) {
|
||
if (!d) return;
|
||
var ki = $('st-ai-key');
|
||
if (ki && d.has_key) ki.placeholder = '已設定(留空=不變更)';
|
||
var cb = $('st-ai-use-claude');
|
||
if (cb) {
|
||
// 有 claude 才能勾;沒有則停用並顯示提示
|
||
var hint = $('st-ai-claude-hint');
|
||
if (d.claude_available) {
|
||
cb.disabled = false;
|
||
cb.checked = !!d.use_claude_for_extract;
|
||
if (hint) hint.style.display = 'none';
|
||
} else {
|
||
cb.disabled = true;
|
||
cb.checked = false;
|
||
if (hint) {
|
||
hint.style.display = '';
|
||
// 區分:從未連上小幫手 vs 有連上但沒裝 Claude
|
||
hint.textContent = d.has_key
|
||
? '你的電腦沒有偵測到 Claude Code;裝好並讓小幫手重連一次後,這個選項就會開啟。'
|
||
: '連上小幫手後才知道你的電腦有沒有 Claude Code。';
|
||
}
|
||
}
|
||
}
|
||
})
|
||
.catch(function () { /* 讀不到不擋頁面 */ });
|
||
}
|
||
window._loadAiConfig = loadAiConfig;
|
||
|
||
// 儲存
|
||
var sb = $('st-ai-save');
|
||
if (sb) sb.addEventListener('click', function () {
|
||
var m = $('st-ai-status');
|
||
var k = (($('st-ai-key') && $('st-ai-key').value) || '').trim();
|
||
var cb = $('st-ai-use-claude');
|
||
var useClaud = cb && !cb.disabled ? cb.checked : undefined;
|
||
var body = {};
|
||
if (k) body.gemini_api_key = k;
|
||
if (useClaud !== undefined) body.use_claude_for_extract = useClaud;
|
||
sb.disabled = true; m.textContent = '儲存中…'; m.style.color = '';
|
||
fetch(API_BASE + '/portal/admin/ai', {
|
||
method: 'POST',
|
||
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
|
||
body: JSON.stringify(body)
|
||
}).then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||
.then(function (x) {
|
||
sb.disabled = false;
|
||
if (guard401(x.status)) return;
|
||
if (!x.ok) { m.textContent = (x.d && x.d.error) || '儲存失敗'; m.style.color = '#b4462f'; return; }
|
||
if ($('st-ai-key')) { $('st-ai-key').value = ''; $('st-ai-key').placeholder = '已設定(留空=不變更)'; }
|
||
var claudeOn = x.d && x.d.use_claude_for_extract;
|
||
m.textContent = claudeOn
|
||
? '已儲存,萃取改用 Claude Code。小幫手請重連一次生效。'
|
||
: '已儲存,AI 問答與萃取皆可使用。';
|
||
m.style.color = '#3f7a4f';
|
||
})
|
||
.catch(function (e) { sb.disabled = false; m.textContent = friendlyErr(e); m.style.color = '#b4462f'; });
|
||
});
|
||
})();
|
||
|
||
$('st-logout').addEventListener('click', function () {
|
||
fetch(API_BASE + '/portal/logout', { method: 'POST', headers: authHeaders() }).catch(function () { /* 盡力而為 */ });
|
||
dropSession();
|
||
});
|
||
|
||
// t87 07-28 leo:知識庫網址 helper(只有 origin,不含 /portal/# 後綴),兩處 UI 共用
|
||
function copyOriginUrl(btn) {
|
||
var url = location.origin;
|
||
var orig = btn.textContent;
|
||
if (!navigator.clipboard || !navigator.clipboard.writeText) {
|
||
alert('請手動選取並複製:' + url);
|
||
return;
|
||
}
|
||
navigator.clipboard.writeText(url).then(function () {
|
||
btn.textContent = '已複製';
|
||
setTimeout(function () { btn.textContent = orig; }, 1500);
|
||
}).catch(function () {
|
||
alert('請手動選取並複製:' + url);
|
||
});
|
||
}
|
||
(function () {
|
||
['st-origin-url', 'ad-origin-url'].forEach(function (id) {
|
||
var el = $(id); if (el) el.textContent = location.origin;
|
||
});
|
||
['st-copy-url', 'ad-copy-url'].forEach(function (id) {
|
||
var el = $(id); if (el) el.addEventListener('click', function () { copyOriginUrl(this); });
|
||
});
|
||
})();
|
||
|
||
// ── t53 完成安裝清單(進站必見,三件做完才消失)─────────────────────────────
|
||
function setupSteps() {
|
||
try { return JSON.parse(localStorage.getItem('arcrun_setup_steps') || '{}'); } catch (e) { return {}; }
|
||
}
|
||
function markStep(k) {
|
||
var s = setupSteps(); s[k] = 1;
|
||
try { localStorage.setItem('arcrun_setup_steps', JSON.stringify(s)); } catch (e) { /* noop */ }
|
||
renderSetupChecklist();
|
||
}
|
||
function renderSetupChecklist() {
|
||
var old = document.getElementById('setup-checklist');
|
||
if (old) old.remove();
|
||
var p = S.profile || {};
|
||
if (p.role !== 'admin') return;
|
||
var s = setupSteps();
|
||
if (s.daemon && s.key) return; // t54 起只剩兩件:設定改由小幫手輸入帳密自取,不再下載檔案
|
||
var cfg = window.ARCRUN_CONFIG || {};
|
||
var dpick = daemonPick(); // t72 OS 分流(同一組判定,見上面 daemonPick)
|
||
var daemonUrl = dpick.sure ? dpick.pick.url : dpick.mac.url;
|
||
var row = function (done, id, html) {
|
||
return '<div style="display:flex;gap:10px;align-items:baseline;margin-top:10px">'
|
||
+ '<span>' + (done ? '✅' : '⬜') + '</span><div style="flex:1">' + html + '</div></div>';
|
||
};
|
||
var el = document.createElement('div');
|
||
el.id = 'setup-checklist';
|
||
el.style.cssText = 'position:fixed;right:20px;bottom:20px;z-index:60;max-width:400px;width:calc(100% - 40px);padding:18px 20px;border-radius:14px;background:rgba(var(--amber-rgb),.10);border:1px solid rgba(var(--amber-rgb),.45);backdrop-filter:blur(8px);font-size:14px;line-height:1.65';
|
||
el.innerHTML = '<b>還差 ' + (2 - (s.daemon?1:0) - (s.key?1:0)) + ' 步,安裝就真的完成了</b>'
|
||
+ row(s.daemon, 'daemon',
|
||
'<b>下載同步小幫手</b>(把資料夾變成知識庫)<br>'
|
||
+ (daemonUrl
|
||
// t72:按鈕字樣+擋關話術都跟著 OS 走;並且一律附「另一個系統」的連結
|
||
//(UA 判錯時用戶要有路走;判不出來時=兩個平等並列,不預設 Mac)
|
||
? '<a href="' + esc(daemonUrl) + '" id="sc-daemon">'
|
||
+ esc(dpick.sure ? dpick.pick.label : dpick.mac.label) + '</a>'
|
||
+ '<span class="muted" style="font-size:12.5px">'
|
||
+ esc(daemonHint(dpick.sure ? dpick.pick.os : 'mac')) + '</span>'
|
||
+ '<div class="muted" style="font-size:12.5px">'
|
||
+ (dpick.sure
|
||
? '不是這個系統?<a href="' + esc(dpick.other.url) + '">' + esc(dpick.other.label) + '</a>'
|
||
: '或 <a href="' + esc(dpick.win.url) + '">' + esc(dpick.win.label) + '</a>')
|
||
+ '</div>'
|
||
// 誠實降級(leo 判準:按了連到錯的機制=不友善):沒有可用下載點時
|
||
// 不放死連結,說明現況並讓這步可手動打勾,不卡住清單。
|
||
: '<span class="muted" style="font-size:13px">封測期由我們直接寄給你安裝檔;'
|
||
+ '收到後回來按 <a href="#" id="sc-daemon">我已經裝好了</a>。</span>')
|
||
// t54(leo:「最好的就是把它的帳密直接輸入」):設定不再是一個要下載的檔案——
|
||
// 小幫手第一次開啟會問網址+帳密,自己去換設定。
|
||
+ '<div class="muted" style="font-size:12.5px;margin-top:4px">裝好第一次開啟時,貼上這個網址+你的帳號密碼就連上了,不用下載設定檔。</div>')
|
||
+ row(s.key, 'key',
|
||
'<b>啟用 AI 問答</b>(<a href="https://aistudio.google.com" target="_blank" rel="noopener">aistudio.google.com</a> 免費申請)<br>'
|
||
+ '<input id="sc-key" type="password" placeholder="貼上 Google AI 金鑰" style="width:60%;padding:6px 8px;border-radius:8px;border:1px solid rgba(var(--ink-rgb),.25);background:rgba(var(--ink-rgb),.04);color:var(--ink)"> '
|
||
+ '<button class="btn3" id="sc-key-save" style="padding:6px 12px;border-radius:8px;cursor:pointer">啟用</button>'
|
||
+ '<div id="sc-key-msg" style="font-size:12.5px;min-height:1.1em"></div>')
|
||
+ '<div style="margin-top:10px;text-align:right"><button id="sc-later" style="border:none;background:none;color:inherit;cursor:pointer;font-size:12.5px;text-decoration:underline;opacity:.65">稍後再說</button></div>';
|
||
document.body.appendChild(el);
|
||
var dl = document.getElementById('sc-daemon');
|
||
if (dl) dl.addEventListener('click', function () { markStep('daemon'); });
|
||
// t54:config.json 下載鈕已移除(設定由小幫手憑帳密自取)
|
||
var kb = document.getElementById('sc-key-save');
|
||
if (kb) kb.addEventListener('click', function () {
|
||
var k = (document.getElementById('sc-key').value || '').trim();
|
||
var m = document.getElementById('sc-key-msg');
|
||
if (!k) { m.textContent = '請先貼上金鑰'; return; }
|
||
kb.disabled = true; m.textContent = '啟用中…';
|
||
fetch(API_BASE + '/portal/admin/ai', {
|
||
method: 'POST',
|
||
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
|
||
body: JSON.stringify({ gemini_api_key: k })
|
||
}).then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||
.then(function (x) {
|
||
kb.disabled = false;
|
||
if (!x.ok || !x.d.success) { m.textContent = (x.d && x.d.error) || '啟用失敗,請再試一次'; return; }
|
||
markStep('key');
|
||
})
|
||
.catch(function () { kb.disabled = false; m.textContent = '網路好像有問題,請再試一次'; });
|
||
});
|
||
var later = document.getElementById('sc-later');
|
||
if (later) later.addEventListener('click', function () { el.remove(); }); // 只藏本次,下次進站再提醒
|
||
}
|
||
|
||
// ── t49 首次設定(leo 07-25):console/setup → portal/admin/bootstrap → 預設庫登記 → portal/login
|
||
// 四發全是既有端點,一顆按鈕做完;用戶感受=「建一組帳密就進來了」。
|
||
$('fs-submit').addEventListener('click', doFirstSetup);
|
||
$('fs-password2').addEventListener('keydown', function (ev) { if (ev.key === 'Enter') doFirstSetup(); });
|
||
$('fs-tologin').addEventListener('click', function () {
|
||
$('v-firstsetup').classList.remove('on');
|
||
$('v-login').classList.add('on');
|
||
});
|
||
function doFirstSetup() {
|
||
var email = $('fs-email').value.trim();
|
||
var pw = $('fs-password').value;
|
||
var st = $('fs-status');
|
||
if (!email || pw.length < 8) { st.textContent = '請填 Email,密碼至少 8 碼'; return; }
|
||
if (pw !== $('fs-password2').value) { st.textContent = '兩次密碼不一樣'; return; }
|
||
$('fs-submit').disabled = true; st.textContent = '';
|
||
var post = function (path, body, hdrs) {
|
||
return fetch(API_BASE + path, {
|
||
method: 'POST',
|
||
headers: Object.assign({ 'Content-Type': 'application/json' }, hdrs || {}),
|
||
body: JSON.stringify(body)
|
||
}).then(function (r) { return r.json().catch(function () { return {}; }).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); });
|
||
};
|
||
post('/console/setup', { email: email, password: pw })
|
||
.then(function (x) {
|
||
// 已設定過(409)=這實例其實有人建過了——自動帶去登入,別把用戶困在死路(leo 實撞)
|
||
if (x.status === 409) {
|
||
$('fs-submit').disabled = false;
|
||
$('v-firstsetup').classList.remove('on');
|
||
$('v-login').classList.add('on');
|
||
$('login-email').value = email;
|
||
$('login-status').textContent = '這個知識庫已經設定過帳號了,直接登入即可。';
|
||
throw { handled: true };
|
||
}
|
||
if (!x.ok || !x.d.session_token) throw new Error(x.d.error || '設定沒有成功,請再試一次');
|
||
var ownerTok = x.d.session_token;
|
||
return post('/portal/admin/bootstrap', { email: email, password: pw, display_name: email.split('@')[0] }, { Authorization: 'Bearer ' + ownerTok })
|
||
.then(function (b) {
|
||
// 409=已 bootstrap 過(冪等視為就緒);其餘失敗誠實丟出
|
||
if (!b.ok && b.status !== 409) throw new Error(b.d.error || '初始化沒有成功,請再試一次');
|
||
return post('/portal/login', { email: email, password: pw });
|
||
});
|
||
})
|
||
.then(function (l) {
|
||
if (!l.ok || !l.d.session_token) throw new Error(l.d.error || '帳號建好了,但自動登入沒成功——請用剛設定的帳密登入');
|
||
S.token = l.d.session_token;
|
||
try { localStorage.setItem('arcrun_portal_session', S.token); } catch (e) { /* noop */ }
|
||
})
|
||
.then(function () {
|
||
$('fs-submit').disabled = false;
|
||
try { localStorage.setItem('arcrun_onboarding', '1'); } catch (e) { /* noop */ }
|
||
boot();
|
||
})
|
||
.catch(function (e) {
|
||
if (e && e.handled) return; // 409 已自動切登入,不再顯示錯誤
|
||
$('fs-submit').disabled = false;
|
||
st.textContent = (e && e.message) || friendlyErr(e);
|
||
});
|
||
}
|
||
|
||
// 任何 data 請求收到 401 → session 失效 → 回登入殼
|
||
function guard401(status) {
|
||
if (status === 401) { dropSession(); return true; }
|
||
return false;
|
||
}
|
||
|
||
// ── 搜尋 ──
|
||
document.querySelectorAll('.modebtn').forEach(function (b) {
|
||
b.addEventListener('click', function () {
|
||
S.mode = b.getAttribute('data-mode');
|
||
document.querySelectorAll('.modebtn').forEach(function (x) { x.classList.toggle('on', x === b); });
|
||
if ($('se-q').value.trim()) doSearch();
|
||
});
|
||
});
|
||
$('se-go').addEventListener('click', doSearch);
|
||
$('se-q').addEventListener('keydown', function (ev) { if (ev.key === 'Enter') doSearch(); });
|
||
function doSearch() {
|
||
var q = $('se-q').value.trim();
|
||
if (!q) { $('se-count').textContent = '請輸入查詢字'; return; }
|
||
$('se-banner').innerHTML = '';
|
||
if (S.mode === 'graph') { doGraphSearch(q); return; }
|
||
$('se-graphout').style.display = 'none';
|
||
$('se-results').style.display = '';
|
||
$('se-count').textContent = '查詢中…';
|
||
$('se-results').innerHTML = '';
|
||
var url = API_BASE + '/portal/data/search?q=' + encodeURIComponent(q) + '&mode=' + (S.mode === 'semantic' ? 'semantic' : 'keyword');
|
||
fetch(url, { headers: authHeaders() })
|
||
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||
.then(function (x) {
|
||
if (guard401(x.status)) return;
|
||
if (!x.ok) { $('se-count').innerHTML = '<span class="err">' + esc(x.d.error || ('查詢失敗(HTTP ' + x.status + ')')) + '</span>'; return; }
|
||
var d = x.d;
|
||
if (S.mode === 'semantic' && d.mode === 'keyword') {
|
||
$('se-banner').innerHTML = '<div class="honest" style="margin-top:18px"><div class="h">語意搜尋尚未啟用</div><div class="b">語意搜尋用「意思」找資料,不是字面比對。<br>' + esc(d.capability_hint || '系統尚未開啟語意索引——不會假裝有語意結果,以下是關鍵字結果。') + '</div></div>';
|
||
}
|
||
var entries = d.entries || [];
|
||
$('se-count').textContent = '命中 ' + entries.length + ' 筆・模式 ' + (d.mode || 'keyword') + (d.note ? '・' + d.note : '');
|
||
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) {
|
||
var src = entrySource(e);
|
||
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">' +
|
||
'<span class="tag">' + esc(e.entry_type || 'entry') + '</span>' +
|
||
'<span class="tag green">' + esc(entryLib(e)) + '</span>' +
|
||
(src ? '<span class="dim" style="word-break:break-all">' + esc(src) + '</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'));
|
||
});
|
||
|
||
// ── AI 問答(portal-demo-suite)──
|
||
// 設計哲學:AI 檢索=用戶手動搜尋同一套——同 session token、同 /portal/data/* enforce 面,
|
||
// AI 只代查再作答;答案附 sources(mode+page),點 page 帶回搜尋框讓用戶自己驗證。
|
||
$('ai-go').addEventListener('click', doAsk);
|
||
$('ai-q').addEventListener('keydown', function (ev) { if (ev.key === 'Enter') doAsk(); });
|
||
function doAsk() {
|
||
var q = $('ai-q').value.trim();
|
||
if (!q) { toast('請先輸入問題'); return; }
|
||
$('ai-go').disabled = true;
|
||
$('ai-out').innerHTML = '<div class="muted" style="margin-top:12px">AI 查閱知識庫中…</div>';
|
||
fetch(API_BASE + '/portal/data/chat?question=' + encodeURIComponent(q), { headers: authHeaders() })
|
||
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||
.then(function (x) {
|
||
$('ai-go').disabled = false;
|
||
if (guard401(x.status)) return;
|
||
if (!x.ok) {
|
||
// 404=此實例未安裝問答 workflow(server 誠實回報,前端不假裝有 AI)
|
||
$('ai-out').innerHTML = '<div class="honest" style="margin-top:12px"><div class="h">問不到</div><div class="b">' + esc(x.d.error || ('問答失敗(HTTP ' + x.status + ')')) + '</div></div>';
|
||
return;
|
||
}
|
||
var srcs = x.d.sources || [];
|
||
// answer 純文字渲染、保留換行(pre-wrap+esc,不走 markdown、防 XSS)
|
||
var html = '<div class="panel" style="margin-top:12px">' +
|
||
'<div style="white-space:pre-wrap;font-size:16px;line-height:1.85;word-break:break-word">' + esc(x.d.answer || '(AI 沒有給出答案)') + '</div>';
|
||
if (srcs.length) {
|
||
html += '<div style="margin-top:14px;font-size:13px;letter-spacing:.1em;color:rgba(var(--ink-rgb),.5)">出處(點頁名帶入搜尋框)</div>' +
|
||
'<div style="margin-top:7px;display:flex;flex-direction:column;gap:6px">' + srcs.map(function (s) {
|
||
var page = (s && (s.page || s.page_name)) || '';
|
||
var mode = (s && s.mode) || '';
|
||
var href = s && s.source ? srcHref(s.source) : '';
|
||
return '<div class="nbrow" data-aisrc="' + esc(page) + '">' +
|
||
(mode ? '<span class="tag" style="flex:none">' + esc(mode) + '</span>' : '') +
|
||
'<span style="word-break:break-word">' + esc(page || '(無頁名)') + '</span>' +
|
||
(href ? '<a href="' + esc(href) + '" target="_blank" rel="noopener" style="flex:none;color:var(--amber);font-size:13px" title="開啟來源檔">來源 ↗</a>' : '') +
|
||
'<span style="margin-left:auto;color:rgba(var(--amber-rgb),.6);flex:none">›</span></div>';
|
||
}).join('') + '</div>';
|
||
}
|
||
html += '</div>';
|
||
$('ai-out').innerHTML = html;
|
||
})
|
||
.catch(function (e) { $('ai-go').disabled = false; $('ai-out').innerHTML = '<div class="err" style="margin-top:12px">請求失敗:' + esc(friendlyErr(e)) + '</div>'; });
|
||
}
|
||
$('ai-out').addEventListener('click', function (ev) {
|
||
if (ev.target.closest('a')) return; // 「來源 ↗」超連結交給瀏覽器,不觸發帶入搜尋框
|
||
var t = ev.target.closest('[data-aisrc]');
|
||
if (!t) return;
|
||
var page = t.getAttribute('data-aisrc');
|
||
if (!page) return;
|
||
$('se-q').value = page;
|
||
$('se-q').focus();
|
||
window.scrollTo(0, 0);
|
||
});
|
||
|
||
// ── graph 模式(D-4 粗閘:無權者按鈕根本不顯示;就算 curl 直打 API 也是 403)──
|
||
function doGraphSearch(name) {
|
||
$('se-results').style.display = 'none';
|
||
$('se-graphout').style.display = '';
|
||
$('se-count').textContent = '查詢關聯中…';
|
||
renderGraphEmpty('查詢關聯中…');
|
||
fetch(API_BASE + '/portal/data/graph/neighbors/' + encodeURIComponent(name), { headers: authHeaders() })
|
||
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||
.then(function (x) {
|
||
if (guard401(x.status)) return;
|
||
if (!x.ok) { $('se-count').innerHTML = '<span class="err">' + esc(x.d.error || ('關聯查詢失敗(HTTP ' + x.status + ')')) + '</span>'; renderGraphEmpty(x.d.error || '關聯查詢失敗'); return; }
|
||
var neighbors = x.d.neighbors || [];
|
||
var edges = x.d.edges || [];
|
||
// 形狀相容(Arcrun#57 家族):plugin 版 neighbors 是字串、workflow 版是
|
||
// { node, predicate, from, depth } 物件——不正規化會渲染成 [object Object]。
|
||
var names = neighbors.map(function (nb) {
|
||
if (typeof nb === 'string') return nb;
|
||
return (nb && (nb.node || nb.name)) || '';
|
||
}).filter(Boolean);
|
||
// workflow 版 edges 恆空,但 neighbor 物件本身就是一條邊(from --predicate--> node)→ 導出。
|
||
if (!edges.length) {
|
||
edges = neighbors.filter(function (nb) { return nb && typeof nb === 'object' && nb.predicate; })
|
||
.map(function (nb) { return { subject: nb.from || name, predicate: nb.predicate, object: nb.node }; });
|
||
}
|
||
$('se-count').textContent = '節點「' + name + '」・鄰居 ' + names.length + '・關聯 ' + edges.length;
|
||
if (!names.length) { renderGraphEmpty('尚無關聯資料——這個名字還沒有連進知識圖譜。'); return; }
|
||
renderGraph(name, names.slice(0, 10));
|
||
$('gr-edges').innerHTML = edges.slice(0, 12).map(function (t) {
|
||
// 大小寫不敏感比對(graph 查詢已正規化:搜 rag 會命中節點 RAG)
|
||
var other = String(t.subject).toLowerCase() === String(name).toLowerCase() ? 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) { $('se-count').innerHTML = '<span class="err">關聯服務不可達:' + esc(friendlyErr(e)) + '</span>'; renderGraphEmpty('關聯服務不可達'); });
|
||
}
|
||
function renderGraphEmpty(msg) {
|
||
$('gr-box').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>';
|
||
$('gr-edges').innerHTML = '';
|
||
}
|
||
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;
|
||
$('gr-box').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');
|
||
$('se-q').value = name;
|
||
doGraphSearch(name);
|
||
});
|
||
|
||
// ── 卡片詳頁 ──
|
||
function loadCard() {
|
||
if (!S.cardId) { $('cd-main').innerHTML = '<div class="muted">沒有指定卡片——從「搜尋」點一張進來。</div>'; return; }
|
||
$('cd-main').innerHTML = '<div class="muted">載入中…</div>';
|
||
fetch(API_BASE + '/portal/data/entries/' + encodeURIComponent(S.cardId), { headers: authHeaders() })
|
||
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||
.then(function (x) {
|
||
if (guard401(x.status)) return;
|
||
if (!x.ok || !x.d.entry) { $('cd-main').innerHTML = '<div class="err">' + esc(x.d.error || '讀不到這張卡片') + '</div>'; return; }
|
||
var e = x.d.entry;
|
||
var src = entrySource(e);
|
||
$('cd-main').innerHTML = '<div class="cardtitle">' + esc(entryTitle(e)) + '</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>' +
|
||
'<span class="tag green">' + esc(entryLib(e)) + '</span>' +
|
||
(e.page_name ? '<span class="tag dim">' + esc(e.page_name) + '</span>' : '') +
|
||
'<span class="muted mono">' + esc(fmtDateTime(e.created_at)) + '</span></div>' +
|
||
'<div class="md">' + mdRender(e.content || '(此條目沒有內文)') + '</div>' +
|
||
(src
|
||
? (function () {
|
||
var href = srcHref(src);
|
||
var body = href
|
||
? '<a class="mono" style="font-size:14px;color:var(--amber);word-break:break-all" href="' + esc(href) + '" target="_blank" rel="noopener">' + esc(src) + ' ↗</a>'
|
||
: '<span class="mono" style="font-size:14px;color:var(--amber);word-break:break-all">' + esc(src) + '</span>';
|
||
return '<div class="srcline"><span style="font-size:13px;letter-spacing:.14em;color:rgba(var(--ink-rgb),.5);flex:none">來源回溯</span>' + body + '</div>';
|
||
})()
|
||
: '');
|
||
})
|
||
.catch(function (er) { $('cd-main').innerHTML = '<div class="err">請求失敗:' + esc(friendlyErr(er)) + '</div>'; });
|
||
}
|
||
|
||
// ── 總圖(Arcrun#39 藏書地圖 GUI 切片)──
|
||
// 全庫三元組一張網:/portal/data/graph/overview → 手刻力導向佈局(demo 量級,零外部套件)。
|
||
// 佈局用固定種子的 PRNG=同一庫每次長一樣(客戶重整不會跳位)。
|
||
function jumpToGraph(name) {
|
||
S.mode = 'graph';
|
||
document.querySelectorAll('.modebtn').forEach(function (x) { x.classList.toggle('on', x.getAttribute('data-mode') === 'graph'); });
|
||
nav('search');
|
||
$('se-q').value = name;
|
||
doGraphSearch(name);
|
||
}
|
||
function loadMap() {
|
||
$('map-box').innerHTML = '<div class="muted" style="padding:30px 10px">載入總圖中…</div>';
|
||
$('map-md-link').innerHTML = SOURCE_WEB_BASE
|
||
? ':<a href="' + esc(SOURCE_WEB_BASE + '/system-dev/wiki/00-MAP.md') + '" target="_blank" rel="noopener" style="color:var(--amber)">00-MAP.md ↗</a>'
|
||
: '存在知識庫的 00-MAP.md';
|
||
fetch(API_BASE + '/portal/data/graph/overview', { headers: authHeaders() })
|
||
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||
.then(function (x) {
|
||
if (guard401(x.status)) return;
|
||
if (!x.ok) { $('map-box').innerHTML = '<div class="err">' + esc(x.d.error || ('總圖載入失敗(HTTP ' + x.status + ')')) + '</div>'; return; }
|
||
var nodes = x.d.nodes || [];
|
||
var edges = x.d.edges || [];
|
||
$('map-meta').textContent = nodes.length + ' 個實體・' + edges.length + ' 條關聯' + (x.d.truncated ? '・已達上限截斷' : '');
|
||
if (!nodes.length) { $('map-box').innerHTML = '<div class="muted" style="padding:30px 10px">知識庫還沒有任何關聯——上傳文件後 AI 會自動織網。</div>'; return; }
|
||
renderMap(nodes, edges);
|
||
})
|
||
.catch(function (e) { $('map-box').innerHTML = '<div class="err">請求失敗:' + esc(friendlyErr(e)) + '</div>'; });
|
||
}
|
||
function renderMap(nodes, edges) {
|
||
var N = nodes.length;
|
||
var idx = {};
|
||
nodes.forEach(function (n, i) { idx[n.name] = i; });
|
||
var el = [];
|
||
edges.forEach(function (e) {
|
||
var a = idx[e.subject], b = idx[e.object];
|
||
if (a == null || b == null || a === b) return;
|
||
el.push([a, b, e.predicate || '']);
|
||
});
|
||
// 固定種子 PRNG(mulberry32):確定性佈局
|
||
var seed = 42;
|
||
function rnd() {
|
||
seed = (seed + 0x6D2B79F5) | 0;
|
||
var t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||
}
|
||
var W = 1000, H = Math.max(480, Math.min(760, 200 + N * 14));
|
||
var px = [], py = [];
|
||
for (var i = 0; i < N; i++) {
|
||
var ang = 2 * Math.PI * i / N;
|
||
var rad = Math.min(W, H) * (0.22 + 0.2 * rnd());
|
||
px.push(W / 2 + rad * Math.cos(ang));
|
||
py.push(H / 2 + rad * Math.sin(ang) * 0.75);
|
||
}
|
||
// 力導向:斥力(全對)+彈簧(邊)+向心,260 輪退火
|
||
var K = Math.max(75, Math.sqrt(W * H / Math.max(N, 1)) * 0.9);
|
||
for (var it = 0; it < 260; it++) {
|
||
var cool = Math.max(0.06, 1 - it / 260);
|
||
var fx = [], fy = [];
|
||
for (var a1 = 0; a1 < N; a1++) { fx.push(0); fy.push(0); }
|
||
for (var a2 = 0; a2 < N; a2++) {
|
||
for (var b2 = a2 + 1; b2 < N; b2++) {
|
||
var dx = px[a2] - px[b2], dy = py[a2] - py[b2];
|
||
var d2 = dx * dx + dy * dy + 0.01;
|
||
var f = Math.min(28, (K * K) / d2 * 2.2);
|
||
var d = Math.sqrt(d2);
|
||
fx[a2] += dx / d * f; fy[a2] += dy / d * f;
|
||
fx[b2] -= dx / d * f; fy[b2] -= dy / d * f;
|
||
}
|
||
}
|
||
el.forEach(function (e) {
|
||
var dx = px[e[0]] - px[e[1]], dy = py[e[0]] - py[e[1]];
|
||
var d = Math.sqrt(dx * dx + dy * dy) + 0.01;
|
||
var f = (d - K) * 0.045;
|
||
fx[e[0]] -= dx / d * f; fy[e[0]] -= dy / d * f;
|
||
fx[e[1]] += dx / d * f; fy[e[1]] += dy / d * f;
|
||
});
|
||
for (var m = 0; m < N; m++) {
|
||
fx[m] += (W / 2 - px[m]) * 0.012;
|
||
fy[m] += (H / 2 - py[m]) * 0.012;
|
||
px[m] += Math.max(-14, Math.min(14, fx[m])) * cool;
|
||
py[m] += Math.max(-14, Math.min(14, fy[m])) * cool;
|
||
}
|
||
}
|
||
// 視窗貼合
|
||
var minX = 1e9, maxX = -1e9, minY = 1e9, maxY = -1e9;
|
||
for (var q = 0; q < N; q++) {
|
||
minX = Math.min(minX, px[q]); maxX = Math.max(maxX, px[q]);
|
||
minY = Math.min(minY, py[q]); maxY = Math.max(maxY, py[q]);
|
||
}
|
||
var PAD = 70;
|
||
var vb = (minX - PAD) + ' ' + (minY - PAD) + ' ' + (maxX - minX + PAD * 2) + ' ' + (maxY - minY + PAD * 2);
|
||
var maxDeg = 1;
|
||
nodes.forEach(function (n) { maxDeg = Math.max(maxDeg, n.degree || 1); });
|
||
var svg = '<svg viewBox="' + vb + '" preserveAspectRatio="xMidYMid meet">';
|
||
el.forEach(function (e) {
|
||
svg += '<line class="mapedge" x1="' + px[e[0]].toFixed(1) + '" y1="' + py[e[0]].toFixed(1) +
|
||
'" x2="' + px[e[1]].toFixed(1) + '" y2="' + py[e[1]].toFixed(1) + '"><title>' +
|
||
esc(nodes[e[0]].name + ' —' + (e[2] || '關聯') + '→ ' + nodes[e[1]].name) + '</title></line>';
|
||
});
|
||
nodes.forEach(function (n, i2) {
|
||
var deg = n.degree || 1;
|
||
var r = 9 + Math.sqrt(deg) * 5;
|
||
var hub = deg >= Math.max(3, maxDeg * 0.6) ? ' hub' : '';
|
||
svg += '<g class="mapnode' + hub + '" data-mapnode="' + esc(n.name) + '">' +
|
||
'<circle cx="' + px[i2].toFixed(1) + '" cy="' + py[i2].toFixed(1) + '" r="' + r.toFixed(1) + '"><title>' + esc(n.name + '・' + deg + ' 條關聯') + '</title></circle>' +
|
||
'<text x="' + px[i2].toFixed(1) + '" y="' + (py[i2] + r + 15).toFixed(1) + '" text-anchor="middle">' + esc(n.name.length > 14 ? n.name.slice(0, 14) + '…' : n.name) + '</text></g>';
|
||
});
|
||
svg += '</svg>';
|
||
$('map-box').innerHTML = '<div class="mapbox">' + svg +
|
||
'<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-mapnode]');
|
||
if (!t) return;
|
||
jumpToGraph(t.getAttribute('data-mapnode'));
|
||
});
|
||
|
||
// ── 工作流(唯讀)──
|
||
function loadWorkflows() {
|
||
$('wf-list').innerHTML = '<div class="muted">載入中…</div>';
|
||
fetch(API_BASE + '/portal/data/workflows', { headers: authHeaders() })
|
||
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||
.then(function (x) {
|
||
if (guard401(x.status)) return;
|
||
if (!x.ok) { $('wf-list').innerHTML = '<div class="err">' + esc(x.d.error || ('讀取失敗(HTTP ' + x.status + ')')) + '</div>'; return; }
|
||
var wfs = x.d.workflows || [];
|
||
if (!wfs.length) { $('wf-list').innerHTML = '<div class="muted" style="padding:20px 4px">目前沒有任何工作流。</div>'; return; }
|
||
$('wf-list').innerHTML = wfs.map(function (w) {
|
||
var lastHtml = '<span class="dim">尚無執行紀錄</span>';
|
||
if (w.last_execution) {
|
||
var v = w.last_execution.verdict || '';
|
||
var cls = v === 'success' ? 'ok' : (v === 'failed' ? 'err' : 'muted');
|
||
lastHtml = '<span class="' + cls + '">' + esc(v || '(無 verdict)') + '</span> <span class="dim mono">' + esc(fmtDateTime(Number(w.last_execution.timestamp))) + '</span>';
|
||
}
|
||
return '<div class="card-item">' +
|
||
'<div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap">' +
|
||
'<span class="serif" style="font-size:17px;font-weight:600">' + esc(w.name) + '</span>' +
|
||
(w.cron_expr ? '<span class="tag dim mono">' + esc(w.cron_expr) + '</span>' : '') +
|
||
'<span style="margin-left:auto;font-size:13.5px">' + lastHtml + '</span></div>' +
|
||
(w.description ? '<div style="margin-top:8px;font-size:14.5px;line-height:1.65;color:rgba(var(--ink-rgb),.65)">' + esc(w.description) + '</div>' : '<div class="dim" style="margin-top:8px;font-size:13.5px">(沒有描述)</div>') +
|
||
'</div>';
|
||
}).join('');
|
||
})
|
||
.catch(function (e) { $('wf-list').innerHTML = '<div class="err">請求失敗:' + esc(friendlyErr(e)) + '</div>'; });
|
||
}
|
||
|
||
// ── 上傳頁(portal-demo-suite)──
|
||
// 前端只讀檔轉 base64 逐檔 POST /portal/data/upload;Gitea 位置與 token 全在 server 側,
|
||
// 這裡看不到任何 repo/token 字串。逐檔獨立狀態列(一檔失敗不拖累其他檔)。
|
||
var UP_OK_MSG = '已收件,約 1 分鐘後可在搜尋頁找到;AI 整理後會以 wiki 卡形式出現。';
|
||
function b64FromDataUrl(dataUrl) {
|
||
var i = String(dataUrl).indexOf(',');
|
||
return i >= 0 ? String(dataUrl).slice(i + 1) : '';
|
||
}
|
||
function uploadStatusRow(name) {
|
||
var row = document.createElement('div');
|
||
row.className = 'card-item';
|
||
row.innerHTML = '<div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap">' +
|
||
'<span class="mono" style="font-size:14.5px;word-break:break-all">' + esc(name) + '</span>' +
|
||
'<span class="up-st muted" style="margin-left:auto;font-size:14px;flex:none">讀取中…</span></div>' +
|
||
'<div class="up-msg" style="margin-top:6px;font-size:13.5px;line-height:1.65;color:rgba(var(--ink-rgb),.6)"></div>';
|
||
$('up-list').prepend(row);
|
||
return row;
|
||
}
|
||
function setRowStatus(row, ok, statusText, msg) {
|
||
var st = row.querySelector('.up-st');
|
||
st.textContent = statusText;
|
||
st.className = 'up-st ' + (ok === true ? 'ok' : (ok === false ? 'err' : 'muted'));
|
||
row.querySelector('.up-msg').textContent = msg || '';
|
||
}
|
||
function uploadOneFile(file) {
|
||
var row = uploadStatusRow(file.name);
|
||
if (!/\.(md|txt)$/i.test(file.name)) {
|
||
setRowStatus(row, false, '已略過', '只收 .md / .txt 檔。');
|
||
return;
|
||
}
|
||
var reader = new FileReader();
|
||
reader.onerror = function () { setRowStatus(row, false, '讀檔失敗', '瀏覽器讀不到這個檔案。'); };
|
||
reader.onload = function () {
|
||
setRowStatus(row, null, '上傳中…', '');
|
||
fetch(API_BASE + '/portal/data/upload', {
|
||
method: 'POST',
|
||
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
|
||
body: JSON.stringify({ filename: file.name, content_b64: b64FromDataUrl(reader.result) })
|
||
})
|
||
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||
.then(function (x) {
|
||
if (guard401(x.status)) return;
|
||
if (!x.ok) { setRowStatus(row, false, '失敗', x.d.error || ('HTTP ' + x.status)); return; }
|
||
setRowStatus(row, true, '已收件', UP_OK_MSG + (x.d.path ? '(' + x.d.path + ')' : ''));
|
||
})
|
||
.catch(function (e) { setRowStatus(row, false, '失敗', friendlyErr(e)); });
|
||
};
|
||
reader.readAsDataURL(file); // dataURL 尾段即 base64(unicode 安全,免手刻 btoa 編碼)
|
||
}
|
||
function handleUploadFiles(files) {
|
||
for (var i = 0; i < files.length; i++) uploadOneFile(files[i]);
|
||
}
|
||
$('up-pick').addEventListener('click', function (ev) { ev.stopPropagation(); $('up-file').click(); });
|
||
$('up-drop').addEventListener('click', function () { $('up-file').click(); });
|
||
$('up-file').addEventListener('change', function () { handleUploadFiles(this.files); this.value = ''; });
|
||
['dragover', 'dragenter'].forEach(function (t) {
|
||
$('up-drop').addEventListener(t, function (ev) { ev.preventDefault(); ev.currentTarget.style.background = 'rgba(var(--amber-rgb),.12)'; });
|
||
});
|
||
['dragleave', 'drop'].forEach(function (t) {
|
||
$('up-drop').addEventListener(t, function (ev) { ev.preventDefault(); ev.currentTarget.style.background = 'rgba(var(--amber-rgb),.04)'; });
|
||
});
|
||
$('up-drop').addEventListener('drop', function (ev) {
|
||
if (ev.dataTransfer && ev.dataTransfer.files && ev.dataTransfer.files.length) handleUploadFiles(ev.dataTransfer.files);
|
||
});
|
||
|
||
// ── 管理頁(P4:帳號管理+庫目錄管理。全走 /portal/admin/*,前端零業務邏輯;
|
||
// 一般用戶 nav 不顯示,且 server role 閘 403——藏只是 UX,真閘在後端)──
|
||
var adminLibs = []; // 庫目錄快取(渲染每帳號的庫勾選用)
|
||
|
||
function adminApi(method, path, body) {
|
||
path = API_BASE + path;
|
||
var opt = { method: method, headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()) };
|
||
if (body !== undefined) opt.body = JSON.stringify(body);
|
||
return fetch(path, opt).then(function (r) {
|
||
return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; });
|
||
});
|
||
}
|
||
|
||
function loadAdmin() {
|
||
$('ad-users').innerHTML = '<div class="muted">載入中…</div>';
|
||
$('ad-libs').innerHTML = '<div class="muted">載入中…</div>';
|
||
adminApi('GET', '/portal/admin/libraries')
|
||
.then(function (x) {
|
||
if (guard401(x.status)) return;
|
||
if (!x.ok) { $('ad-libs').innerHTML = '<div class="err">' + esc(x.d.error || ('讀取失敗(HTTP ' + x.status + ')')) + '</div>'; return; }
|
||
adminLibs = x.d.libraries || [];
|
||
renderAdminLibs();
|
||
return adminApi('GET', '/portal/admin/users').then(function (y) {
|
||
if (guard401(y.status)) return;
|
||
if (!y.ok) { $('ad-users').innerHTML = '<div class="err">' + esc(y.d.error || ('讀取失敗(HTTP ' + y.status + ')')) + '</div>'; return; }
|
||
renderAdminUsers(y.d.users || []);
|
||
});
|
||
})
|
||
.catch(function (e) {
|
||
$('ad-users').innerHTML = '<div class="err">請求失敗:' + esc(friendlyErr(e)) + '</div>';
|
||
$('ad-libs').innerHTML = '';
|
||
});
|
||
}
|
||
|
||
// 帳號可勾選的庫=庫目錄啟用中的庫 ∪ general ∪ 該帳號已有的庫(目錄外的舊授權不憑空消失)
|
||
function checkableLibs(userLibs) {
|
||
var names = ['general'];
|
||
adminLibs.forEach(function (l) { if (l.status !== 'disabled' && l.name && names.indexOf(l.name) < 0) names.push(l.name); });
|
||
(userLibs || []).forEach(function (n) { if (n !== '*' && names.indexOf(n) < 0) names.push(n); });
|
||
return names;
|
||
}
|
||
function libDisplay(name) {
|
||
for (var i = 0; i < adminLibs.length; i++) { if (adminLibs[i].name === name) return adminLibs[i].display_name || name; }
|
||
return name;
|
||
}
|
||
|
||
function renderAdminUsers(users) {
|
||
if (!users.length) { $('ad-users').innerHTML = '<div class="muted" style="padding:16px 4px">還沒有任何同仁帳號——用上面的表單新增第一個。</div>'; return; }
|
||
$('ad-users').innerHTML = users.map(function (u) {
|
||
var star = (u.libraries || []).indexOf('*') >= 0;
|
||
var disabled = u.status === 'disabled';
|
||
var libChips = star
|
||
? '<span class="tag green">全部知識庫</span>'
|
||
: ((u.libraries || []).length
|
||
? (u.libraries || []).map(function (n) { return '<span class="tag green">' + esc(libDisplay(n)) + '</span>'; }).join(' ')
|
||
: '<span class="tag dim">尚未授權任何庫</span>');
|
||
var boxes = '<label><input type="checkbox" data-star data-uid="' + esc(u.record_id) + '"' + (star ? ' checked' : '') + '> 全部知識庫(*)</label>' +
|
||
checkableLibs(u.libraries).map(function (n) {
|
||
var on = !star && (u.libraries || []).indexOf(n) >= 0;
|
||
return '<label><input type="checkbox" data-lib="' + esc(n) + '" data-uid="' + esc(u.record_id) + '"' + (on ? ' checked' : '') + (star ? ' disabled' : '') + '> ' + esc(libDisplay(n)) + ' <span class="dim mono" style="font-size:12px">' + esc(n) + '</span></label>';
|
||
}).join('');
|
||
return '<div class="card-item"' + (disabled ? ' style="opacity:.6"' : '') + '>' +
|
||
'<div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap">' +
|
||
'<span class="serif" style="font-size:17px;font-weight:600">' + esc(u.display_name || u.email) + '</span>' +
|
||
'<span class="muted" style="font-size:14px;word-break:break-all">' + esc(u.email) + '</span>' +
|
||
'<span class="tag' + (u.role === 'admin' ? '' : ' dim') + '">' + (u.role === 'admin' ? '管理員' : '一般用戶') + '</span>' +
|
||
'<span class="tag ' + (disabled ? 'dim' : 'green') + '">' + (disabled ? '已停用' : '啟用中') + '</span></div>' +
|
||
'<div style="margin-top:9px;display:flex;gap:6px;flex-wrap:wrap;align-items:center;font-size:13px"><span class="muted" style="flex:none">可查庫</span>' + libChips + '</div>' +
|
||
'<div class="btnrow">' +
|
||
'<button class="btn2" data-act="edit-libs" data-uid="' + esc(u.record_id) + '">編輯可查庫</button>' +
|
||
'<button class="btn2" data-act="reset-pw" data-uid="' + esc(u.record_id) + '" data-email="' + esc(u.email) + '">重設密碼</button>' +
|
||
(disabled
|
||
? '<button class="btn2" data-act="set-status" data-next="active" data-uid="' + esc(u.record_id) + '">啟用</button>'
|
||
: '<button class="btn2 warn" data-act="set-status" data-next="disabled" data-uid="' + esc(u.record_id) + '" data-email="' + esc(u.email) + '">停用</button>') +
|
||
'</div>' +
|
||
'<div id="libed-' + esc(u.record_id) + '" style="display:none">' +
|
||
'<div class="libgrid">' + boxes + '</div>' +
|
||
'<div class="btnrow"><button class="btn2" data-act="save-libs" data-uid="' + esc(u.record_id) + '">儲存庫權限</button>' +
|
||
'<span class="dim" style="font-size:13px;align-self:center">勾「全部知識庫」=不限庫;否則至少勾一個。儲存後立即生效。</span></div></div>' +
|
||
'</div>';
|
||
}).join('');
|
||
}
|
||
|
||
function renderAdminLibs() {
|
||
if (!adminLibs.length) { $('ad-libs').innerHTML = '<div class="muted" style="padding:16px 4px">同步小幫手還沒送來任何庫。裝好小幫手並選好資料夾後,庫會自動出現在這裡。</div>'; return; }
|
||
$('ad-libs').innerHTML = adminLibs.map(function (l) {
|
||
var disabled = l.status === 'disabled';
|
||
var notWatching = l.daemon_watching === false; // daemon 有報告但此庫不在其中
|
||
var removeBtn = l.auto
|
||
? '<button class="btn2" style="color:#c0392b;border-color:#e57373;margin-left:auto" data-act="lib-remove-auto" data-name="' + esc(l.name) + '">移除</button>'
|
||
: '<button class="btn2" style="color:#c0392b;border-color:#e57373;margin-left:auto" data-act="lib-remove" data-id="' + esc(l.record_id) + '" data-name="' + esc(l.display_name || l.name) + '">移除</button>';
|
||
// t142:卡數+三元組數顯示(政府驗收用)。兩者皆 0 顯示「還沒有內容」,不顯示「0 張」。
|
||
var cardCount = typeof l.card_count === 'number' ? l.card_count : undefined;
|
||
var tripletCount = typeof l.triplet_count === 'number' ? l.triplet_count : undefined;
|
||
var statsHtml = '';
|
||
if (cardCount !== undefined || tripletCount !== undefined) {
|
||
var parts = [];
|
||
if (cardCount > 0) parts.push(cardCount + ' 張知識卡');
|
||
if (tripletCount > 0) parts.push(tripletCount + ' 條關聯');
|
||
statsHtml = '<div style="margin-top:5px;font-size:13px;color:rgba(var(--ink-rgb),.5)">' +
|
||
(parts.length ? parts.join('・') : '還沒有內容') + '</div>';
|
||
}
|
||
return '<div class="card-item">' +
|
||
'<div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap">' +
|
||
'<span class="serif" style="font-size:17px;font-weight:600">' + esc(l.display_name || l.name) + '</span>' +
|
||
'<span class="dim mono" style="font-size:13px">' + esc(l.name) + '</span>' +
|
||
(l.auto
|
||
? '<span class="tag green">啟用中</span><span class="tag">同步自動出現</span>'
|
||
: '<span class="tag ' + (disabled ? 'dim' : 'green') + '">' + (disabled ? '已停用' : '啟用中') + '</span>') +
|
||
removeBtn +
|
||
'</div>' +
|
||
statsHtml +
|
||
(!l.auto && l.description ? '<div style="margin-top:8px;font-size:14.5px;line-height:1.65;color:rgba(var(--ink-rgb),.65)">' + esc(l.description) + '</div>' : '') +
|
||
(notWatching ? '<div style="margin-top:6px;font-size:13px;color:rgba(var(--ink-rgb),.45)">小幫手目前沒有在同步這個資料夾</div>' : '') +
|
||
'</div>';
|
||
}).join('');
|
||
}
|
||
|
||
// 一次性密碼顯示(server 不留明碼——關掉就真的沒了,誠實跟 admin 說清楚)
|
||
function showOtp(email, password) {
|
||
$('ad-otp').innerHTML = '<div class="otpbox">' +
|
||
'<div style="font-size:15px;font-weight:600">' + esc(email) + ' 的一次性密碼</div>' +
|
||
'<div class="pw">' + esc(password) + '</div>' +
|
||
'<div style="font-size:13.5px;line-height:1.7;color:rgba(var(--ink-rgb),.65)">只顯示這一次——關閉後再也調不出來(系統只存雜湊)。請當場轉交同仁,並提醒登入後到「設定」改成自己的密碼。</div>' +
|
||
'<div class="btnrow"><button class="btn2" id="ad-otp-close">我已轉交,關閉</button></div></div>';
|
||
$('ad-otp').scrollIntoView({ block: 'nearest' });
|
||
}
|
||
|
||
// 新增帳號
|
||
$('ad-nu-create').addEventListener('click', function () {
|
||
var email = $('ad-nu-email').value.trim();
|
||
var name = $('ad-nu-name').value.trim();
|
||
var role = $('ad-nu-role').value === 'admin' ? 'admin' : 'user';
|
||
var st = $('ad-nu-status');
|
||
if (!email) { st.textContent = '請填 Email'; return; }
|
||
st.textContent = '';
|
||
$('ad-nu-create').disabled = true;
|
||
var body = { email: email, role: role };
|
||
if (name) body.display_name = name;
|
||
adminApi('POST', '/portal/admin/users', body)
|
||
.then(function (x) {
|
||
$('ad-nu-create').disabled = false;
|
||
if (guard401(x.status)) return;
|
||
if (!x.ok) { st.textContent = x.d.error || ('建立失敗(HTTP ' + x.status + ')'); return; }
|
||
$('ad-nu-email').value = ''; $('ad-nu-name').value = ''; $('ad-nu-role').value = 'user';
|
||
if (x.d.generated_password) showOtp(email, x.d.generated_password);
|
||
toast('帳號已建立');
|
||
loadAdmin();
|
||
})
|
||
.catch(function (e) { $('ad-nu-create').disabled = false; st.textContent = friendlyErr(e); });
|
||
});
|
||
|
||
|
||
// 「全部知識庫」勾選 → 其餘庫勾選框連動停用(存的就是 ["*"],個別勾選無意義)
|
||
document.addEventListener('change', function (ev) {
|
||
var t = ev.target;
|
||
if (!t || !t.hasAttribute || !t.hasAttribute('data-star')) return;
|
||
var uid = t.getAttribute('data-uid');
|
||
document.querySelectorAll('#libed-' + uid + ' input[data-lib]').forEach(function (cb) {
|
||
cb.disabled = t.checked;
|
||
if (t.checked) cb.checked = false;
|
||
});
|
||
});
|
||
|
||
// 管理頁動作(事件委派)
|
||
$('v-admin').addEventListener('click', function (ev) {
|
||
var t = ev.target.closest('[data-act]');
|
||
if (!t) return;
|
||
var act = t.getAttribute('data-act');
|
||
var uid = t.getAttribute('data-uid');
|
||
|
||
if (act === 'edit-libs') {
|
||
var ed = $('libed-' + uid);
|
||
ed.style.display = ed.style.display === 'none' ? '' : 'none';
|
||
return;
|
||
}
|
||
if (act === 'save-libs') {
|
||
var star = document.querySelector('#libed-' + uid + ' input[data-star]');
|
||
var libs;
|
||
if (star && star.checked) { libs = ['*']; }
|
||
else {
|
||
libs = [];
|
||
document.querySelectorAll('#libed-' + uid + ' input[data-lib]').forEach(function (cb) {
|
||
if (cb.checked) libs.push(cb.getAttribute('data-lib'));
|
||
});
|
||
if (!libs.length) { toast('至少勾一個庫,或勾「全部知識庫」'); return; }
|
||
}
|
||
t.disabled = true;
|
||
adminApi('PATCH', '/portal/admin/users/' + encodeURIComponent(uid), { libraries: libs })
|
||
.then(function (x) {
|
||
t.disabled = false;
|
||
if (guard401(x.status)) return;
|
||
if (!x.ok) { toast(x.d.error || ('儲存失敗(HTTP ' + x.status + ')')); return; }
|
||
toast('庫權限已更新,立即生效');
|
||
loadAdmin();
|
||
})
|
||
.catch(function (e) { t.disabled = false; toast(friendlyErr(e)); });
|
||
return;
|
||
}
|
||
if (act === 'set-status') {
|
||
var next = t.getAttribute('data-next');
|
||
if (next === 'disabled' && !confirm('確定停用 ' + (t.getAttribute('data-email') || '這個帳號') + '?停用後其登入與既有 session 立即失效。')) return;
|
||
t.disabled = true;
|
||
adminApi('PATCH', '/portal/admin/users/' + encodeURIComponent(uid), { status: next })
|
||
.then(function (x) {
|
||
t.disabled = false;
|
||
if (guard401(x.status)) return;
|
||
if (!x.ok) { toast(x.d.error || ('更新失敗(HTTP ' + x.status + ')')); return; } // 含最後一個 admin 的 409 訊息
|
||
toast(next === 'disabled' ? '已停用' : '已啟用');
|
||
loadAdmin();
|
||
})
|
||
.catch(function (e) { t.disabled = false; toast(friendlyErr(e)); });
|
||
return;
|
||
}
|
||
if (act === 'reset-pw') {
|
||
var email = t.getAttribute('data-email') || '';
|
||
if (!confirm('確定重設 ' + email + ' 的密碼?舊密碼將立即失效,會產生一組新的一次性密碼。')) return;
|
||
t.disabled = true;
|
||
adminApi('POST', '/portal/admin/users/' + encodeURIComponent(uid) + '/reset-password')
|
||
.then(function (x) {
|
||
t.disabled = false;
|
||
if (guard401(x.status)) return;
|
||
if (!x.ok) { toast(x.d.error || ('重設失敗(HTTP ' + x.status + ')')); return; }
|
||
showOtp(email, x.d.password);
|
||
toast('密碼已重設');
|
||
})
|
||
.catch(function (e) { t.disabled = false; toast(friendlyErr(e)); });
|
||
return;
|
||
}
|
||
// 移除已登記庫(有 record_id)
|
||
if (act === 'lib-remove') {
|
||
var libId = t.getAttribute('data-id') || '';
|
||
var libName = t.getAttribute('data-name') || libId;
|
||
if (!confirm('把「' + libName + '」從目錄移除?\n\n資料不會刪除,重新同步時會再出現。')) return;
|
||
t.disabled = true;
|
||
adminApi('DELETE', '/portal/admin/libraries/' + encodeURIComponent(libId))
|
||
.then(function (x) {
|
||
t.disabled = false;
|
||
if (guard401(x.status)) return;
|
||
if (!x.ok) { toast(x.d.error || ('移除失敗(HTTP ' + x.status + ')')); return; }
|
||
adminLibs = adminLibs.filter(function (l) { return l.record_id !== libId; });
|
||
renderAdminLibs();
|
||
toast('已從目錄移除');
|
||
})
|
||
.catch(function (e) { t.disabled = false; toast(friendlyErr(e)); });
|
||
return;
|
||
}
|
||
// 移除 auto 庫(無 record_id,entries 標 deprecated)
|
||
if (act === 'lib-remove-auto') {
|
||
var autoName = t.getAttribute('data-name') || '';
|
||
var input = window.prompt('移除「' + autoName + '」會讓它的內容不再被搜尋到(資料保留可還原)。\n\n請輸入庫名確認:');
|
||
if (input === null) return; // 取消
|
||
if (input.trim() !== autoName) { toast('庫名輸入不符,取消移除'); return; }
|
||
t.disabled = true;
|
||
adminApi('DELETE', '/portal/admin/libraries/by-name/' + encodeURIComponent(autoName), { confirm: autoName })
|
||
.then(function (x) {
|
||
t.disabled = false;
|
||
if (guard401(x.status)) return;
|
||
if (!x.ok) { toast(x.d.error || ('移除失敗(HTTP ' + x.status + ')')); return; }
|
||
adminLibs = adminLibs.filter(function (l) { return l.name !== autoName; });
|
||
renderAdminLibs();
|
||
toast('已移除(' + (x.d.deprecated_count || 0) + ' 筆標為不可搜)');
|
||
})
|
||
.catch(function (e) { t.disabled = false; toast(friendlyErr(e)); });
|
||
return;
|
||
}
|
||
});
|
||
// 一次性密碼關閉鈕(otpbox 動態生成,掛在容器上)
|
||
$('ad-otp').addEventListener('click', function (ev) {
|
||
if (ev.target && ev.target.id === 'ad-otp-close') $('ad-otp').innerHTML = '';
|
||
});
|
||
|
||
// ── 設定 ──
|
||
function loadSettings() {
|
||
var p = S.profile;
|
||
if (!p) { $('st-me').innerHTML = '<div class="muted">載入中…</div>'; return; }
|
||
if (window._loadAiConfig) window._loadAiConfig();
|
||
var libs = p.libraries || [];
|
||
var libHtml = libs.indexOf('*') >= 0
|
||
? '<span class="tag green">全部知識庫</span>'
|
||
: (libs.length ? libs.map(function (l) { return '<span class="tag green">' + esc(l) + '</span>'; }).join(' ') : '<span class="tag dim">尚未授權任何庫</span>');
|
||
$('st-me').innerHTML =
|
||
'<div class="kvline"><span class="muted">名稱</span><span>' + esc(p.display_name || '') + '</span></div>' +
|
||
'<div class="kvline"><span class="muted">角色</span><span>' + esc(p.role === 'admin' ? '管理員' : '一般用戶') + '</span></div>' +
|
||
'<div class="kvline" style="align-items:flex-start"><span class="muted" style="flex:none">可查庫</span><span style="text-align:right;display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end">' + libHtml + '</span></div>';
|
||
}
|
||
$('st-pw-save').addEventListener('click', function () {
|
||
var oldPw = $('st-pw-old').value;
|
||
var newPw = $('st-pw-new').value;
|
||
var newPw2 = $('st-pw-new2').value;
|
||
var st = $('st-pw-status');
|
||
st.className = 'err';
|
||
if (!oldPw || !newPw) { st.textContent = '請填舊密碼與新密碼'; return; }
|
||
if (newPw.length < 8) { st.textContent = '新密碼至少 8 碼'; return; }
|
||
if (newPw !== newPw2) { st.textContent = '兩次輸入的新密碼不一致'; return; }
|
||
$('st-pw-save').disabled = true;
|
||
st.textContent = '';
|
||
fetch(API_BASE + '/portal/me/password', {
|
||
method: 'POST',
|
||
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
|
||
body: JSON.stringify({ current: oldPw, 'new': newPw })
|
||
})
|
||
.then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
|
||
.then(function (x) {
|
||
$('st-pw-save').disabled = false;
|
||
if (guard401(x.status)) return;
|
||
if (!x.ok) { st.textContent = x.d.error || '更新失敗'; return; }
|
||
st.className = 'ok';
|
||
st.textContent = '密碼已更新';
|
||
$('st-pw-old').value = ''; $('st-pw-new').value = ''; $('st-pw-new2').value = '';
|
||
toast('密碼已更新');
|
||
})
|
||
.catch(function (e) { $('st-pw-save').disabled = false; st.textContent = friendlyErr(e); });
|
||
});
|
||
|
||
boot();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html> |