portal-auth P3(#24 #25):Portal UI+/portal/data/* server-side enforce(不 merge,待總管審) (#52)
This commit was merged in pull request #52.
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;
|
||||
|
||||
@@ -30,9 +30,11 @@ export const PORTAL_TEMPLATE_SEEDS: PortalTemplateSeed[] = [
|
||||
{
|
||||
// design §3.2:庫目錄(admin 頁列庫用)。庫本體=知識條目 metadata_json.$.library 標記,
|
||||
// 這裡只是「有哪些庫」的登記簿。
|
||||
// graph_source(design D-4,P3):'true'=此庫是知識圖譜的萃取來源——graph 粗閘按
|
||||
// 「用戶是否擁有 graph 來源庫權限」放行。**沒有任何庫標記時預設視同 general**(D-4 定案)。
|
||||
name: 'portal_library',
|
||||
description: 'RAG Portal 庫目錄登記(portal-auth §3.2;庫=metadata_json.$.library 標記)',
|
||||
slots: ['name', 'display_name', 'description', 'status'],
|
||||
slots: ['name', 'display_name', 'description', 'status', 'graph_source'],
|
||||
created_by: 'system',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -121,8 +121,10 @@ kbdbProxyRouter.get('/kbdb/records/:recordId', async (c) => {
|
||||
|
||||
// ── search(限本租戶範圍內)────────────────────────────────────────────────────
|
||||
|
||||
// GET /kbdb/search?q=&entry_type=&source=&mode= — entries 搜尋,限本租戶 owner_id。
|
||||
// 透傳 entry_type(base 通用 filter,workflow-discovery Q4)/ source / mode 給 KBDB /entries/search。
|
||||
// GET /kbdb/search?q=&entry_type=&source=&library=&mode= — entries 搜尋,限本租戶 owner_id。
|
||||
// 透傳 entry_type(base 通用 filter,workflow-discovery Q4)/ source / library(多值逗號分隔,
|
||||
// portal-auth P1 順延項——owner/admin 面自選庫過濾;portal 一般用戶不經這,走 /portal/data/*
|
||||
// 的 server 注入)/ mode 給 KBDB /entries/search。
|
||||
kbdbProxyRouter.get('/kbdb/search', async (c) => {
|
||||
const owner = tenant(c);
|
||||
if (!owner) return c.json(NEED_KEY, 401);
|
||||
@@ -130,7 +132,7 @@ kbdbProxyRouter.get('/kbdb/search', async (c) => {
|
||||
if (!q) return c.json({ error: 'q 必填' }, 400);
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
const params = new URLSearchParams({ q, owner_id: owner });
|
||||
for (const k of ['entry_type', 'source', 'mode']) {
|
||||
for (const k of ['entry_type', 'source', 'library', 'mode']) {
|
||||
const v = c.req.query(k);
|
||||
if (v) params.set(k, v);
|
||||
}
|
||||
@@ -160,7 +162,7 @@ kbdbProxyRouter.post('/kbdb/entries', async (c) => {
|
||||
return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
|
||||
});
|
||||
|
||||
// GET /kbdb/entries — list(filters: entry_type / parent_id / page_name / source / q(search) / limit / offset)。
|
||||
// GET /kbdb/entries — list(filters: entry_type / parent_id / page_name / source / library / q(search) / limit / offset)。
|
||||
// owner_id 強制覆寫成本租戶(防跨租戶讀;caller 不能查別人的 owner_id)。
|
||||
// Arcrun#3 發現①根因:本白名單原本沒有 q/search,caller 帶 search= 會被這裡靜默丟棄,
|
||||
// 打到 base 永遠是「無過濾 list」——不是 458K 筆搜不到,是這個 filter 從沒被轉發過。
|
||||
@@ -171,7 +173,7 @@ kbdbProxyRouter.get('/kbdb/entries', async (c) => {
|
||||
const { base, headers } = kbdbBase(c.env);
|
||||
const params = new URLSearchParams();
|
||||
params.set('owner_id', owner); // 強制本租戶,不接受 caller 覆寫
|
||||
for (const k of ['entry_type', 'parent_id', 'page_name', 'source', 'limit', 'offset']) {
|
||||
for (const k of ['entry_type', 'parent_id', 'page_name', 'source', 'library', 'limit', 'offset']) {
|
||||
const v = c.req.query(k);
|
||||
if (v) params.set(k, v);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
@@ -39,9 +39,14 @@ const LIBRARY_TEMPLATE = 'portal_library';
|
||||
|
||||
// ── 基礎 helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
/** 帳號子 namespace(design D-2)。tenant 預設沿 console-auth 同款 'leo'。 */
|
||||
/** 租戶字串(=知識資料的 owner_id)。預設沿 console-auth 同款 'leo'。**只在 server 側使用,永不下發前端**。 */
|
||||
export function portalTenant(env: Bindings): string {
|
||||
return env.CONSOLE_TENANT || 'leo';
|
||||
}
|
||||
|
||||
/** 帳號子 namespace(design D-2)。 */
|
||||
function portalNamespace(env: Bindings): string {
|
||||
return `${env.CONSOLE_TENANT || 'leo'}::portal`;
|
||||
return `${portalTenant(env)}::portal`;
|
||||
}
|
||||
|
||||
function sessionTtl(env: Bindings): number {
|
||||
@@ -56,9 +61,9 @@ function bearerToken(c: Context<{ Bindings: Bindings }>): string | null {
|
||||
}
|
||||
|
||||
/** KBDB 不可達/回錯時拋這個 → 各 route 統一 502 誠實回報(不假綠、不偽裝成 401)。 */
|
||||
class KbdbError extends Error {}
|
||||
export class KbdbError extends Error {}
|
||||
|
||||
async function kbdbFetch(env: Bindings, path: string, init?: RequestInit): Promise<Response> {
|
||||
export async function kbdbFetch(env: Bindings, path: string, init?: RequestInit): Promise<Response> {
|
||||
const { base, headers } = kbdbBase(env);
|
||||
let res: Response;
|
||||
try {
|
||||
@@ -70,7 +75,7 @@ async function kbdbFetch(env: Bindings, path: string, init?: RequestInit): Promi
|
||||
}
|
||||
|
||||
/** route handler 包一層:KbdbError → 502(誠實),其餘照拋。 */
|
||||
async function run(c: Context<{ Bindings: Bindings }>, fn: () => Promise<Response>): Promise<Response> {
|
||||
export async function run(c: Context<{ Bindings: Bindings }>, fn: () => Promise<Response>): Promise<Response> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
@@ -81,7 +86,7 @@ async function run(c: Context<{ Bindings: Bindings }>, fn: () => Promise<Respons
|
||||
|
||||
// ── KBDB 資料層 helpers(全走 base HTTP API,零 SQL)────────────────────────────
|
||||
|
||||
interface PortalRecord {
|
||||
export interface PortalRecord {
|
||||
record_id: string;
|
||||
template_id: string;
|
||||
values: Record<string, string>;
|
||||
@@ -98,6 +103,30 @@ export async function ensurePortalTemplates(
|
||||
try {
|
||||
const got = await kbdbFetch(env, `/templates/${encodeURIComponent(seed.name)}`);
|
||||
if (got.ok) {
|
||||
// 已存在 → 檢查 slots 是否落後 seed(如 P3 新增 portal_library.graph_source)。
|
||||
// updateRecord 對「不在 template slots_json 的 slot」會 reject——不補 slot,
|
||||
// 舊實例就永遠寫不進新標記。PATCH 補聯集(冪等,既有 record 不動)。
|
||||
const body = (await got.json().catch(() => null)) as {
|
||||
template?: { id: string; slots_json?: string };
|
||||
} | null;
|
||||
const tpl = body?.template;
|
||||
if (tpl?.id && tpl.slots_json) {
|
||||
let currentSlots: string[] = [];
|
||||
try {
|
||||
const parsed = JSON.parse(tpl.slots_json);
|
||||
if (Array.isArray(parsed)) currentSlots = parsed.filter((s): s is string => typeof s === 'string');
|
||||
} catch {
|
||||
/* slots_json 壞掉 → 視同空,補成 seed 全集 */
|
||||
}
|
||||
const missing = seed.slots.filter((s) => !currentSlots.includes(s));
|
||||
if (missing.length > 0) {
|
||||
const patched = await kbdbFetch(env, `/templates/${encodeURIComponent(tpl.id)}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ slots: [...currentSlots, ...missing] }),
|
||||
});
|
||||
if (!patched.ok) throw new KbdbError(`PATCH /templates/${seed.name} 補 slots → ${patched.status}`);
|
||||
}
|
||||
}
|
||||
existing.push(seed.name);
|
||||
continue;
|
||||
}
|
||||
@@ -155,7 +184,7 @@ async function patchRecordValues(env: Bindings, recordId: string, values: Record
|
||||
return body.record;
|
||||
}
|
||||
|
||||
async function listRecordsByTemplate(env: Bindings, template: string): Promise<PortalRecord[]> {
|
||||
export async function listRecordsByTemplate(env: Bindings, template: string): Promise<PortalRecord[]> {
|
||||
const ns = portalNamespace(env);
|
||||
const res = await kbdbFetch(env, `/records/by-template/${encodeURIComponent(template)}?owner_id=${encodeURIComponent(ns)}`);
|
||||
if (!res.ok) throw new KbdbError(`GET /records/by-template/${template} → ${res.status}`);
|
||||
@@ -213,7 +242,7 @@ async function createPortalUser(env: Bindings, input: CreateUserInput): Promise<
|
||||
|
||||
// ── user 值域 helpers ──────────────────────────────────────────────────────
|
||||
|
||||
function parseLibraries(raw: string | undefined): string[] {
|
||||
export function parseLibraries(raw: string | undefined): string[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const arr = JSON.parse(raw);
|
||||
@@ -254,14 +283,14 @@ function toPublicUser(rec: PortalRecord) {
|
||||
|
||||
// ── session 閘 ────────────────────────────────────────────────────────────
|
||||
|
||||
type AuthedUser = { token: string; recordId: string; values: Record<string, string> };
|
||||
type AuthResult = { ok: true; user: AuthedUser } | { ok: false; res: Response };
|
||||
export type AuthedUser = { token: string; recordId: string; values: Record<string, string> };
|
||||
export type AuthResult = { ok: true; user: AuthedUser } | { ok: false; res: Response };
|
||||
|
||||
/**
|
||||
* portal session 閘:token → KV → record_id → **回讀 record**(唯一真相源)→ status=active。
|
||||
* 停用即時生效(design §4.3);停用/孤兒 session 順手刪 KV(best-effort,正確性不依賴它)。
|
||||
*/
|
||||
async function requirePortalUser(c: Context<{ Bindings: Bindings }>): Promise<AuthResult> {
|
||||
export async function requirePortalUser(c: Context<{ Bindings: Bindings }>): Promise<AuthResult> {
|
||||
const token = bearerToken(c);
|
||||
if (!token) return { ok: false, res: c.json({ error: '未登入' }, 401) };
|
||||
const sess = await c.env.SESSIONS_KV.get(`${SESSION_PREFIX}${token}`);
|
||||
@@ -297,6 +326,40 @@ async function requirePortalAdmin(c: Context<{ Bindings: Bindings }>): Promise<A
|
||||
return auth;
|
||||
}
|
||||
|
||||
// ── D-4 graph 粗閘 / D-8 工作流頁能力(P3;server 是唯一裁決點,前端只照 session 渲染)────
|
||||
|
||||
/**
|
||||
* 知識圖譜的「來源庫」集合(design D-4):portal_library 中標 graph_source='true'
|
||||
* 且未停用的庫。**沒有任何庫標記時預設 ['general']**(D-4 定案)。
|
||||
*/
|
||||
export async function graphSourceLibraries(env: Bindings): Promise<string[]> {
|
||||
const libs = await listRecordsByTemplate(env, LIBRARY_TEMPLATE);
|
||||
const marked = libs
|
||||
.filter((l) => (l.values.graph_source ?? '') === 'true' && (l.values.status ?? 'active') !== 'disabled')
|
||||
.map((l) => l.values.name ?? '')
|
||||
.filter(Boolean);
|
||||
return marked.length > 0 ? marked : ['general'];
|
||||
}
|
||||
|
||||
/** graph 粗閘判定:擁有任一 graph 來源庫的權限(或 ["*"] 全庫)才放行。 */
|
||||
export async function hasGraphAccess(env: Bindings, userLibraries: string[]): Promise<boolean> {
|
||||
if (userLibraries.includes('*')) return true; // 全庫 → 必含來源庫,省一次 KBDB 呼叫
|
||||
if (userLibraries.length === 0) return false;
|
||||
const sources = await graphSourceLibraries(env);
|
||||
return sources.some((s) => userLibraries.includes(s));
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作流頁可見性(design D-8 定案:admin):PORTAL_SHOW_WORKFLOWS = admin(預設)/ all / off。
|
||||
* 壞值誠實退回預設 admin(不因 typo 意外全開)。
|
||||
*/
|
||||
export function workflowsVisible(env: Bindings, role: string): boolean {
|
||||
const setting = (env.PORTAL_SHOW_WORKFLOWS ?? 'admin').toLowerCase();
|
||||
if (setting === 'off') return false;
|
||||
if (setting === 'all') return true;
|
||||
return role === 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* admin 操作目標 record 的成員資格驗證:record 的 email head entry(子 namespace 內)
|
||||
* 必須指回同一 record_id——同時證明「是 portal_user」且「在本實例的 {tenant}::portal 下」,
|
||||
@@ -402,16 +465,23 @@ portalRouter.post('/portal/logout', async (c) => {
|
||||
|
||||
// GET /portal/session — 每請求回讀 user record(真相源);回 display_name/role/libraries,
|
||||
// **絕不回租戶字串**(design §5)。
|
||||
// P3 補能力欄位(前端據此渲染,design §6/D-4/D-8):graph_allowed(graph 模式要不要顯示)、
|
||||
// workflows_visible(工作流頁要不要顯示)。**這兩個只是顯示提示——真正的擋在
|
||||
// /portal/data/* 路由層**(無權 403/404),前端藏不藏都繞不過。
|
||||
portalRouter.get('/portal/session', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
const v = auth.user.values;
|
||||
const role = v.role ?? 'user';
|
||||
const libraries = parseLibraries(v.libraries);
|
||||
return c.json({
|
||||
valid: true,
|
||||
display_name: v.display_name ?? '',
|
||||
role: v.role ?? 'user',
|
||||
libraries: parseLibraries(v.libraries),
|
||||
role,
|
||||
libraries,
|
||||
graph_allowed: await hasGraphAccess(c.env, libraries),
|
||||
workflows_visible: workflowsVisible(c.env, role),
|
||||
});
|
||||
}),
|
||||
);
|
||||
@@ -597,6 +667,8 @@ function toPublicLibrary(rec: PortalRecord) {
|
||||
display_name: v.display_name ?? '',
|
||||
description: v.description ?? '',
|
||||
status: v.status ?? '',
|
||||
// D-4:此庫是否為知識圖譜萃取來源(graph 粗閘按這個判定;全都沒標 → 預設 general)
|
||||
graph_source: (v.graph_source ?? '') === 'true',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -669,8 +741,15 @@ portalRouter.patch('/portal/admin/libraries/:id', (c) =>
|
||||
}
|
||||
patch.status = body.status;
|
||||
}
|
||||
// D-4(P3):標記/取消「知識圖譜來源庫」。boolean 進、slot 存 'true'/'false' 字串。
|
||||
if (body.graph_source !== undefined) {
|
||||
if (typeof body.graph_source !== 'boolean') {
|
||||
return c.json({ error: 'graph_source 只能是 true / false' }, 400);
|
||||
}
|
||||
patch.graph_source = body.graph_source ? 'true' : 'false';
|
||||
}
|
||||
if (Object.keys(patch).length === 0) {
|
||||
return c.json({ error: '沒有可更新的欄位(display_name/description/status)' }, 400);
|
||||
return c.json({ error: '沒有可更新的欄位(display_name/description/status/graph_source)' }, 400);
|
||||
}
|
||||
const updated = await patchRecordValues(c.env, recordId, patch);
|
||||
return c.json({ success: true, library: toPublicLibrary(updated) });
|
||||
|
||||
@@ -106,6 +106,10 @@ export type Bindings = {
|
||||
// expirationTtl。未設 → 604800(7 天,design §4.3——issue 要求短效,比 console 30 天緊)。
|
||||
// 只影響新發的 session;權限/停用的即時性不靠 TTL(每請求回讀 user record)。
|
||||
PORTAL_SESSION_TTL?: string;
|
||||
// Portal 工作流頁可見性(portal-auth P3,design D-8 定案,非機密):admin(預設)/ all / off。
|
||||
// 路由層 enforce 在 /portal/data/workflows(無權 403、off 404),前端只照 /portal/session
|
||||
// 的 workflows_visible 顯示或隱藏 nav 項。壞值退回 admin(不因 typo 意外全開)。
|
||||
PORTAL_SHOW_WORKFLOWS?: string;
|
||||
// kbdb-graph-plugin worker base URL(可選)。未設 → 用 WORKER_SUBDOMAIN 現算
|
||||
// https://kbdb-graph-plugin.<subdomain>.workers.dev(該 repo wrangler.toml name 固定)。
|
||||
// console 卡片詳頁「關聯視圖」經 cypher proxy 打它(kbdb-proxy.ts /kbdb/graph/neighbors/:name)。
|
||||
|
||||
Reference in New Issue
Block a user