t193:daemon UI 換 Wails——CIS 是硬要求,fyne 做不到

leo 2026-08-04 看過 v0.16.0 畫面後:
「功能都有了,但美感非常糟糕⋯⋯跟 CIS 完全無關,每個功能都開一個小小的 popup 視窗,
 非常缺乏整體感,這要理解的是原始的技術選擇是否出錯?」
「我的要求是符合 CIS,在風格上跟 portal 一樣」
「CIS 已經提供規範,你做的連 Logo 都沒放上去,這跟美不美有關係嗎?
 要求放進 CIS 是硬要求,你做為檢查有嗎?沒有怎麼交貨?」

## 我的兩個錯
1. **動工前沒提醒做不到**——decisions-summary.md:314 的 D-daemon-UI
   是我 07-27 自己寫的調研(結論:fyne 全自繪、CSS 套不進去),寫完就沒再看。
   今天動工前該查它卻直接開寫,浪費 leo 的時間與 token。
2. **沒做 CIS 檢查就交貨**——連 logo 都沒放。

## 換 Wails(WebView 殼)
前端就是 HTML/CSS ⇒ 可以直接用 portal 那份色票與 lockup。
- style.css 的色票/字體/紙張紋理**逐字取自** portal 的 :root
  (matrix/arcrun:console-ui/public/portal/index.html),不自創任何顏色
- CIS lockup 官方 PNG,淺色 ink 版/深色 paper 版,切換規則同 portal
  (避開 08-01 作廢的自產 SVG——字腔缺失)
- 對話框改**內嵌覆蓋層**,不再每個功能開一個小 popup
- 原生資料夾選擇器(macOS powerbox 會自動授予該資料夾存取權,
  fyne 自繪 picker 拿不到;未來上 Mac App Store 是硬需求)
- 同步中的圓點會呼吸 ⇒ 看得出來在動(issue #17)
- 無帳號時是 onboarding 引導,不是空白面板

## 連線邏輯逐字沿用,不重寫
connect.go 的 normalizePortalURL/fetchConfigByLogin 取自 arcrun-tray/main.go,
含 t86 個資外洩事故的防線(不同實例=新增帳號,絕不覆蓋舊帳號的資料夾)。
今天已犯過一次「重寫別人修好的東西還修得更差」,不再犯第二次。

## 新增 check-cis.sh(交貨前必跑)
機械檢查六類:官方色票逐個到齊/**不准出現非 CIS 色**/logo 真的放進去/
不可用作廢 SVG/深色模式/字體與紙張紋理同 portal/app icon。
實跑 14 項全過。以後「沒跑過就不准交」。

⚠️ 未驗:實際畫面要 leo 開來看(我開不了視窗)。
This commit is contained in:
2026-08-04 22:19:41 +08:00
parent cc6534dd39
commit bd0cead00e
30 changed files with 2856 additions and 0 deletions
+163
View File
@@ -0,0 +1,163 @@
// Arcrun 桌面 App — 前端(t193
//
// 全部畫面都在**同一個視窗**裡:狀態、清單、設定都用內嵌覆蓋層,
// 不再像 fyne 版那樣「每個功能開一個小小的 popup」(leo 08-04 點破)。
import './style.css';
const go = window.go.main.App;
const $ = (id) => document.getElementById(id);
// 跟隨系統深淺色——CIS 兩套色票都定義好了,這裡只負責切 data-theme
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 視窗)──
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(); });
const esc = (s) => String(s).replace(/[&<>"]/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
// ── 主畫面渲染 ──
let state = null;
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) => `
<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">
<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="folder"><span class="path dim">還沒有資料夾——按下面的「加入資料夾」</span></div>`}
`).join('');
list.querySelectorAll('[data-rm]').forEach((b) => {
b.onclick = () => confirmRemove(Number(b.dataset.acc), b.dataset.rm);
});
}
async function tick() {
try { render(await go.GetState()); } catch (e) { /* 後端還沒起來,下一輪再試 */ }
}
// ── 動作 ──
$('btnSync').onclick = async () => { await go.SyncNow(); tick(); };
$('btnAddFolder').onclick = async () => {
if (!state || !state.accounts.length) { showConnect(); return; }
const p = await go.PickFolder(); // 系統原生面板(macOS powerbox 會授權)
if (!p) return;
await go.AddFolder(0, p);
tick();
};
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(); };
});
}
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="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';
}
};
});
}
$('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'; }
};
});
};
$('btnUpdate').onclick = () => go.OpenURL('https://rag.arcrun.dev/docs/use/update/');
tick();
setInterval(tick, 1000); // 每秒刷新 ⇒ 同步中看得到在動