t193 二修:側邊欄版面+共用 CSS+App 內更新+兩支視覺機械閘

leo 08-04 看過 v0.17.0 後的四點,逐條回應:

## ① 「不會這裡又寫了一個新的 CSS?」——是,我又寫了一份
真兇:portal 的樣式內嵌在 HTML 裡,沒有獨立檔 ⇒ 四個站各抄一份、本來就在漂移。
解:抽出 InkStoneCo/arcrun-cis/css/arcrun-cis.css 當唯一真相源。
    daemon 的 style.css 現在**一個 hex 都沒有**,顏色全走 var(--...)。

## ② 「完全模仿 gdrive,左方側邊欄,在右方替換頁面」
底部 nav bar 拿掉(leo:「幾乎沒看過這種 nav bar 在下方的」)。
改成左側邊欄四頁:首頁/同步資料夾/AI 設定/版本與更新,右側換頁。
設定不再開 popup;只有「確認移除資料夾」才用覆蓋層。

## ③ 「既然是 Web,你做的時候無法檢視?」——可以,我之前沒做
新增 check-render.sh:headless Chrome 真的渲染一次並**量像素**。
它抓到一個肉眼容易誤判的真 bug:
  arcrun-lockup-h-paper-on-ink.png **自帶不透明 Ink 底板**(實測左上像素 RGBA=23,24,26,255),
  設計前提是貼在純 Ink 表面;我們背景有紙張紋理 ⇒ 出現突兀方塊。
  修:深色模式把品牌區做成純 Ink。實測落差 4(原本會是一個明顯方塊)。
過程中也發現自己對著**舊的 dist**除錯,浪費一輪——這支閘同時防這個。

## ④ 「檢查更新連到 docs,不正確,是直接在這裡進行」
新增「版本與更新」頁:顯示**現在版本 vs 最新版本**,
三段式全在 App 內完成——檢查/下載並安裝/重新啟動套用。
selfupdate 邏輯逐字沿用 arcrun-tray(含 t186 走 raw、t184 蓋正在跑的 .app),不重寫。

## 交貨閘(leo:「你做為檢查有嗎?沒有怎麼交貨?」)
check-cis.sh 擴充:版面檔不准出現任何 hex + 共用底層色票逐項比對。
兩支閘實跑全過。
This commit is contained in:
2026-08-04 23:09:54 +08:00
parent bd0cead00e
commit 3d13e5a54e
9 changed files with 893 additions and 226 deletions
+211 -105
View File
@@ -1,62 +1,70 @@
// Arcrun 桌面 App — 前端(t193
//
// 全部畫面都在**同一個視窗**裡:狀態、清單、設定都用內嵌覆蓋層,
// 不再像 fyne 版那樣「每個功能開一個小小的 popup」(leo 08-04 點破)。
import './style.css';
// 版面照 leo 08-04 指定(附 Google Drive 截圖):
// 「可以完全模仿 gdrive,**左方側邊欄,在右方替換頁面**」
// ⇒ 設定不再開 popup,全部是右側的頁;只有「確認刪除」才用覆蓋層。
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]));
// 跟隨系統深淺色——CIS 兩套色票都定義好了,這裡只負責切 data-theme
// 跟隨系統深淺色CIS 兩套色票都在 arcrun-cis.css 定義好了)
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const applyTheme = () =>
document.documentElement.setAttribute('data-theme', mq.matches ? 'dark' : 'light');
mq.addEventListener('change', applyTheme);
applyTheme();
// ── 覆蓋層(取代 popup 視窗)──
let state = null;
let page = 'home';
let updateInfo = null; // { current, latest, available, notes, staged }
// ── 覆蓋層(只給必須打斷的確認)──
function openSheet(html, wire) {
$('sheet').innerHTML = html;
$('overlay').classList.add('on');
if (wire) 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(); });
const esc = (s) => String(s).replace(/[&<>"]/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
// ── 側邊欄換頁 ──
document.querySelectorAll('#side .nav').forEach((el) => {
el.onclick = () => {
page = el.dataset.page;
document.querySelectorAll('#side .nav').forEach((n) => n.classList.toggle('on', n === el));
renderPage();
};
});
// ── 主畫面渲染 ──
let state = null;
// ── 各頁 ──
function pageHome(s) {
const folders = (s.accounts || []).reduce((n, a) => n + (a.folders || []).length, 0);
if (!s.accounts || !s.accounts.length) return onboarding();
return `
<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">${folders}</div><div class="k">個資料夾在看守</div></div>
<div><div class="big-num">${s.extractedOK || 0}</div><div class="k">份已整理</div></div>
</div>
</div>
<div class="card">
<h3>快速動作</h3>
<div class="acts">
<button id="qAdd">加入資料夾</button>
<button id="qPortal">開啟知識庫網頁</button>
<button id="qDocs">使用說明</button>
</div>
</div>`;
}
function render(s) {
state = s;
$('ver').textContent = s.version ? '版本 ' + s.version : '';
$('statusBig').textContent = s.statusBig;
$('statusBig').classList.toggle('syncing', !!s.syncing);
$('statusSub').textContent = s.statusSub;
const list = $('list');
if (!s.accounts || s.accounts.length === 0) {
// 空狀態=onboarding,不是空白面板
list.innerHTML = `
<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>`;
$('obConnect').onclick = showConnect;
$('obInstall').onclick = () => go.OpenURL('https://install.arcrun.dev/');
return;
}
list.innerHTML = s.accounts.map((a) => `
function pageFolders(s) {
if (!s.accounts || !s.accounts.length) return onboarding();
return (s.accounts).map((a) => `
<div class="acct"><span class="name">${esc(a.name)}</span><span class="host">${esc(a.host)}</span></div>
${(a.folders || []).map((f) => `
<div class="folder">
@@ -64,100 +72,198 @@ function render(s) {
<span class="tag">自動同步中</span>
<button class="ghost" data-rm="${esc(f.path)}" data-acc="${f.accIdx}">移除</button>
</div>`).join('')
|| `<div class="folder"><span class="path dim">還沒有資料夾——按下面的「加入資料夾」</span></div>`}
`).join('');
|| `<div class="folder"><span class="path dim">還沒有資料夾</span></div>`}
`).join('') + `
<div class="acts">
<button class="primary" id="fAdd">加入資料夾</button>
<button id="fAcct">新增知識庫帳號</button>
</div>`;
}
list.querySelectorAll('[data-rm]').forEach((b) => {
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>`;
}
// leo:「檢查更新連到 docs 的檢查更新,不正確,**是直接在這裡進行檢查更新**,
// 應該是跳到一個頁面顯示**現在版本與最新版本**,然後**下載可以進行安裝**」
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;
const el = $('page');
el.innerHTML = page === 'folders' ? pageFolders(state)
: page === 'ai' ? pageAI(state)
: page === 'update' ? pageUpdate(state)
: pageHome(state);
wire();
}
function wire() {
const on = (id, fn) => { const e = $(id); if (e) e.onclick = fn; };
on('qAdd', addFolder); on('fAdd', addFolder);
on('qDocs', () => go.OpenURL('https://rag.arcrun.dev/docs/'));
on('uDocs', () => go.OpenURL('https://rag.arcrun.dev/docs/use/update/'));
on('qPortal', () => {
const a = state.accounts && state.accounts[0];
if (a) go.OpenURL('https://' + a.host.replace('arcrun-cypher-executor.', 'arcrun-rag-ui.') + '/portal/');
});
on('fAcct', 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-rm]').forEach((b) => {
b.onclick = () => confirmRemove(Number(b.dataset.acc), b.dataset.rm);
});
}
function render(s) {
const first = !state;
state = s;
$('ver').textContent = s.version ? s.version : '';
$('statusBig').textContent = s.statusBig;
$('statusBig').classList.toggle('syncing', !!s.syncing);
$('statusSub').textContent = s.statusSub;
// 只有狀態列每秒更新;頁面內容不重繪,避免打斷輸入
if (first) renderPage();
}
async function tick() {
try { render(await go.GetState()); } catch (e) { /* 後端還沒起來,下一輪再試 */ }
try { render(await go.GetState()); } catch (e) { /* 後端還沒起來 */ }
}
// ── 動作 ──
$('btnSync').onclick = async () => { await go.SyncNow(); tick(); };
$('btnAddFolder').onclick = async () => {
async function addFolder() {
if (!state || !state.accounts.length) { showConnect(); return; }
const p = await go.PickFolder(); // 系統原生面板(macOS powerbox 會授權)
const p = await go.PickFolder();
if (!p) return;
await go.AddFolder(0, p);
tick();
};
state = await go.GetState(); 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(); tick(); };
});
<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(); renderPage();
};
});
}
function showConnect() {
openSheet(`
<h2>連上你的知識庫</h2>
<p>貼上你的知識庫網址,再輸入你在網站上設定的帳號密碼。</p>
<label>知識庫網址</label>
<input type="text" id="u" placeholder="https://arcrun-cypher-executor.xxxx.workers.dev"/>
<label>帳號(Email</label>
<input type="text" id="e" placeholder="you@example.com"/>
<label>密碼</label>
<input type="password" id="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 () => {
const err = $('err');
try {
await go.Connect($('u').value.trim(), $('e').value.trim(), $('p').value);
closeSheet(); tick();
} catch (ex) {
err.textContent = String(ex); err.style.display = 'block';
}
};
});
<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(); renderPage();
} catch (ex) { $('err').textContent = String(ex); $('err').style.display = 'block'; }
};
});
}
$('btnAddAccount').onclick = showConnect;
$('btnAI').onclick = () => {
const gem = state && state.engine === 'gemma';
openSheet(`
<h2>用哪個 AI 幫你整理文件?</h2>
<label class="radio"><input type="radio" name="ai" value="cloud" ${gem ? '' : 'checked'}/>
<span><b>雲端 AI</b>(推薦・不必申請任何金鑰)<br/>
<span class="muted">用你自己 Cloudflare 帳號內建的 AI,不需要任何金鑰。</span></span></label>
<label class="radio"><input type="radio" name="ai" value="gemini" ${gem ? 'checked' : ''}/>
<span><b>Google Gemini</b>(需要自己申請金鑰)</span></label>
<label>Gemini 金鑰${state && state.geminiKey ? '(目前已設定,清空即可刪除)' : ''}</label>
<input type="text" id="k" placeholder="貼上你的 Gemini API Key"/>
<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 () => {
const useGemini = document.querySelector('input[name=ai]:checked').value === 'gemini';
const err = $('err');
try {
await go.SetAI(useGemini, $('k').value.trim());
closeSheet(); tick();
} 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'; }
}
$('btnUpdate').onclick = () => go.OpenURL('https://rag.arcrun.dev/docs/use/update/');
// 更新:**在 App 裡完成**leo 指定),不是丟去 docs
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); // 每秒刷新 ⇒ 同步中看得到在動
setInterval(tick, 1000);