Files
arcrun-collector/cmd/arcrun-app/frontend/src/main.js
T
Leo 73004b4149 失敗清單改成「檔名 + 三角形,預設收合」(leo 實測版面被壓扁)
leo:「表格最左邊的標題被壓扁了…**可以顯示沒通過的名稱,旁邊有個三角形,
點擊看到失敗細節,default 摺疊起來,重點是找到問題在哪裏**」

## 真兇
我重用 `.step` 的橫向格線(`display:flex` + 原因擠在右邊)
⇒ 中文檔名被壓成**一行一個字**(實撞截圖)。

## 改法
改用原生 `<details>/<summary>`:三角形、鍵盤可操作、預設收合都不必自己寫。
檔名一行(`word-break: break-word`,長檔名換行但不是一字一行),
原因收在裡面,需要時才展開——**先看到「哪些檔沒過」,細節是第二層**。
CSS 用實際存在的 CIS 變數(`--ink-rgb`/`--amber`;原本誤用不存在的 `--hair`/`--ink-soft`)。

## 驗
`node --check` 過、`check-cis.sh` **全過**、go build 過。
⚠️ **未真機驗**(未打包送到 leo 手上)。
2026-08-06 22:31:00 +08:00

357 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Arcrun 桌面 App — 前端(t193
//
// 版面(leo 08-04 指定,附 Google Drive 截圖):左側邊欄、右側換頁。
// 🔴 08-04 二輪回饋(本次處理):
// ③「開啟知識庫網頁」不該在首頁(會開到錯的庫)⇒ 移到**各庫頁的上方**
// ④ 一個庫 30 個資料夾放不下 ⇒ **每個知識庫一個獨立分頁**
// ⑤「加入資料夾」會加到哪個帳號?⇒ 在庫頁裡加,**作用對象就是那個庫**,不會加錯
// ⑥ 首頁要顯示 status:看守/發現變化/萃取/上傳 ⇒ **狀態時間軸**
import './arcrun-cis.css'; // 共用底層(色票/字體/紋理)——唯一真相源在 arcrun-cis/css/
import './style.css'; // 本 App 的版面
const go = window.go.main.App;
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s).replace(/[&<>"]/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
// 主題:**與 portal 同一套規則**——預設淺色,使用者切換後存 localStorage
// (leo 問「它有淺色佈景?」⇒ 是,portal 預設就是淺色)
const THEME_KEY = 'arcrun_app_theme';
function applyTheme(t) {
document.documentElement.setAttribute('data-theme', t);
try { localStorage.setItem(THEME_KEY, t); } catch (e) {}
const b = $('themeBtn'); if (b) b.textContent = t === 'dark' ? '☀' : '☾';
}
applyTheme((() => { try { return localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light'; } catch (e) { return 'light'; } })());
$('themeBtn').onclick = () =>
applyTheme(document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark');
let state = null;
let page = 'home'; // 'home' | 'ai' | 'update' | 'lib:<idx>'
let updateInfo = null;
// ── 覆蓋層(只給必須打斷的確認)──
function openSheet(html, wire) { $('sheet').innerHTML = html; $('overlay').classList.add('on'); if (wire) wire(); }
function closeSheet() { $('overlay').classList.remove('on'); }
$('overlay').addEventListener('click', (e) => { if (e.target.id === 'overlay') closeSheet(); });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeSheet(); });
// ── 側邊欄:每個知識庫一項(leo:「每個帳號有獨立的一個頁面」)──
function renderNav() {
const accs = (state && state.accounts) || [];
$('nav').innerHTML = `
<div class="nav" data-p="home"><span class="ic">◫</span><span class="nm">首頁</span></div>
${accs.length ? `<div class="sec">知識庫</div>` : ''}
${accs.map((a, i) => `
<div class="nav" data-p="lib:${i}">
<span class="ic">▤</span><span class="nm">${esc(a.name)}</span>
<span class="cnt">${(a.folders || []).length}</span>
</div>`).join('')}
<div class="sec">設定</div>
<div class="nav" data-p="ai"><span class="ic">✧</span><span class="nm">AI 設定</span></div>
<div class="nav" data-p="update"><span class="ic">↧</span><span class="nm">版本與更新</span></div>`;
$('nav').querySelectorAll('.nav').forEach((el) => {
el.classList.toggle('on', el.dataset.p === page);
el.onclick = () => { page = el.dataset.p; renderNav(); renderPage(); };
});
}
// ── 首頁:狀態時間軸(leo:「看守、發現變化、萃取、上傳… 不同 status 在哪裡顯示?」)──
function pageHome(s) {
if (!s.accounts || !s.accounts.length) return onboarding();
const st = s.steps || [];
return `
<div class="card">
<h3>現在的狀態</h3>
<div class="steps">
${st.map((x) => `
<div class="step ${esc(x.state)}">
<span class="dot"></span>
<span class="t">${esc(x.title)}</span>
<span class="m">${esc(x.meta || '')}</span>
</div>`).join('')}
</div>
</div>
${cardTrouble(s)}
${cardFailures(s.failures)}
${cardSkipped(s.skipped)}
<div class="card">
<h3>總計</h3>
<div class="kv" style="margin-top:10px">
<div><div class="big-num">${s.accounts.length}</div><div class="k">個知識庫</div></div>
<div><div class="big-num">${s.accounts.reduce((n,a)=>n+(a.folders||[]).length,0)}</div><div class="k">個資料夾在看守</div></div>
<div><div class="big-num">${s.extractedOK || 0}</div><div class="k">份已整理</div></div>
</div>
</div>`;
}
// 🔴 G-6.2「不准安靜地略過」(2026-08-06)——J-1/S6 考題的後半句:
// 「Then 我一樣找得到——**或當場被告知這種檔案還不支援**」
// 以前 .doc.pages 這類檔在 collector 掃描時就被丟掉,畫面上一個字都沒有,
// 使用者只能得到「我丟了檔,然後什麼都沒發生」這個結論。
// 這張卡就是那句話該出現的地方——**首頁**,他每次打開 App 一定會看到。
// 沒有東西被略過時後端回 null ⇒ 這裡回空字串,畫面保持乾淨(沒事不佔版面)。
// 引擎有問題時才長出來:一鍵打開紀錄檔資料夾。
// leo 2026-08-06:「不能用一個 debug mode?」——log 一直都在寫,缺的是入口。
function cardTrouble(s) {
if (!s.engineTrouble) return '';
return `
<div class="card">
<h3>需要回報這個問題?</h3>
<div class="d">
詳細的錯誤紀錄已經自動存在你電腦裡,不必開啟任何設定。<br/>
把 <b>collector.log</b> 和 <b>app.log</b> 傳給我們就能查。
</div>
<div class="d" style="margin-top:6px;opacity:.7">${esc(s.logFolder || '')}</div>
<div class="acts"><button id="hLogs">打開紀錄檔資料夾</button></div>
</div>`;
}
// 失敗的檔案:列出**檔名與原因**。
// leo 2026-08-06 實測:畫面只寫「⚠ 3 份失敗」,他只能回報「沒推到雲端」,
// 而原因本來就在 status.json 裡 —— 數字不能代替原因。
function cardFailures(f) {
if (!f || !f.items || !f.items.length) return '';
// 🔴 leo 2026-08-06:「**可以顯示沒通過的名稱,旁邊有個三角形,點擊看到失敗細節,
// default 摺疊起來,重點是找到問題在哪裏**」
// ——他要的是先看到「哪些檔沒過」,細節是需要時才展開。
// 先前把原因直接攤在旁邊,害檔名欄被擠成一行一個字(實撞截圖)。
// 用原生 <details>:三角形、鍵盤可操作、預設收合,都不必自己寫。
return `
<div class="card">
<h3>${esc(f.title)}</h3>
<div class="d">${esc(f.note)}</div>
<div class="faillist">
${f.items.map((it) => `
<details class="fail">
<summary>${esc(it.name)}</summary>
<div class="failwhy">${esc(it.reason)}</div>
</details>`).join('')}
</div>
${f.more > 0 ? `<div class="d" style="margin-top:8px">…還有 ${f.more} 份</div>` : ''}
</div>`;
}
function cardSkipped(k) {
if (!k) return '';
return `
<div class="card">
<h3>${esc(k.title)}</h3>
<div class="d" style="margin-top:6px">${esc(k.note)}</div>
${(k.files || []).length ? `
<ul class="skiplist">
${k.files.map((f) => `<li>${esc(f)}</li>`).join('')}
${k.more ? `<li class="more">…等 ${k.more} 個</li>` : ''}
</ul>` : ''}
${k.other ? `<div class="d" style="margin-top:8px">${esc(k.other)}</div>` : ''}
</div>`;
}
// ── 各庫頁:動作全部作用在這個庫(不會加錯帳號)──
function pageLib(s, idx) {
const a = s.accounts[idx];
if (!a) return `<div class="empty"><div class="t">找不到這個知識庫</div></div>`;
const portal = 'https://' + a.host.replace('arcrun-cypher-executor.', 'arcrun-rag-ui.') + '/portal/';
return `
<div class="libhead">
<div class="g">
<div class="nm">${esc(a.name)}</div>
<div class="host">${esc(a.host)}</div>
</div>
<button data-portal="${esc(portal)}">開啟知識庫網頁</button>
<button class="primary" data-addto="${idx}">加入資料夾</button>
</div>
${(a.folders || []).map((f) => `
<div class="folder">
<span class="path" title="${esc(f.path)}">${esc(f.path)}</span>
<span class="tag">自動同步中</span>
<button class="ghost" data-rm="${esc(f.path)}" data-acc="${f.accIdx}">移除</button>
</div>`).join('')
|| `<div class="empty"><div class="t">這個知識庫還沒有資料夾</div>
<div class="d">按右上的「加入資料夾」,選一個要自動整理的資料夾。</div></div>`}`;
}
function pageAI(s) {
const gem = s.engine === 'gemma';
return `
<div class="card">
<h3>用哪個 AI 幫你整理文件?</h3>
<label class="radio"><input type="radio" name="ai" value="cloud" ${gem ? '' : 'checked'}/>
<span><b>雲端 AI</b>(推薦・不必申請任何金鑰)<br/>
<span class="d">用你自己 Cloudflare 帳號內建的 AI,不需要任何金鑰、不必去別的網站申請。</span></span></label>
<label class="radio"><input type="radio" name="ai" value="gemini" ${gem ? 'checked' : ''}/>
<span><b>Google Gemini</b>(需要自己申請金鑰)<br/>
<span class="d">進階選項。要自己去 aistudio.google.com 申請一把 API Key。</span></span></label>
<div class="field">
<div class="lb">Gemini 金鑰${s.geminiKey ? '(目前已設定,清空並儲存即可刪除)' : ''}</div>
<input type="text" id="k" placeholder="貼上你的 Gemini API Key"/>
</div>
<div class="err" id="aiErr" style="display:none"></div>
<div class="acts"><button class="primary" id="aiSave">儲存</button></div>
</div>`;
}
function pageUpdate(s) {
const u = updateInfo;
const latest = u ? (u.latest || '查詢中…') : '按「檢查更新」查詢';
let action = `<button class="primary" id="uCheck">檢查更新</button>`;
let note = '';
if (u && u.staged) {
action = `<button class="primary" id="uApply">重新啟動以完成更新</button>`;
note = `<div class="d">新版 ${esc(u.latest)} 已下載完成,重新啟動就會套用。</div>`;
} else if (u && u.available) {
action = `<button class="primary" id="uDownload">下載並安裝 ${esc(u.latest)}</button>`;
note = u.notes ? `<div class="d">${esc(u.notes)}</div>` : '';
} else if (u && u.err) {
note = `<div class="err">${esc(u.err)}</div>`;
} else if (u) {
note = `<div class="d">你已經是最新版本。</div>`;
}
return `
<div class="card">
<h3>版本與更新</h3>
<div class="kv" style="margin-top:12px">
<div><div class="big-num">${esc(s.version || '—')}</div><div class="k">目前版本</div></div>
<div><div class="big-num">${esc(latest)}</div><div class="k">最新版本</div></div>
</div>
<div style="margin-top:14px">${note}</div>
<div class="acts">${action}</div>
</div>
<div class="card">
<h3>需要協助?</h3>
<div class="d">安裝、更新、把知識庫接到你的 AI,說明文件都寫在這裡。</div>
<div class="acts"><button id="uDocs">開啟使用說明</button></div>
</div>`;
}
function onboarding() {
return `
<div class="empty">
<div class="t">先連上你的知識庫</div>
<div class="d">
Arcrun 會看守你指定的資料夾,<br/>
自動把文件整理成 AI 查得到的知識卡。<br/>
還沒有知識庫?先到 install.arcrun.dev 裝一個(免費)。
</div>
<button class="primary" id="obConnect">新增知識庫帳號</button>
<button id="obInstall">我還沒有知識庫</button>
</div>`;
}
function renderPage() {
if (!state) return;
let html;
if (page.startsWith('lib:')) html = pageLib(state, Number(page.slice(4)));
else if (page === 'ai') html = pageAI(state);
else if (page === 'update') html = pageUpdate(state);
else html = pageHome(state);
// 每個庫頁底下都給「新增知識庫帳號」入口
if (page === 'home' && state.accounts && state.accounts.length) {
html += `<div class="acts"><button id="hAcct">新增知識庫帳號</button></div>`;
}
$('page').innerHTML = html;
wire();
}
function wire() {
const on = (id, fn) => { const e = $(id); if (e) e.onclick = fn; };
on('uDocs', () => go.OpenURL('https://rag.arcrun.dev/docs/'));
on('hLogs', () => go.OpenLogFolder());
on('hLogs', () => go.OpenLogFolder());
on('hAcct', showConnect); on('obConnect', showConnect);
on('obInstall', () => go.OpenURL('https://install.arcrun.dev/'));
on('aiSave', saveAI);
on('uCheck', checkUpdate); on('uDownload', downloadUpdate); on('uApply', applyUpdate);
document.querySelectorAll('[data-portal]').forEach((b) => { b.onclick = () => go.OpenURL(b.dataset.portal); });
document.querySelectorAll('[data-addto]').forEach((b) => { b.onclick = () => addFolder(Number(b.dataset.addto)); });
document.querySelectorAll('[data-rm]').forEach((b) => {
b.onclick = () => confirmRemove(Number(b.dataset.acc), b.dataset.rm);
});
}
function render(s) {
const first = !state;
const navChanged = state && JSON.stringify((state.accounts||[]).map(a=>[a.name,(a.folders||[]).length]))
!== JSON.stringify((s.accounts||[]).map(a=>[a.name,(a.folders||[]).length]));
state = s;
$('ver').textContent = s.version || '';
$('statusBig').textContent = s.statusBig;
$('statusBig').classList.toggle('syncing', !!s.syncing);
$('statusSub').textContent = s.statusSub;
if (first || navChanged) { renderNav(); renderPage(); }
else if (page === 'home') renderPage(); // 首頁的狀態時間軸要跟著跳
}
async function tick() { try { render(await go.GetState()); } catch (e) {} }
// ── 動作 ──
$('btnSync').onclick = async () => { await go.SyncNow(); tick(); };
async function addFolder(accIdx) {
const p = await go.PickFolder();
if (!p) return;
await go.AddFolder(accIdx, p);
state = await go.GetState(); renderNav(); renderPage();
}
function confirmRemove(accIdx, path) {
openSheet(`
<h2>移除這個資料夾?</h2>
<p>「${esc(path)}」不再自動同步。<br/>已經上傳的知識卡不會被刪除。</p>
<div class="acts"><button id="c1">取消</button><button class="primary" id="c2">移除</button></div>`,
() => {
$('c1').onclick = closeSheet;
$('c2').onclick = async () => {
await go.RemoveFolder(accIdx, path); closeSheet();
state = await go.GetState(); renderNav(); renderPage();
};
});
}
function showConnect() {
openSheet(`
<h2>連上你的知識庫</h2>
<p>貼上你的知識庫網址,再輸入你在網站上設定的帳號密碼。</p>
<div class="field"><div class="lb">知識庫網址</div>
<input type="text" id="u" placeholder="https://arcrun-cypher-executor.xxxx.workers.dev"/></div>
<div class="field"><div class="lb">帳號(Email</div>
<input type="text" id="e" placeholder="you@example.com"/></div>
<div class="field"><div class="lb">密碼</div><input type="password" id="p"/></div>
<div class="err" id="err" style="display:none"></div>
<div class="acts"><button id="c1">取消</button><button class="primary" id="c2">連線</button></div>`,
() => {
$('c1').onclick = closeSheet;
$('c2').onclick = async () => {
try {
await go.Connect($('u').value.trim(), $('e').value.trim(), $('p').value);
closeSheet(); state = await go.GetState(); renderNav(); renderPage();
} catch (ex) { $('err').textContent = String(ex); $('err').style.display = 'block'; }
};
});
}
async function saveAI() {
const useGemini = document.querySelector('input[name=ai]:checked').value === 'gemini';
try {
await go.SetAI(useGemini, $('k').value.trim());
state = await go.GetState(); renderPage();
} catch (ex) { $('aiErr').textContent = String(ex); $('aiErr').style.display = 'block'; }
}
async function checkUpdate() {
updateInfo = { latest: '查詢中…' }; renderPage();
try { updateInfo = await go.CheckUpdate(); } catch (ex) { updateInfo = { err: String(ex) }; }
renderPage();
}
async function downloadUpdate() {
updateInfo = Object.assign({}, updateInfo, { notes: '下載中…請稍候' }); renderPage();
try { updateInfo = await go.DownloadUpdate(); } catch (ex) { updateInfo = { err: String(ex) }; }
renderPage();
}
async function applyUpdate() {
try { await go.ApplyUpdate(); } catch (ex) { updateInfo = { err: String(ex) }; renderPage(); }
}
tick();
setInterval(tick, 1000);