ad367e450d
🔴 根因:5a16484 把 UI 搬 CF Pages 時「移出 → console-ui/」實際上只搬了 build 產物(HTML),三支 renderer 原始檔被刪且沒搬 → `npm run build` 從那天起 ENOENT 死掉,線上 HTML 是刪檔前烤好的, 一個月來無法重建(CONSOLE_PROFILE 改了也不會生效,因為 build 跑不起來)。 修法: - 從 git 撈回 console.ts(1623)/portal-ui.ts(1390)/console-dashboard.ts(822) 放進 console-ui/src/——UI 原始碼跟著 UI 專案走,才是那一刀的原意 - build.mjs 加 resolveSource():本地 src/ 優先,找不到才回退 cypher-executor 新增具名部署目標(deploy.targets.json + scripts/deploy.mjs): 一個目標=帳號+profile+apiBase 綁在一起,解決三次帶漏參數: - demo 站漏 CONSOLE_PROFILE=rag → 顯示個人版 7 頁駕駛艙(leo「進去是 Mira 介面」的真因) - 兩站漏 ARCRUN_API_BASE → apiBase 空 → 前端打自己 405 → 登不進去 - 兩帳號都有同名 arcrun-console-ui 專案且 wrangler OAuth 登入在 uncle6 → 不指定帳號 deploy 會部到 demo 站(差點蓋掉,改用 API token 鎖帳號) npm run deploy:personal → leo21c, profile=full(7頁), apiBase 指 leo21c worker npm run deploy:enterprise → uncle6, profile=rag(4頁落地搜尋), apiBase 指 cypher.arcrun.dev 實測(用戶真的會走的路): - mira.uncle6.me/console/ 200,VIEWS 7 頁,apiBase 對,CORS ACAO 通 - rag-demo.arcrun.dev 4 頁落地搜尋;登入 OK、搜「藍鯨導航儀」5 筆(未受部署影響) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1391 lines
83 KiB
TypeScript
1391 lines
83 KiB
TypeScript
/**
|
||
* 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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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*>\\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 = ${JSON.stringify(sourceWebBase)};
|
||
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-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) : '';
|
||
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('/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 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('/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 || ''));
|
||
});
|