portal-auth P3:/portal/data/* server-side enforce+/portal 單檔 HTML 殼
- portal-data.ts(安全核心):search(server 注入 owner_id+library,caller 的 owner_id/library 參數一律被覆蓋);entries/:id 逐筆驗租戶+庫(越庫/不存在同一句 404 不洩存在性);graph D-4 粗閘(無權 403,SDD 明定例外);workflows D-8 (admin/all/off,唯讀+最近執行,無 trigger、無 webhook_url) - portal-ui.ts:獨立 HTML 殼(不動 console;樣式複製 console 設計語言子集)—— 登入/搜尋(三模式+source 溯源+卡片詳頁)/設定(改密碼/看權限/主題)/工作流 (admin);前端零租戶字串、零 X-Arcrun-API-Key,只持 portal session token - index.ts 掛載兩個新 router;tsc exit 0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,8 @@ import { consoleRouter } from './routes/console';
|
||||
import { consoleAuthRouter } from './routes/console-auth';
|
||||
import { consoleDashboardRouter } from './routes/console-dashboard';
|
||||
import { portalRouter } from './routes/portal';
|
||||
import { portalDataRouter } from './routes/portal-data';
|
||||
import { portalUiRouter } from './routes/portal-ui';
|
||||
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
@@ -57,7 +59,9 @@ app.route('/', kbdbProxyRouter); // kbdb-base 9.5:KBDB 資料層 proxy(讓
|
||||
app.route('/', consoleRouter); // Arcrun#3:搜尋/控制台頁 v0(單檔 HTML+原生 JS,薄殼)
|
||||
app.route('/', consoleAuthRouter); // Arcrun#3 發現②:console 專用簡單 email+password 登入(單一管理員帳密,非多租戶)
|
||||
app.route('/', consoleDashboardRouter); // T-cockpit ②:駕駛艙 dashboard(聚合 KBDB dash_* entries,無需登入唯讀)
|
||||
app.route('/', portalRouter); // portal-auth P2(#24/#25):RAG Portal 多人授權——用戶模型+認證 API(P3 UI 另一波)
|
||||
app.route('/', portalRouter); // portal-auth P2(#24/#25):RAG Portal 多人授權——用戶模型+認證 API
|
||||
app.route('/', portalDataRouter); // portal-auth P3:/portal/data/* server-side enforce(owner_id+library 注入,安全核心)
|
||||
app.route('/', portalUiRouter); // portal-auth P3:GET /portal 單檔 HTML 殼(登入/搜尋/設定;獨立於 console)
|
||||
|
||||
// Worker 導出(fetch + scheduled)
|
||||
// scheduled handler 對應 wrangler.toml [triggers].crons,每分鐘 tick;
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* RAG Portal 查詢面 — P3:/portal/data/* server-side enforce(portal-auth design §3.3/§3.4/§5,
|
||||
* Gitea #24/#25)。本檔是本 SDD 的**安全核心**。
|
||||
*
|
||||
* 安全模型(design §3.3,與 console 的關鍵差異):
|
||||
* - console 登入後把 CONSOLE_TENANT 下發給前端直打 /kbdb/*;**portal 前端絕不持有租戶字串**,
|
||||
* 只有 portal session token。portal_user 拿到租戶字串就能繞過庫 filter 直打 /kbdb/search,
|
||||
* 所以 enforce 全在 server:session → 回讀 user record(唯一真相源)→ 取 libraries →
|
||||
* server 注入 owner_id+library 後轉發 KBDB。
|
||||
* - caller 自帶的 owner_id / library query 參數**一律忽略**(不是拒絕——拒絕會變成
|
||||
* 「試參數名」的 oracle;直接靜默覆蓋,怎麼傳都是自己的權限範圍)。
|
||||
* - ["*"]=全庫:只注 owner_id、不注 library(design §3.3)。
|
||||
*
|
||||
* 不洩存在性(紅線):越庫的 entry(含根本不存在的 id、別的租戶的 id)一律回**同一句 404**,
|
||||
* 不讓攻擊者從 403/404 差異推斷某 id / 某庫存在。唯一例外=graph 粗閘按 SDD 明定回 403(D-4)。
|
||||
*
|
||||
* 薄殼(rule 07):本檔沒有新能力——搜尋/取條目能力真身在 KBDB base(P1 的 library filter),
|
||||
* graph 真身在 kbdb-graph-plugin,工作流真身在 WEBHOOKS/ANALYTICS KV(與 /webhooks/named、
|
||||
* /workflows/:name/executions 同一資料源)。這裡只做「權限注入+轉發/讀取」。
|
||||
*
|
||||
* log 紅線:本檔不 log 任何 token / 密碼 / 查詢內容。
|
||||
*/
|
||||
import { Hono } from 'hono';
|
||||
import type { Context } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { kbdbFetch, run, requirePortalUser, parseLibraries, portalTenant, hasGraphAccess, workflowsVisible } from './portal';
|
||||
import { graphBase } from './kbdb-proxy';
|
||||
|
||||
export const portalDataRouter = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
/** 越庫/不存在 一律同一句 404(不洩存在性)。 */
|
||||
function notFound(c: Context<{ Bindings: Bindings }>): Response {
|
||||
return c.json({ error: '找不到這筆資料' }, 404);
|
||||
}
|
||||
|
||||
/** entry 的庫歸屬:metadata_json.$.library,未標記 → 'general'(design §3.2 fallback,與 KBDB P1 同語意)。 */
|
||||
export function entryLibrary(entry: { metadata_json?: string | null }): string {
|
||||
try {
|
||||
const meta = JSON.parse(entry.metadata_json ?? 'null') as { library?: unknown } | null;
|
||||
if (meta && typeof meta.library === 'string' && meta.library.trim()) return meta.library;
|
||||
} catch {
|
||||
/* metadata 壞掉 → 視同未標記 */
|
||||
}
|
||||
return 'general';
|
||||
}
|
||||
|
||||
/** 用戶庫集合是否覆蓋某庫(["*"]=全庫)。 */
|
||||
function canReadLibrary(userLibraries: string[], library: string): boolean {
|
||||
return userLibraries.includes('*') || userLibraries.includes(library);
|
||||
}
|
||||
|
||||
// GET /portal/data/search?q=&mode=&entry_type=&limit= — 三模式中的 keyword/semantic
|
||||
//(graph 走 /portal/data/graph/*)。server 注入 owner_id+library;回應照 KBDB 原形
|
||||
//(entries 含 metadata_json,前端自取 source 溯源;mode/capability_hint 誠實透傳——
|
||||
// semantic 未開的降級行為沿 KBDB 既有,P1 已保 library 照 enforce)。
|
||||
portalDataRouter.get('/portal/data/search', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const q = c.req.query('q');
|
||||
if (!q) return c.json({ error: 'q 必填' }, 400);
|
||||
|
||||
const libraries = parseLibraries(auth.user.values.libraries);
|
||||
if (libraries.length === 0) {
|
||||
// 帳號沒被授權任何庫:誠實空結果(不打 KBDB——沒有可查範圍就沒有查詢)
|
||||
return c.json({ success: true, entries: [], count: 0, mode: 'keyword', note: '此帳號尚未被授權任何知識庫,請聯絡管理員。' });
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ q, owner_id: portalTenant(c.env) });
|
||||
if (!libraries.includes('*')) params.set('library', libraries.join(','));
|
||||
// 透傳的只有「在權限範圍內再收窄」的 filter;owner_id/library 上面已由 server 定死,
|
||||
// caller 傳什麼都不看(URLSearchParams 是新建的,蓋不掉)。
|
||||
if (c.req.query('mode') === 'semantic') params.set('mode', 'semantic');
|
||||
const entryType = c.req.query('entry_type');
|
||||
if (entryType) params.set('entry_type', entryType);
|
||||
const limit = c.req.query('limit');
|
||||
if (limit && /^\d{1,3}$/.test(limit)) params.set('limit', limit);
|
||||
|
||||
const res = await kbdbFetch(c.env, `/entries/search?${params.toString()}`);
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
}),
|
||||
);
|
||||
|
||||
// GET /portal/data/entries/:id — 卡片詳頁。**逐筆驗庫**(design §5):
|
||||
// ① entry 必須屬於本實例租戶(owner_id=CONSOLE_TENANT)——防拿別租戶 id 直讀;
|
||||
// ② entry 的 library(NULL→general)必須在用戶庫集合內——防拿越庫 id 直讀。
|
||||
// 兩者不符與不存在同回 404(不洩存在性)。
|
||||
portalDataRouter.get('/portal/data/entries/:id', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const libraries = parseLibraries(auth.user.values.libraries);
|
||||
if (libraries.length === 0) return notFound(c);
|
||||
|
||||
const res = await kbdbFetch(c.env, `/entries/${encodeURIComponent(c.req.param('id'))}`);
|
||||
if (res.status === 404) return notFound(c);
|
||||
if (!res.ok) return c.json({ error: `KBDB 回錯(HTTP ${res.status})` }, 502);
|
||||
const body = (await res.json()) as { entry?: { owner_id?: string | null; metadata_json?: string | null } };
|
||||
const entry = body.entry;
|
||||
if (!entry) return notFound(c);
|
||||
if ((entry.owner_id ?? '') !== portalTenant(c.env)) return notFound(c);
|
||||
if (!canReadLibrary(libraries, entryLibrary(entry))) return notFound(c);
|
||||
return c.json({ success: true, entry });
|
||||
}),
|
||||
);
|
||||
|
||||
// GET /portal/data/graph/neighbors/:name — graph 模式(D-4 粗閘):
|
||||
// 只對「擁有 graph 來源庫權限」的用戶開放;無權 → 403(SDD 明定,graph 粗閘是 404 紅線的例外)。
|
||||
// 放行後純轉發 kbdb-graph-plugin(token 只在 server 側,同 kbdb-proxy 慣例)。
|
||||
portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const libraries = parseLibraries(auth.user.values.libraries);
|
||||
if (!(await hasGraphAccess(c.env, libraries))) {
|
||||
return c.json({ error: '無知識圖譜檢視權限' }, 403);
|
||||
}
|
||||
const base = graphBase(c.env);
|
||||
const headers: Record<string, string> = {};
|
||||
if (c.env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`;
|
||||
try {
|
||||
const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(c.req.param('name'))}`, { headers });
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
} catch (e) {
|
||||
// plugin 沒部署/不可達 → 誠實 502(前端顯示「關聯服務不可達」,不假裝無關聯)
|
||||
return c.json({ error: `kbdb-graph-plugin 不可達:${e instanceof Error ? e.message : String(e)}` }, 502);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// GET /portal/data/workflows — 工作流顯示(D-8):唯讀 list+每條的最近一次執行,**不開 trigger**
|
||||
//(trigger 是 owner/console 的事;回應也不含 webhook_url,不給可打的把手)。
|
||||
// 可見性:PORTAL_SHOW_WORKFLOWS=admin(預設,role 閘 403)/ all / off(整頁不存在 → 404)。
|
||||
portalDataRouter.get('/portal/data/workflows', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const setting = (c.env.PORTAL_SHOW_WORKFLOWS ?? 'admin').toLowerCase();
|
||||
if (setting === 'off') return notFound(c);
|
||||
if (!workflowsVisible(c.env, auth.user.values.role ?? 'user')) {
|
||||
return c.json({ error: '需要 admin 權限' }, 403);
|
||||
}
|
||||
|
||||
// 資料源與 /webhooks/named + /workflows/:name/executions 同一份(WEBHOOKS/ANALYTICS KV)。
|
||||
// 不經 HTTP 打自己(global_fetch_strictly_public 下 fetch 自己 hostname 會 self-loop),
|
||||
// 直讀同 worker 的 KV binding;欄位收斂成唯讀展示需要的最小集合。
|
||||
const tenant = portalTenant(c.env);
|
||||
const prefix = `${tenant}:wf:`;
|
||||
const list = await c.env.WEBHOOKS.list({ prefix });
|
||||
const workflows = await Promise.all(
|
||||
list.keys.map(async (k) => {
|
||||
const name = k.name.slice(prefix.length);
|
||||
const raw = await c.env.WEBHOOKS.get(k.name, 'text');
|
||||
let description = '';
|
||||
let created_at = '';
|
||||
let cron_expr: string | undefined;
|
||||
if (raw) {
|
||||
try {
|
||||
const rec = JSON.parse(raw) as { description?: string; created_at?: string; cron_expr?: string };
|
||||
description = rec.description ?? '';
|
||||
created_at = rec.created_at ?? '';
|
||||
cron_expr = rec.cron_expr;
|
||||
} catch {
|
||||
/* 壞 record 誠實留空 */
|
||||
}
|
||||
}
|
||||
// 最近一次執行:ANALYTICS_KV stats:{name}:{unix_ms}——key 後綴定長毫秒 timestamp,
|
||||
// 字典序=時間序,取最後一把 key 即最新(同 /workflows/:name/executions 的排序邏輯)。
|
||||
let last_execution: { timestamp: string; verdict?: string } | null = null;
|
||||
const stats = await c.env.ANALYTICS_KV.list({ prefix: `stats:${name}:`, limit: 1000 });
|
||||
if (stats.keys.length > 0) {
|
||||
const latest = stats.keys.reduce((a, b) => (a.name > b.name ? a : b));
|
||||
const ts = latest.name.split(':').pop() ?? '';
|
||||
const rawStat = await c.env.ANALYTICS_KV.get(latest.name);
|
||||
let verdict: string | undefined;
|
||||
if (rawStat) {
|
||||
try {
|
||||
verdict = (JSON.parse(rawStat) as { verdict?: string }).verdict;
|
||||
} catch {
|
||||
/* 壞 record 誠實留空 */
|
||||
}
|
||||
}
|
||||
last_execution = { timestamp: ts, verdict };
|
||||
}
|
||||
return { name, description, created_at, cron_expr, last_execution };
|
||||
}),
|
||||
);
|
||||
return c.json({ success: true, workflows, total: workflows.length, read_only: true });
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,743 @@
|
||||
/**
|
||||
* 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)
|
||||
* - 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): 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; }
|
||||
|
||||
/* ── 清單 / 設定 ── */
|
||||
.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-workflows" data-nav="workflows">工作流</div>
|
||||
<div class="nav" data-nav="settings">設定</div>
|
||||
<div class="nav themelabel" data-themetoggle style="margin-top:8px;font-size:14.5px">☾ 切深色</div>
|
||||
<div class="foot" id="side-foot"></div>
|
||||
</div>
|
||||
<main>
|
||||
|
||||
<!-- 搜尋(落地頁)-->
|
||||
<div class="view page" id="v-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>
|
||||
<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>
|
||||
|
||||
<!-- 卡片詳頁 -->
|
||||
<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>
|
||||
|
||||
<!-- 工作流(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>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 手機底部導覽 -->
|
||||
<div id="tabbar">
|
||||
<div class="tab" data-nav="search">搜尋</div>
|
||||
<div class="tab hide" id="tab-workflows" data-nav="workflows">工作流</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 : ''; }
|
||||
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', 'card', 'workflows', 'settings'];
|
||||
var HOME = 'search';
|
||||
function allowedViews() {
|
||||
// 工作流頁按 session 能力顯示(真閘在 server:/portal/data/workflows 403/404)
|
||||
return (S.profile && S.profile.workflows_visible) ? VIEWS : VIEWS.filter(function (v) { return v !== 'workflows'; });
|
||||
}
|
||||
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, workflows: loadWorkflows, 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);
|
||||
$('mode-graph').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'));
|
||||
});
|
||||
|
||||
// ── 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 || [];
|
||||
$('se-count').textContent = '節點「' + name + '」・鄰居 ' + neighbors.length + '・關聯 ' + edges.length;
|
||||
if (!neighbors.length) { renderGraphEmpty('尚無關聯資料——這個名字還沒有連進知識圖譜。'); return; }
|
||||
renderGraph(name, neighbors.slice(0, 10));
|
||||
$('gr-edges').innerHTML = edges.slice(0, 12).map(function (t) {
|
||||
var other = t.subject === name ? t.object : t.subject;
|
||||
return '<div class="nbrow" data-gnode="' + esc(other) + '">' +
|
||||
'<span class="tag green" style="flex:none">' + esc(t.predicate || '關聯') + '</span>' +
|
||||
'<span style="word-break:break-word">' + esc(other) + '</span>' +
|
||||
'<span style="margin-left:auto;color:rgba(var(--amber-rgb),.6);flex:none">›</span></div>';
|
||||
}).join('');
|
||||
})
|
||||
.catch(function (e) { $('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
|
||||
? '<div class="srcline"><span style="font-size:13px;letter-spacing:.14em;color:rgba(var(--ink-rgb),.5);flex:none">來源回溯</span><span class="mono" style="font-size:14px;color:var(--amber);word-break:break-all">' + esc(src) + '</span></div>'
|
||||
: '');
|
||||
})
|
||||
.catch(function (er) { $('cd-main').innerHTML = '<div class="err">請求失敗:' + esc(friendlyErr(er)) + '</div>'; });
|
||||
}
|
||||
|
||||
// ── 工作流(唯讀)──
|
||||
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>'; });
|
||||
}
|
||||
|
||||
// ── 設定 ──
|
||||
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));
|
||||
});
|
||||
Reference in New Issue
Block a user