73004b4149
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 手上)。
357 lines
16 KiB
JavaScript
357 lines
16 KiB
JavaScript
// 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) =>
|
||
({ '&': '&', '<': '<', '>': '>', '"': '"' }[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);
|