/**
 * RAG Portal 前端殼 — P3：GET /portal 單檔 HTML（portal-auth design §1 D-1/§6，Gitea #24/#25）
 *
 * 形態（D-1）：cypher-executor 的新路由、**獨立 HTML 殼**——不在 console 上加 if（「Admin
 * Console 現狀不動」鐵律）。樣式重用 console 的設計語言（紙感/明體標題/琥珀強調/深淺主題），
 * 取捨＝**複製樣式子集而非抽共用模組**：抽共用要動 console.ts（違「console 不動」），且兩頁
 * 受眾不同（owner vs 同仁）預期各自演化；代價＝樣式雙份、改版要兩邊同步（PR 說明誠實記）。
 *
 * 頁面（design §6，leo 頁面級拍板）：
 *   - 登入頁（brand＝CONSOLE_BRAND；未登入只見這個殼）
 *   - 搜尋頁（落地頁）：keyword/semantic/graph 三模式＋結果含 source 溯源＋卡片詳頁
 *   - 設定頁：改自己密碼、看自己角色與可查庫、主題切換
 *   - 工作流頁：D-8 定案 admin 可見（PORTAL_SHOW_WORKFLOWS 可調 all/off）；唯讀、不開 trigger
 *   - graph 模式按 D-4 粗閘顯示（/portal/session 的 graph_allowed）
 *   - 管理頁（P4，admin-only nav）：帳號管理（列表/新增/停用/重設密碼＋每帳號勾選可查庫
 *     含 ["*"] 全庫）＋庫目錄管理（登記/停用/graph_source 標記）。一次性密碼只在畫面顯示
 *     一次（server 不留明碼）；一般用戶 nav 不顯示、直打 /portal/admin/* 也是 403（role 閘）。
 *   - Mira 專屬頁（駕駛艙/分流台/憑證/專案）一律不進
 *
 * 安全（design §3.3 關鍵差異 vs console）：
 *   - 本頁 JS **沒有任何租戶字串、沒有 X-Arcrun-API-Key**——只持 portal session token
 *     （localStorage `arcrun_portal_session`），一切資料走 /portal/data/*（server-side enforce）。
 *   - 前端的「藏」（graph 模式不顯示、工作流頁不顯示）只是 UX；真閘在 /portal/data/* 路由層
 *     （403/404），curl 直打也繞不過（#48 guard 精神）。
 *   - 所有動態內容經 esc() 跳脫（防 XSS，同 console 慣例）；密碼欄位值不進 log、不進 URL。
 *
 * 薄殼（rule 07）：零業務邏輯，只 fetch /portal/* 端點渲染。
 */
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { TAIPEI_CLIENT_JS } from '../lib/taipei-time';

export const portalUiRouter = new Hono<{ Bindings: Bindings }>();

function renderPortalHtml(brand: string, sourceWebBase = ''): string {
  return `<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${brand} 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>
</head>
<body>

<!-- 登入（未登入只見這個殼；本頁不含任何資料）-->
<div class="authwrap view" id="v-login">
  <div class="authbox">
    <div>
      <div class="brand">${brand}</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>

<!-- 主應用 -->
<div id="shell">
  <div id="sidenav">
    <div class="logo">${brand}</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>
      <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="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)">
      這裡是「庫」的登記簿——條目歸哪個庫由資料導入（ingest）時蓋章決定；未蓋章的舊資料一律視同 <b style="color:var(--ink)">general</b>。標了「圖譜來源」的庫決定誰能用圖譜模式（全都沒標＝預設 general）。
    </div>
    <div class="panel">
      <div style="font-size:16px;font-weight:600;margin-bottom:12px">登記新庫</div>
      <div class="formrow">
        <input type="text" id="ad-nl-name" class="txt" placeholder="庫名（英數 _ -，例：finance）" autocomplete="off">
        <input type="text" id="ad-nl-display" class="txt" placeholder="顯示名（例：財務庫）" autocomplete="off">
        <input type="text" id="ad-nl-desc" class="txt" placeholder="描述（可留空）" autocomplete="off">
        <button class="btn" id="ad-nl-create" style="flex:none;padding:0 20px">登記</button>
      </div>
      <div id="ad-nl-status" class="err" style="font-size:14px;min-height:1.2em;margin-top:8px"></div>
    </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 () {
  'use strict';
  // 台北時間 helper（lib/taipei-time.ts 注入，console 同套——顯示不隨裝置時區漂移）
  ${TAIPEI_CLIENT_JS}
  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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
    });
  }
  function toast(msg) {
    var el = $('toast');
    el.textContent = msg;
    el.style.display = 'block';
    clearTimeout(toast._t);
    toast._t = setTimeout(function () { el.style.display = 'none'; }, 2000);
  }
  function friendlyErr(e) {
    var m = e && e.message ? String(e.message) : String(e);
    if (/failed to fetch|load failed|networkerror|network request failed/i.test(m)) return '連線中斷——請檢查網路後重試';
    return m;
  }
  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*&gt;\\s?/.test(line)) { closeList(); out.push('<blockquote>' + mdInline(line.replace(/^\\s*&gt;\\s?/, '')) + '</blockquote>'); continue; }
      var li = raw.match(/^\\s*[-*+]\\s+(.*)$/);
      if (li) { if (listMode !== 'ul') { closeList(); out.push('<ul>'); listMode = 'ul'; } out.push('<li>' + mdInline(esc(li[1])) + '</li>'); continue; }
      var oli = raw.match(/^\\s*\\d+[.)]\\s+(.*)$/);
      if (oli) { if (listMode !== 'ol') { closeList(); out.push('<ol>'); listMode = 'ol'; } out.push('<li>' + mdInline(esc(oli[1])) + '</li>'); continue; }
      if (/^\\s*$/.test(raw)) { closeList(); continue; }
      closeList();
      out.push('<p>' + mdInline(line) + '</p>');
    }
    closeList();
    if (inCode) out.push('</code></pre>');
    return out.join('');
  }

  // ── 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 有設才給 href。認兩種 gitea scheme（t21）：
  //   gitea://<path>          → {base}/<path>（base 已含 repo 瀏覽前綴；舊 rag_ingest v2，行為不變）
  //   gitea:<org/repo>@<path> → {base}/<org/repo>/src/branch/main/<path>（km-wiki-ingest；base＝Gitea 站根）
  // kb://<path>＝用戶自己同步資料夾內的相對路徑，雲端沒有對應網頁 → 回空字串不造死鏈；
  // 顯示交給 srcLocalPath()（誠實標「在你的同步資料夾內」）。其餘 scheme 回空＝維持純文字。
  // chunk/segment 錨點（#n / #segNN）捨棄——Gitea 網頁沒有對應錨。
  var SOURCE_WEB_BASE = ${JSON.stringify(sourceWebBase)};
  function encSrcPath(p) { return p.split('/').map(encodeURIComponent).join('/'); }
  function srcHref(src) {
    src = String(src);
    if (!SOURCE_WEB_BASE) return '';
    var base = SOURCE_WEB_BASE.replace(/\\/+$/, '');
    if (src.indexOf('gitea://') === 0) {
      var p = src.slice(8).replace(/#\\d+$/, '');
      return base + '/' + encSrcPath(p);
    }
    if (src.indexOf('gitea:') === 0) {
      var rest = src.slice(6).replace(/#[^#]*$/, '');
      var at = rest.indexOf('@');
      if (at <= 0 || at >= rest.length - 1) return '';
      return base + '/' + encSrcPath(rest.slice(0, at)) + '/src/branch/main/' + encSrcPath(rest.slice(at + 1));
    }
    return '';
  }
  // kb:// 來源的本地相對路徑（去 scheme、去錨點）；非 kb:// 回空字串。
  function srcLocalPath(src) {
    src = String(src);
    if (src.indexOf('kb://') !== 0) return '';
    return src.slice(5).replace(/#[^#]*$/, '');
  }
  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-login').classList.add('on');
    $('shell').classList.remove('on');
    $('tabbar').classList.remove('on');
  }
  function showApp() {
    $('v-login').classList.remove('on');
    $('shell').classList.add('on');
    $('tabbar').classList.add('on');
    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('/portal/session', { headers: authHeaders() })
      .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
      .then(function (x) {
        if (!x.ok) { dropSession(); return; }
        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('/portal/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email: email, password: password })
    })
      .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
      .then(function (x) {
        $('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); });
  }
  $('st-logout').addEventListener('click', function () {
    fetch('/portal/logout', { method: 'POST', headers: authHeaders() }).catch(function () { /* 盡力而為 */ });
    dropSession();
  });

  // 任何 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 = '/portal/data/search?q=' + encodeURIComponent(q) + '&mode=' + (S.mode === 'semantic' ? 'semantic' : 'keyword');
    fetch(url, { headers: authHeaders() })
      .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
      .then(function (x) {
        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('/portal/data/chat?question=' + encodeURIComponent(q), { headers: authHeaders() })
      .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
      .then(function (x) {
        $('ai-go').disabled = false;
        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) : '';
              var localPath = s && s.source ? srcLocalPath(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>' : '') +
                (!href && localPath ? '<span class="dim mono" style="flex:none;font-size:12.5px" title="' + esc(localPath) + '">在你的同步資料夾內</span>' : '') +
                '<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('/portal/data/graph/neighbors/' + encodeURIComponent(name), { headers: authHeaders() })
      .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
      .then(function (x) {
        if (guard401(x.status)) return;
        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('/portal/data/entries/' + encodeURIComponent(S.cardId), { headers: authHeaders() })
      .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
      .then(function (x) {
        if (guard401(x.status)) return;
        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 localPath = srcLocalPath(src);
                var body;
                if (href) {
                  body = '<a class="mono" style="font-size:14px;color:var(--amber);word-break:break-all" href="' + esc(href) + '" target="_blank" rel="noopener">' + esc(src) + ' ↗</a>';
                } else if (localPath) {
                  // kb://＝本地同步資料夾的相對路徑，雲端沒有對應網頁——誠實顯示路徑文字＋標註，不造死鏈。
                  body = '<span class="mono" style="font-size:14px;color:var(--amber);word-break:break-all">' + esc(localPath) + '</span>' +
                    '<span class="dim" style="font-size:12.5px;flex:none">在你的同步資料夾內</span>';
                } else {
                  body = '<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('/portal/data/graph/overview', { headers: authHeaders() })
      .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
      .then(function (x) {
        if (guard401(x.status)) return;
        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('/portal/data/workflows', { headers: authHeaders() })
      .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
      .then(function (x) {
        if (guard401(x.status)) return;
        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('/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 r.json().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) {
    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 r.json().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">還沒有登記任何庫。未登記時整個系統視同單一 general 庫。</div>'; return; }
    $('ad-libs').innerHTML = adminLibs.map(function (l) {
      var disabled = l.status === 'disabled';
      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(l.display_name || l.name) + '</span>' +
        '<span class="dim mono" style="font-size:13px">' + esc(l.name) + '</span>' +
        '<span class="tag ' + (disabled ? 'dim' : 'green') + '">' + (disabled ? '已停用' : '啟用中') + '</span>' +
        (l.graph_source ? '<span class="tag">圖譜來源</span>' : '') + '</div>' +
        (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>' : '') +
        '<div class="btnrow">' +
        '<button class="btn2" data-act="lib-graph" data-lid="' + esc(l.record_id) + '" data-next="' + (l.graph_source ? 'false' : 'true') + '">' + (l.graph_source ? '取消圖譜來源' : '標為圖譜來源') + '</button>' +
        (disabled
          ? '<button class="btn2" data-act="lib-status" data-next="active" data-lid="' + esc(l.record_id) + '">啟用</button>'
          : '<button class="btn2 warn" data-act="lib-status" data-next="disabled" data-lid="' + esc(l.record_id) + '" data-name="' + esc(l.display_name || l.name) + '">停用</button>') +
        '</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); });
  });

  // 登記新庫
  $('ad-nl-create').addEventListener('click', function () {
    var name = $('ad-nl-name').value.trim();
    var st = $('ad-nl-status');
    if (!name) { st.textContent = '請填庫名（英數 _ -）'; return; }
    st.textContent = '';
    $('ad-nl-create').disabled = true;
    adminApi('POST', '/portal/admin/libraries', {
      name: name,
      display_name: $('ad-nl-display').value.trim(),
      description: $('ad-nl-desc').value.trim()
    })
      .then(function (x) {
        $('ad-nl-create').disabled = false;
        if (guard401(x.status)) return;
        if (!x.ok) { st.textContent = x.d.error || ('登記失敗（HTTP ' + x.status + '）'); return; }
        $('ad-nl-name').value = ''; $('ad-nl-display').value = ''; $('ad-nl-desc').value = '';
        toast('庫已登記');
        loadAdmin();
      })
      .catch(function (e) { $('ad-nl-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;
    }
    if (act === 'lib-status' || act === 'lib-graph') {
      var lid = t.getAttribute('data-lid');
      var body2 = {};
      if (act === 'lib-status') {
        var nextS = t.getAttribute('data-next');
        if (nextS === 'disabled' && !confirm('確定停用庫「' + (t.getAttribute('data-name') || '') + '」？停用後不再出現在庫勾選清單（既有授權不會被自動改）。')) return;
        body2.status = nextS;
      } else {
        body2.graph_source = t.getAttribute('data-next') === 'true';
      }
      t.disabled = true;
      adminApi('PATCH', '/portal/admin/libraries/' + encodeURIComponent(lid), body2)
        .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;
    }
  });
  // 一次性密碼關閉鈕（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; }
    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('/portal/me/password', {
      method: 'POST',
      headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
      body: JSON.stringify({ current: oldPw, 'new': newPw })
    })
      .then(function (r) { return r.json().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>`;
}

// GET /portal — Portal HTML 殼（無 auth：回的是純殼，資料全在 /portal/data/* session 閘後）。
// 未 bootstrap 任何 portal_user 時登入必失敗（「尚未啟用」的誠實形態），不影響 console。
portalUiRouter.get('/portal', (c) => {
  const brand = c.env.CONSOLE_BRAND || 'Arcrun';
  return c.html(renderPortalHtml(brand, c.env.PORTAL_SOURCE_WEB_BASE || ''));
});
