fix(local-tool): Wails 主視窗改為 splash + redirect 到 Next.js 主 UI
根因:visiona-local/frontend/ 是從 edge-ai-platform 複製過來的 installer wizard HTML/JS/CSS,整組沒清理。main.go 的 //go:embed all:frontend 把這堆 wizard 直接當 Wails 主視窗內容,使用者開啟 app 看到的就是 "Edge AI Platform Installer" 而不是 Next.js 主 UI。macOS dmg 版本也有同樣問題,只是之前驗證時沒開 Wails 視窗而是用瀏覽器直連 localhost:3721 所以沒抓到。 修法:把 visiona-local/frontend/ 重寫為極簡 splash: - index.html:splash 畫面 - app.js:import GetServerStatus / GetServerURL binding,輪詢直到 server ready, window.location.replace(url + '/') 跳到 Next.js 主 UI - style.css:splash 樣式 Next.js 主 UI 不使用任何 Wails JS binding(純 HTTP API),所以從 wails:// 跳到 http://127.0.0.1:<port>/ 後功能完整可用。 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1c5866ed14
commit
570e040a67
@ -1,540 +1,55 @@
|
|||||||
// Edge AI Platform Installer — Wizard Controller v0.2
|
// visionA Local — splash / bootstrap
|
||||||
|
// 職責:等 Go server 起來,拿到 URL 後跳轉到 Next.js 主 UI
|
||||||
|
|
||||||
// ── i18n Dictionary ───────────────────────────────────────
|
import { GetServerStatus, GetServerURL } from './wailsjs/go/main/App.js';
|
||||||
const i18n = {
|
|
||||||
en: {
|
|
||||||
'welcome.title': 'Edge AI Platform Installer',
|
|
||||||
'welcome.subtitle': 'Set up your edge AI development environment with Kneron hardware support.',
|
|
||||||
'path.title': 'Installation Path',
|
|
||||||
'path.subtitle': 'Choose where to install Edge AI Platform.',
|
|
||||||
'path.browse': 'Browse',
|
|
||||||
'path.required': 'Installation path is required.',
|
|
||||||
'path.valid': 'Path is valid.',
|
|
||||||
'components.title': 'Select Components',
|
|
||||||
'components.subtitle': 'Choose which components to install.',
|
|
||||||
'components.server': 'Edge AI Server',
|
|
||||||
'components.serverDesc': 'Core server binary for hardware communication (~10 MB)',
|
|
||||||
'components.models': 'Kneron Models',
|
|
||||||
'components.modelsDesc': 'Pre-trained NEF model files for KL520/KL720 (~50 MB)',
|
|
||||||
'components.python': 'Python Environment',
|
|
||||||
'components.pythonDesc': 'Python venv with Kneron PLUS SDK and dependencies (~200 MB)',
|
|
||||||
'components.libusb': 'libusb',
|
|
||||||
'components.libusbDesc': 'USB library required for Kneron device communication',
|
|
||||||
'components.symlink': 'CLI Symlink',
|
|
||||||
'components.symlinkDesc': "Add 'edge-ai' command to /usr/local/bin",
|
|
||||||
'relay.title': 'Relay Configuration',
|
|
||||||
'relay.subtitle': 'Configure the relay server for remote access. You can skip this and configure later.',
|
|
||||||
'relay.url': 'Relay URL',
|
|
||||||
'relay.token': 'Relay Token',
|
|
||||||
'relay.port': 'Server Port',
|
|
||||||
'relay.hint': 'Leave empty to skip relay configuration. You can set this later in the config file.',
|
|
||||||
'progress.title': 'Installing...',
|
|
||||||
'progress.subtitle': 'Please wait while components are being installed.',
|
|
||||||
'progress.preparing': 'Preparing installation...',
|
|
||||||
'hardware.title': 'Hardware Detection',
|
|
||||||
'hardware.subtitle': 'Connect your Kneron devices and scan for hardware.',
|
|
||||||
'hardware.scanning': 'Scanning for devices...',
|
|
||||||
'hardware.noDevices': 'No Kneron devices found. Connect a device and try again.',
|
|
||||||
'hardware.rescan': 'Rescan',
|
|
||||||
'complete.title': 'Installation Complete',
|
|
||||||
'complete.subtitle': 'Edge AI Platform has been installed successfully.',
|
|
||||||
'complete.location': 'Install Location',
|
|
||||||
'complete.server': 'Edge AI Server',
|
|
||||||
'complete.models': 'Kneron Models',
|
|
||||||
'complete.python': 'Python Environment',
|
|
||||||
'complete.libusb': 'libusb',
|
|
||||||
'complete.installed': 'Installed',
|
|
||||||
'complete.skipped': 'Skipped',
|
|
||||||
'btn.next': 'Next',
|
|
||||||
'btn.back': 'Back',
|
|
||||||
'btn.install': 'Install',
|
|
||||||
'btn.launch': 'Launch Server',
|
|
||||||
'btn.openDashboard': 'Open Dashboard',
|
|
||||||
'btn.close': 'Close',
|
|
||||||
'relay.tokenHint': 'Auto-generated random token. Both the server and browser use this to authenticate with the relay.',
|
|
||||||
'relay.dashboardUrl': 'Dashboard URL',
|
|
||||||
'relay.dashboardHint': 'The HTTP URL to access the dashboard via relay. Opened after server launch.',
|
|
||||||
'existing.detected': 'Existing installation detected',
|
|
||||||
'existing.desc': 'An existing installation was found. You can uninstall it or install over it.',
|
|
||||||
'existing.uninstall': 'Uninstall',
|
|
||||||
'uninstall.title': 'Uninstalling...',
|
|
||||||
'uninstall.subtitle': 'Removing installed files.',
|
|
||||||
'uninstall.confirm': 'This will remove the Edge AI Platform and all installed files. Continue?',
|
|
||||||
'uninstall.complete': 'Uninstall complete. System dependencies (Python, libusb) were preserved.',
|
|
||||||
'system.platform': 'Platform',
|
|
||||||
'system.python': 'Python',
|
|
||||||
'system.libusb': 'libusb',
|
|
||||||
'system.ffmpeg': 'FFmpeg',
|
|
||||||
'status.installed': 'Installed',
|
|
||||||
'status.notFound': 'Not found',
|
|
||||||
'status.notInstalled': 'Not installed',
|
|
||||||
'status.optional': 'Not installed (optional)',
|
|
||||||
},
|
|
||||||
'zh-TW': {
|
|
||||||
'welcome.title': 'Edge AI 平台安裝程式',
|
|
||||||
'welcome.subtitle': '設定您的邊緣 AI 開發環境,支援 Kneron 硬體。',
|
|
||||||
'path.title': '安裝路徑',
|
|
||||||
'path.subtitle': '選擇 Edge AI 平台的安裝位置。',
|
|
||||||
'path.browse': '瀏覽',
|
|
||||||
'path.required': '安裝路徑為必填。',
|
|
||||||
'path.valid': '路徑有效。',
|
|
||||||
'components.title': '選擇元件',
|
|
||||||
'components.subtitle': '選擇要安裝的元件。',
|
|
||||||
'components.server': 'Edge AI 伺服器',
|
|
||||||
'components.serverDesc': '硬體通訊核心伺服器程式 (~10 MB)',
|
|
||||||
'components.models': 'Kneron 模型',
|
|
||||||
'components.modelsDesc': 'KL520/KL720 預訓練 NEF 模型檔案 (~50 MB)',
|
|
||||||
'components.python': 'Python 環境',
|
|
||||||
'components.pythonDesc': '包含 Kneron PLUS SDK 的 Python 虛擬環境 (~200 MB)',
|
|
||||||
'components.libusb': 'libusb',
|
|
||||||
'components.libusbDesc': 'Kneron 裝置通訊所需的 USB 函式庫',
|
|
||||||
'components.symlink': 'CLI 捷徑',
|
|
||||||
'components.symlinkDesc': "新增 'edge-ai' 指令到 /usr/local/bin",
|
|
||||||
'relay.title': 'Relay 設定',
|
|
||||||
'relay.subtitle': '設定 Relay 伺服器以進行遠端存取。可以跳過稍後再設定。',
|
|
||||||
'relay.url': 'Relay URL',
|
|
||||||
'relay.token': 'Relay Token',
|
|
||||||
'relay.port': '伺服器連接埠',
|
|
||||||
'relay.hint': '留空可跳過 Relay 設定,稍後可在設定檔中修改。',
|
|
||||||
'progress.title': '安裝中...',
|
|
||||||
'progress.subtitle': '正在安裝元件,請稍候。',
|
|
||||||
'progress.preparing': '準備安裝中...',
|
|
||||||
'hardware.title': '硬體偵測',
|
|
||||||
'hardware.subtitle': '連接您的 Kneron 裝置並掃描硬體。',
|
|
||||||
'hardware.scanning': '正在掃描裝置...',
|
|
||||||
'hardware.noDevices': '未偵測到 Kneron 裝置。請連接裝置後再試。',
|
|
||||||
'hardware.rescan': '重新掃描',
|
|
||||||
'complete.title': '安裝完成',
|
|
||||||
'complete.subtitle': 'Edge AI 平台已成功安裝。',
|
|
||||||
'complete.location': '安裝位置',
|
|
||||||
'complete.server': 'Edge AI 伺服器',
|
|
||||||
'complete.models': 'Kneron 模型',
|
|
||||||
'complete.python': 'Python 環境',
|
|
||||||
'complete.libusb': 'libusb',
|
|
||||||
'complete.installed': '已安裝',
|
|
||||||
'complete.skipped': '已跳過',
|
|
||||||
'btn.next': '下一步',
|
|
||||||
'btn.back': '上一步',
|
|
||||||
'btn.install': '安裝',
|
|
||||||
'btn.launch': '啟動伺服器',
|
|
||||||
'btn.openDashboard': '開啟控制台',
|
|
||||||
'btn.close': '關閉',
|
|
||||||
'relay.tokenHint': '自動產生的隨機 Token。伺服器和瀏覽器都透過此 Token 向 Relay 驗證身份。',
|
|
||||||
'relay.dashboardUrl': 'Dashboard URL',
|
|
||||||
'relay.dashboardHint': '透過 Relay 存取 Dashboard 的 HTTP URL。啟動伺服器後會自動開啟。',
|
|
||||||
'existing.detected': '偵測到既有安裝',
|
|
||||||
'existing.desc': '發現既有安裝。您可以解除安裝或覆蓋安裝。',
|
|
||||||
'existing.uninstall': '解除安裝',
|
|
||||||
'uninstall.title': '解除安裝中...',
|
|
||||||
'uninstall.subtitle': '正在移除已安裝的檔案。',
|
|
||||||
'uninstall.confirm': '這將移除 Edge AI 平台及所有已安裝的檔案。是否繼續?',
|
|
||||||
'uninstall.complete': '解除安裝完成。系統相依套件(Python、libusb)已保留。',
|
|
||||||
'system.platform': '平台',
|
|
||||||
'system.python': 'Python',
|
|
||||||
'system.libusb': 'libusb',
|
|
||||||
'system.ffmpeg': 'FFmpeg',
|
|
||||||
'status.installed': '已安裝',
|
|
||||||
'status.notFound': '未找到',
|
|
||||||
'status.notInstalled': '未安裝',
|
|
||||||
'status.optional': '未安裝(選用)',
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── State ─────────────────────────────────────────────────
|
const statusEl = document.getElementById('status');
|
||||||
let currentStep = 0;
|
const errorEl = document.getElementById('error');
|
||||||
let systemInfo = null;
|
|
||||||
let installConfig = {
|
|
||||||
installDir: '',
|
|
||||||
createSymlink: true,
|
|
||||||
installPythonEnv: true,
|
|
||||||
installLibusb: true,
|
|
||||||
relayURL: '',
|
|
||||||
relayToken: '',
|
|
||||||
dashboardURL: '',
|
|
||||||
serverPort: 3721,
|
|
||||||
language: 'en',
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── i18n Functions ────────────────────────────────────────
|
const POLL_INTERVAL_MS = 500;
|
||||||
|
const MAX_WAIT_MS = 60_000;
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
function t(key) {
|
async function poll() {
|
||||||
const dict = i18n[installConfig.language] || i18n.en;
|
if (Date.now() - startTime > MAX_WAIT_MS) {
|
||||||
return dict[key] || i18n.en[key] || key;
|
showError('伺服器啟動逾時(60 秒),請檢查 log 或重新啟動應用程式。');
|
||||||
}
|
|
||||||
|
|
||||||
function setLanguage(lang) {
|
|
||||||
installConfig.language = lang;
|
|
||||||
|
|
||||||
// Update active button state
|
|
||||||
document.getElementById('lang-en').classList.toggle('active', lang === 'en');
|
|
||||||
document.getElementById('lang-zh').classList.toggle('active', lang === 'zh-TW');
|
|
||||||
|
|
||||||
// Update all elements with data-i18n attribute
|
|
||||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
|
||||||
const key = el.getAttribute('data-i18n');
|
|
||||||
const text = t(key);
|
|
||||||
if (text) {
|
|
||||||
el.textContent = text;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update html lang attribute
|
|
||||||
document.documentElement.lang = lang === 'zh-TW' ? 'zh-TW' : 'en';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Step Navigation ───────────────────────────────────────
|
|
||||||
|
|
||||||
function showStep(n) {
|
|
||||||
document.querySelectorAll('.step').forEach(s => s.classList.remove('active'));
|
|
||||||
document.querySelectorAll('.step-dot').forEach((d, i) => {
|
|
||||||
d.classList.remove('active', 'completed');
|
|
||||||
if (i < n) d.classList.add('completed');
|
|
||||||
if (i === n) d.classList.add('active');
|
|
||||||
});
|
|
||||||
const step = document.getElementById('step-' + n);
|
|
||||||
if (step) step.classList.add('active');
|
|
||||||
currentStep = n;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Step 0: Welcome ───────────────────────────────────────
|
|
||||||
|
|
||||||
async function initWelcome() {
|
|
||||||
try {
|
|
||||||
systemInfo = await window.go.main.Installer.GetSystemInfo();
|
|
||||||
|
|
||||||
document.getElementById('info-platform').textContent =
|
|
||||||
systemInfo.os + ' / ' + systemInfo.arch;
|
|
||||||
|
|
||||||
const pyEl = document.getElementById('info-python');
|
|
||||||
if (systemInfo.pythonAvailable) {
|
|
||||||
pyEl.textContent = systemInfo.pythonVersion;
|
|
||||||
pyEl.className = 'info-value status-ok';
|
|
||||||
} else {
|
|
||||||
pyEl.textContent = t('status.notFound');
|
|
||||||
pyEl.className = 'info-value status-warn';
|
|
||||||
}
|
|
||||||
|
|
||||||
const luEl = document.getElementById('info-libusb');
|
|
||||||
luEl.textContent = systemInfo.libusbInstalled ? t('status.installed') : t('status.notInstalled');
|
|
||||||
luEl.className = 'info-value ' + (systemInfo.libusbInstalled ? 'status-ok' : 'status-warn');
|
|
||||||
|
|
||||||
const ffEl = document.getElementById('info-ffmpeg');
|
|
||||||
ffEl.textContent = systemInfo.ffmpegAvailable ? t('status.installed') : t('status.optional');
|
|
||||||
ffEl.className = 'info-value ' + (systemInfo.ffmpegAvailable ? 'status-ok' : '');
|
|
||||||
|
|
||||||
installConfig.installDir = systemInfo.defaultDir;
|
|
||||||
|
|
||||||
if (systemInfo.existingInstall) {
|
|
||||||
document.getElementById('existing-install').style.display = 'block';
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('GetSystemInfo failed:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Step 1: Path ──────────────────────────────────────────
|
|
||||||
|
|
||||||
document.getElementById('btn-browse').addEventListener('click', async () => {
|
|
||||||
try {
|
|
||||||
const dir = await window.go.main.Installer.BrowseDirectory();
|
|
||||||
if (dir) {
|
|
||||||
document.getElementById('install-path').value = dir;
|
|
||||||
installConfig.installDir = dir;
|
|
||||||
const msg = await window.go.main.Installer.ValidatePath(dir);
|
|
||||||
const statusEl = document.getElementById('path-status');
|
|
||||||
if (msg) {
|
|
||||||
statusEl.textContent = msg;
|
|
||||||
statusEl.className = 'status-text error';
|
|
||||||
} else {
|
|
||||||
statusEl.textContent = t('path.valid');
|
|
||||||
statusEl.className = 'status-text';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('BrowseDirectory failed:', err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Step 4: Install Progress ──────────────────────────────
|
|
||||||
|
|
||||||
function addLogLine(message, type) {
|
|
||||||
const log = document.getElementById('progress-log');
|
|
||||||
const line = document.createElement('div');
|
|
||||||
line.className = 'log-' + (type || 'line');
|
|
||||||
line.textContent = message;
|
|
||||||
log.appendChild(line);
|
|
||||||
log.scrollTop = log.scrollHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startInstall() {
|
|
||||||
showStep(4);
|
|
||||||
document.getElementById('progress-title').textContent = t('progress.title');
|
|
||||||
document.getElementById('progress-subtitle').textContent = t('progress.subtitle');
|
|
||||||
document.getElementById('progress-log').innerHTML = '';
|
|
||||||
document.getElementById('progress-fill').style.width = '0%';
|
|
||||||
document.getElementById('progress-percent').textContent = '0%';
|
|
||||||
document.getElementById('progress-message').textContent = t('progress.preparing');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await window.go.main.Installer.StartInstall(installConfig);
|
|
||||||
} catch (err) {
|
|
||||||
addLogLine('Error: ' + err, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.runtime && window.runtime.EventsOn) {
|
|
||||||
window.runtime.EventsOn('install:progress', (event) => {
|
|
||||||
const fill = document.getElementById('progress-fill');
|
|
||||||
const percent = document.getElementById('progress-percent');
|
|
||||||
const message = document.getElementById('progress-message');
|
|
||||||
|
|
||||||
fill.style.width = event.percent + '%';
|
|
||||||
percent.textContent = Math.round(event.percent) + '%';
|
|
||||||
message.textContent = event.message;
|
|
||||||
|
|
||||||
addLogLine(event.message, event.isError ? 'error' : (event.isComplete ? 'success' : 'line'));
|
|
||||||
|
|
||||||
if (event.isComplete && !event.isError) {
|
|
||||||
setTimeout(() => {
|
|
||||||
showStep(5);
|
|
||||||
detectHardware();
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
window.runtime.EventsOn('uninstall:progress', (event) => {
|
|
||||||
const message = document.getElementById('progress-message');
|
|
||||||
const fill = document.getElementById('progress-fill');
|
|
||||||
const percent = document.getElementById('progress-percent');
|
|
||||||
|
|
||||||
fill.style.width = event.percent + '%';
|
|
||||||
percent.textContent = Math.round(event.percent) + '%';
|
|
||||||
message.textContent = event.message;
|
|
||||||
|
|
||||||
addLogLine(event.message, event.isError ? 'error' : 'line');
|
|
||||||
|
|
||||||
if (event.isComplete) {
|
|
||||||
document.getElementById('progress-title').textContent = t('uninstall.title').replace('...', '');
|
|
||||||
document.getElementById('progress-subtitle').textContent = t('uninstall.complete');
|
|
||||||
addLogLine(t('uninstall.complete'), 'success');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Step 5: Hardware Detection ────────────────────────────
|
|
||||||
|
|
||||||
async function detectHardware() {
|
|
||||||
const el = document.getElementById('hardware-results');
|
|
||||||
el.innerHTML =
|
|
||||||
'<div class="device-scanning">' +
|
|
||||||
'<div class="spinner"></div>' +
|
|
||||||
'<p>' + t('hardware.scanning') + '</p>' +
|
|
||||||
'</div>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const devices = await window.go.main.Installer.DetectHardware();
|
|
||||||
if (!devices || devices.length === 0) {
|
|
||||||
el.innerHTML = '<div class="no-devices"><p>' + t('hardware.noDevices') + '</p></div>';
|
|
||||||
} else {
|
|
||||||
el.innerHTML = devices.map(d =>
|
|
||||||
'<div class="device-card">' +
|
|
||||||
'<div class="device-icon">⬡</div>' +
|
|
||||||
'<div class="device-info">' +
|
|
||||||
'<span class="device-name">Kneron ' + (d.model || 'Unknown') + '</span>' +
|
|
||||||
'<span class="device-detail">' + (d.product || d.port || '') + '</span>' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>'
|
|
||||||
).join('');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
el.innerHTML = '<div class="no-devices"><p>Detection skipped: ' + err + '</p></div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('btn-rescan').addEventListener('click', () => {
|
|
||||||
detectHardware();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Step 6: Complete ──────────────────────────────────────
|
|
||||||
|
|
||||||
document.getElementById('btn-launch').addEventListener('click', async () => {
|
|
||||||
try {
|
|
||||||
await window.go.main.Installer.LaunchServer();
|
|
||||||
// After launching, if dashboard URL is configured, open it automatically
|
|
||||||
const dashUrl = installConfig.dashboardURL || await window.go.main.Installer.GetDashboardURL();
|
|
||||||
if (dashUrl) {
|
|
||||||
// Give the server a moment to start, then open dashboard
|
|
||||||
setTimeout(async () => {
|
|
||||||
// Append relay token as query param if available
|
|
||||||
let url = dashUrl;
|
|
||||||
if (installConfig.relayToken) {
|
|
||||||
const sep = url.includes('?') ? '&' : '?';
|
|
||||||
url = url + sep + 'token=' + encodeURIComponent(installConfig.relayToken);
|
|
||||||
}
|
|
||||||
await window.go.main.Installer.OpenBrowser(url);
|
|
||||||
}, 2000);
|
|
||||||
} else {
|
|
||||||
// No relay — open local
|
|
||||||
setTimeout(async () => {
|
|
||||||
const port = installConfig.serverPort || 3721;
|
|
||||||
await window.go.main.Installer.OpenBrowser('http://127.0.0.1:' + port);
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
alert('Failed to launch: ' + err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('btn-open-dashboard').addEventListener('click', async () => {
|
|
||||||
const dashUrl = installConfig.dashboardURL || await window.go.main.Installer.GetDashboardURL();
|
|
||||||
if (dashUrl) {
|
|
||||||
let url = dashUrl;
|
|
||||||
if (installConfig.relayToken) {
|
|
||||||
const sep = url.includes('?') ? '&' : '?';
|
|
||||||
url = url + sep + 'token=' + encodeURIComponent(installConfig.relayToken);
|
|
||||||
}
|
|
||||||
await window.go.main.Installer.OpenBrowser(url);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('btn-close').addEventListener('click', () => {
|
|
||||||
if (window.runtime && window.runtime.Quit) {
|
|
||||||
window.runtime.Quit();
|
|
||||||
} else {
|
|
||||||
window.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Uninstall ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
document.getElementById('btn-uninstall').addEventListener('click', async () => {
|
|
||||||
try {
|
|
||||||
const confirmed = await window.go.main.Installer.ConfirmUninstall();
|
|
||||||
if (!confirmed) return;
|
|
||||||
} catch (err) {
|
|
||||||
// Fallback to JS confirm if native dialog fails
|
|
||||||
if (!confirm(t('uninstall.confirm'))) return;
|
|
||||||
}
|
|
||||||
showStep(4);
|
|
||||||
document.getElementById('progress-title').textContent = t('uninstall.title');
|
|
||||||
document.getElementById('progress-subtitle').textContent = t('uninstall.subtitle');
|
|
||||||
document.getElementById('progress-log').innerHTML = '';
|
|
||||||
document.getElementById('progress-fill').style.width = '0%';
|
|
||||||
document.getElementById('progress-percent').textContent = '0%';
|
|
||||||
|
|
||||||
try {
|
|
||||||
await window.go.main.Installer.Uninstall();
|
|
||||||
} catch (err) {
|
|
||||||
addLogLine('Error: ' + err, 'error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Navigation Wiring ─────────────────────────────────────
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
// Detect initial language from browser
|
|
||||||
const browserLang = navigator.language || navigator.userLanguage || 'en';
|
|
||||||
const initialLang = browserLang.startsWith('zh') ? 'zh-TW' : 'en';
|
|
||||||
setLanguage(initialLang);
|
|
||||||
|
|
||||||
initWelcome();
|
|
||||||
|
|
||||||
// Language switcher
|
|
||||||
document.getElementById('lang-en').addEventListener('click', () => setLanguage('en'));
|
|
||||||
document.getElementById('lang-zh').addEventListener('click', () => setLanguage('zh-TW'));
|
|
||||||
|
|
||||||
// Step 0 -> Step 1
|
|
||||||
document.getElementById('btn-next-0').addEventListener('click', () => {
|
|
||||||
showStep(1);
|
|
||||||
document.getElementById('install-path').value = installConfig.installDir;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Step 1 Back -> Step 0
|
|
||||||
document.getElementById('btn-back-1').addEventListener('click', () => {
|
|
||||||
showStep(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Step 1 -> Step 2
|
|
||||||
document.getElementById('btn-next-1').addEventListener('click', async () => {
|
|
||||||
const msg = await window.go.main.Installer.ValidatePath(installConfig.installDir);
|
|
||||||
if (msg) {
|
|
||||||
const statusEl = document.getElementById('path-status');
|
|
||||||
statusEl.textContent = msg;
|
|
||||||
statusEl.className = 'status-text error';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
showStep(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Step 2 Back -> Step 1
|
|
||||||
document.getElementById('btn-back-2').addEventListener('click', () => {
|
|
||||||
showStep(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Step 2 Install -> Step 3 (Relay Config)
|
|
||||||
document.getElementById('btn-install').addEventListener('click', async () => {
|
|
||||||
installConfig.createSymlink = document.getElementById('comp-symlink').checked;
|
|
||||||
installConfig.installPythonEnv = document.getElementById('comp-python').checked;
|
|
||||||
installConfig.installLibusb = document.getElementById('comp-libusb').checked;
|
|
||||||
showStep(3);
|
|
||||||
|
|
||||||
// Auto-generate relay token if empty
|
|
||||||
const tokenInput = document.getElementById('relay-token');
|
|
||||||
if (!tokenInput.value.trim()) {
|
|
||||||
try {
|
try {
|
||||||
const token = await window.go.main.Installer.GenerateToken();
|
const status = await GetServerStatus();
|
||||||
tokenInput.value = token;
|
if (status && status.lastError) {
|
||||||
} catch (err) {
|
showError('伺服器啟動失敗:' + status.lastError);
|
||||||
console.error('GenerateToken failed:', err);
|
return;
|
||||||
}
|
}
|
||||||
|
if (status && status.running && status.url) {
|
||||||
|
statusEl.textContent = '啟動完成,載入主介面...';
|
||||||
|
window.location.replace(status.url + '/');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Step 3 Back -> Step 2
|
const url = await GetServerURL();
|
||||||
document.getElementById('btn-back-3').addEventListener('click', () => {
|
if (url) {
|
||||||
showStep(2);
|
statusEl.textContent = '啟動完成,載入主介面...';
|
||||||
});
|
window.location.replace(url + '/');
|
||||||
|
return;
|
||||||
// Regenerate token button
|
|
||||||
document.getElementById('btn-regen-token').addEventListener('click', async () => {
|
|
||||||
try {
|
|
||||||
const token = await window.go.main.Installer.GenerateToken();
|
|
||||||
document.getElementById('relay-token').value = token;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('GenerateToken failed:', err);
|
|
||||||
}
|
}
|
||||||
});
|
} catch (e) {
|
||||||
|
// binding 尚未就緒時會 throw,忽略繼續輪詢
|
||||||
// Step 3 Next -> collect relay fields -> Step 4 (Progress) -> start install
|
}
|
||||||
document.getElementById('btn-next-3').addEventListener('click', () => {
|
|
||||||
installConfig.relayURL = document.getElementById('relay-url').value.trim();
|
setTimeout(poll, POLL_INTERVAL_MS);
|
||||||
installConfig.relayToken = document.getElementById('relay-token').value.trim();
|
}
|
||||||
installConfig.dashboardURL = document.getElementById('dashboard-url').value.trim();
|
|
||||||
const portVal = parseInt(document.getElementById('server-port').value, 10);
|
function showError(msg) {
|
||||||
installConfig.serverPort = (portVal >= 1024 && portVal <= 65535) ? portVal : 3721;
|
statusEl.hidden = true;
|
||||||
startInstall();
|
errorEl.textContent = msg;
|
||||||
});
|
errorEl.hidden = false;
|
||||||
|
}
|
||||||
// Step 5 Next -> Step 6 (Complete)
|
|
||||||
document.getElementById('btn-next-5').addEventListener('click', () => {
|
// 等 Wails runtime 就緒再開始輪詢
|
||||||
showStep(6);
|
if (window.runtime) {
|
||||||
document.getElementById('summary-path').textContent = installConfig.installDir;
|
poll();
|
||||||
|
} else {
|
||||||
const modelsEl = document.getElementById('summary-models');
|
window.addEventListener('load', () => setTimeout(poll, 200));
|
||||||
modelsEl.textContent = t('complete.installed');
|
|
||||||
modelsEl.className = 'info-value status-ok';
|
|
||||||
|
|
||||||
const pyEl = document.getElementById('summary-python');
|
|
||||||
pyEl.textContent = installConfig.installPythonEnv ? t('complete.installed') : t('complete.skipped');
|
|
||||||
pyEl.className = 'info-value ' + (installConfig.installPythonEnv ? 'status-ok' : 'status-skipped');
|
|
||||||
|
|
||||||
const luEl = document.getElementById('summary-libusb');
|
|
||||||
luEl.textContent = installConfig.installLibusb ? t('complete.installed') : t('complete.skipped');
|
|
||||||
luEl.className = 'info-value ' + (installConfig.installLibusb ? 'status-ok' : 'status-skipped');
|
|
||||||
|
|
||||||
// Show "Open Dashboard" button if relay dashboard URL is configured
|
|
||||||
if (installConfig.dashboardURL) {
|
|
||||||
document.getElementById('btn-open-dashboard').style.display = 'inline-flex';
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@ -3,256 +3,18 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Edge AI Platform Installer</title>
|
<title>visionA Local</title>
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<!-- Header -->
|
<div class="splash">
|
||||||
<header id="wizard-header">
|
<div class="logo">visionA Local</div>
|
||||||
<div class="header-left">
|
|
||||||
<div class="logo-text" data-i18n="welcome.title">Edge AI Platform</div>
|
|
||||||
<div class="version-text">Installer v0.2.0</div>
|
|
||||||
</div>
|
|
||||||
<div class="header-center">
|
|
||||||
<div class="step-indicators">
|
|
||||||
<span class="step-dot active" data-step="0">1</span>
|
|
||||||
<span class="step-line"></span>
|
|
||||||
<span class="step-dot" data-step="1">2</span>
|
|
||||||
<span class="step-line"></span>
|
|
||||||
<span class="step-dot" data-step="2">3</span>
|
|
||||||
<span class="step-line"></span>
|
|
||||||
<span class="step-dot" data-step="3">4</span>
|
|
||||||
<span class="step-line"></span>
|
|
||||||
<span class="step-dot" data-step="4">5</span>
|
|
||||||
<span class="step-line"></span>
|
|
||||||
<span class="step-dot" data-step="5">6</span>
|
|
||||||
<span class="step-line"></span>
|
|
||||||
<span class="step-dot" data-step="6">7</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="header-right">
|
|
||||||
<div class="lang-switch">
|
|
||||||
<button class="lang-btn active" id="lang-en">EN</button>
|
|
||||||
<span class="lang-sep">|</span>
|
|
||||||
<button class="lang-btn" id="lang-zh">中文</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<!-- Step 0: Welcome -->
|
|
||||||
<section id="step-0" class="step active">
|
|
||||||
<h1 data-i18n="welcome.title">Edge AI Platform Installer</h1>
|
|
||||||
<p class="subtitle" data-i18n="welcome.subtitle">Set up your edge AI development environment with Kneron hardware support.</p>
|
|
||||||
|
|
||||||
<div id="system-info" class="info-card">
|
|
||||||
<div class="info-row"><span class="info-label" data-i18n="system.platform">Platform</span><span id="info-platform" class="info-value">-</span></div>
|
|
||||||
<div class="info-row"><span class="info-label" data-i18n="system.python">Python</span><span id="info-python" class="info-value">-</span></div>
|
|
||||||
<div class="info-row"><span class="info-label" data-i18n="system.libusb">libusb</span><span id="info-libusb" class="info-value">-</span></div>
|
|
||||||
<div class="info-row"><span class="info-label" data-i18n="system.ffmpeg">FFmpeg</span><span id="info-ffmpeg" class="info-value">-</span></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="existing-install" class="warning-card" style="display:none">
|
|
||||||
<strong data-i18n="existing.detected">Existing installation detected</strong>
|
|
||||||
<p data-i18n="existing.desc">An existing installation was found. You can uninstall it or install over it.</p>
|
|
||||||
<p id="existing-path" class="existing-path"></p>
|
|
||||||
<button id="btn-uninstall" class="btn btn-danger" data-i18n="existing.uninstall">Uninstall</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="actions">
|
|
||||||
<button id="btn-next-0" class="btn btn-primary" data-i18n="btn.next">Next</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Step 1: Install Path -->
|
|
||||||
<section id="step-1" class="step">
|
|
||||||
<h1 data-i18n="path.title">Installation Path</h1>
|
|
||||||
<p class="subtitle" data-i18n="path.subtitle">Choose where to install Edge AI Platform.</p>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="path-input-group">
|
|
||||||
<input type="text" id="install-path" class="input-field" readonly>
|
|
||||||
<button id="btn-browse" class="btn btn-secondary" data-i18n="path.browse">Browse</button>
|
|
||||||
</div>
|
|
||||||
<p id="path-status" class="status-text"></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="actions">
|
|
||||||
<button id="btn-back-1" class="btn btn-ghost" data-i18n="btn.back">Back</button>
|
|
||||||
<button id="btn-next-1" class="btn btn-primary" data-i18n="btn.next">Next</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Step 2: Components -->
|
|
||||||
<section id="step-2" class="step">
|
|
||||||
<h1 data-i18n="components.title">Select Components</h1>
|
|
||||||
<p class="subtitle" data-i18n="components.subtitle">Choose which components to install.</p>
|
|
||||||
|
|
||||||
<div class="component-list">
|
|
||||||
<label class="component-item required">
|
|
||||||
<div class="component-check">
|
|
||||||
<input type="checkbox" id="comp-server" checked disabled>
|
|
||||||
<span class="checkmark"></span>
|
|
||||||
</div>
|
|
||||||
<div class="component-info">
|
|
||||||
<span class="component-name" data-i18n="components.server">Edge AI Server</span>
|
|
||||||
<span class="component-desc" data-i18n="components.serverDesc">Core server binary for hardware communication (~10 MB)</span>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<label class="component-item">
|
|
||||||
<div class="component-check">
|
|
||||||
<input type="checkbox" id="comp-models" checked>
|
|
||||||
<span class="checkmark"></span>
|
|
||||||
</div>
|
|
||||||
<div class="component-info">
|
|
||||||
<span class="component-name" data-i18n="components.models">Kneron Models</span>
|
|
||||||
<span class="component-desc" data-i18n="components.modelsDesc">Pre-trained NEF model files for KL520/KL720 (~50 MB)</span>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<label class="component-item">
|
|
||||||
<div class="component-check">
|
|
||||||
<input type="checkbox" id="comp-python" checked>
|
|
||||||
<span class="checkmark"></span>
|
|
||||||
</div>
|
|
||||||
<div class="component-info">
|
|
||||||
<span class="component-name" data-i18n="components.python">Python Environment</span>
|
|
||||||
<span class="component-desc" data-i18n="components.pythonDesc">Python venv with Kneron PLUS SDK and dependencies (~200 MB)</span>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<label class="component-item">
|
|
||||||
<div class="component-check">
|
|
||||||
<input type="checkbox" id="comp-libusb" checked>
|
|
||||||
<span class="checkmark"></span>
|
|
||||||
</div>
|
|
||||||
<div class="component-info">
|
|
||||||
<span class="component-name" data-i18n="components.libusb">libusb</span>
|
|
||||||
<span class="component-desc" data-i18n="components.libusbDesc">USB library required for Kneron device communication</span>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<label class="component-item" id="comp-symlink-row">
|
|
||||||
<div class="component-check">
|
|
||||||
<input type="checkbox" id="comp-symlink" checked>
|
|
||||||
<span class="checkmark"></span>
|
|
||||||
</div>
|
|
||||||
<div class="component-info">
|
|
||||||
<span class="component-name" data-i18n="components.symlink">CLI Symlink</span>
|
|
||||||
<span class="component-desc" data-i18n="components.symlinkDesc">Add 'edge-ai' command to /usr/local/bin</span>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="actions">
|
|
||||||
<button id="btn-back-2" class="btn btn-ghost" data-i18n="btn.back">Back</button>
|
|
||||||
<button id="btn-install" class="btn btn-primary" data-i18n="btn.install">Install</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Step 3: Relay Configuration -->
|
|
||||||
<section id="step-3" class="step">
|
|
||||||
<h1 data-i18n="relay.title">Relay Configuration</h1>
|
|
||||||
<p class="subtitle" data-i18n="relay.subtitle">Configure the relay server for remote access. You can skip this and configure later.</p>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="field-label" data-i18n="relay.url">Relay URL</label>
|
|
||||||
<input type="text" class="input-field" id="relay-url" value="ws://ec2-13-192-170-7.ap-northeast-1.compute.amazonaws.com/tunnel/connect">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="field-label" data-i18n="relay.token">Relay Token</label>
|
|
||||||
<div class="path-input-group">
|
|
||||||
<input type="text" class="input-field" id="relay-token" placeholder="auto-generated" readonly>
|
|
||||||
<button id="btn-regen-token" class="btn btn-secondary" title="Regenerate">↻</button>
|
|
||||||
</div>
|
|
||||||
<p class="field-hint" data-i18n="relay.tokenHint">Auto-generated random token. Both the server and browser use this to authenticate with the relay.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="field-label" data-i18n="relay.dashboardUrl">Dashboard URL</label>
|
|
||||||
<input type="text" class="input-field" id="dashboard-url" value="http://ec2-13-192-170-7.ap-northeast-1.compute.amazonaws.com">
|
|
||||||
<p class="field-hint" data-i18n="relay.dashboardHint">The HTTP URL to access the dashboard via relay. Opened after server launch.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="field-label" data-i18n="relay.port">Server Port</label>
|
|
||||||
<input type="number" class="input-field" id="server-port" value="3721" min="1024" max="65535">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="field-hint" data-i18n="relay.hint">Leave empty to skip relay configuration. You can set this later in the config file.</p>
|
|
||||||
|
|
||||||
<div class="actions">
|
|
||||||
<button id="btn-back-3" class="btn btn-ghost" data-i18n="btn.back">Back</button>
|
|
||||||
<button id="btn-next-3" class="btn btn-primary" data-i18n="btn.next">Next</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Step 4: Progress -->
|
|
||||||
<section id="step-4" class="step">
|
|
||||||
<h1 id="progress-title" data-i18n="progress.title">Installing...</h1>
|
|
||||||
<p class="subtitle" id="progress-subtitle" data-i18n="progress.subtitle">Please wait while components are being installed.</p>
|
|
||||||
|
|
||||||
<div class="progress-container">
|
|
||||||
<div class="progress-bar">
|
|
||||||
<div id="progress-fill" class="progress-fill" style="width:0%"></div>
|
|
||||||
</div>
|
|
||||||
<span id="progress-percent" class="progress-percent">0%</span>
|
|
||||||
</div>
|
|
||||||
<p id="progress-message" class="progress-message" data-i18n="progress.preparing">Preparing installation...</p>
|
|
||||||
|
|
||||||
<div id="progress-log" class="log-area"></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Step 5: Hardware Detection -->
|
|
||||||
<section id="step-5" class="step">
|
|
||||||
<h1 data-i18n="hardware.title">Hardware Detection</h1>
|
|
||||||
<p class="subtitle" data-i18n="hardware.subtitle">Connect your Kneron devices and scan for hardware.</p>
|
|
||||||
|
|
||||||
<div id="hardware-results" class="hardware-list">
|
|
||||||
<div class="device-scanning" id="device-scanning">
|
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
<p data-i18n="hardware.scanning">Scanning for devices...</p>
|
<div class="status" id="status">正在啟動伺服器...</div>
|
||||||
</div>
|
<div class="error" id="error" hidden></div>
|
||||||
<div class="no-devices" id="no-devices" style="display:none;">
|
|
||||||
<p data-i18n="hardware.noDevices">No Kneron devices found. Connect a device and try again.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script type="module" src="app.js"></script>
|
||||||
<div class="actions">
|
|
||||||
<button id="btn-rescan" class="btn btn-secondary" data-i18n="hardware.rescan">Rescan</button>
|
|
||||||
<button id="btn-next-5" class="btn btn-primary" data-i18n="btn.next">Next</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Step 6: Complete -->
|
|
||||||
<section id="step-6" class="step">
|
|
||||||
<div class="complete-icon">
|
|
||||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
|
||||||
<polyline points="22 4 12 14.01 9 11.01"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h1 data-i18n="complete.title">Installation Complete</h1>
|
|
||||||
<p class="subtitle" data-i18n="complete.subtitle">Edge AI Platform has been installed successfully.</p>
|
|
||||||
|
|
||||||
<div id="install-summary" class="info-card">
|
|
||||||
<div class="info-row"><span class="info-label" data-i18n="complete.location">Install Location</span><span id="summary-path" class="info-value">-</span></div>
|
|
||||||
<div class="info-row"><span class="info-label" data-i18n="complete.server">Edge AI Server</span><span id="summary-server" class="info-value status-ok" data-i18n="complete.installed">Installed</span></div>
|
|
||||||
<div class="info-row"><span class="info-label" data-i18n="complete.models">Kneron Models</span><span id="summary-models" class="info-value">-</span></div>
|
|
||||||
<div class="info-row"><span class="info-label" data-i18n="complete.python">Python Environment</span><span id="summary-python" class="info-value">-</span></div>
|
|
||||||
<div class="info-row"><span class="info-label" data-i18n="complete.libusb">libusb</span><span id="summary-libusb" class="info-value">-</span></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="actions">
|
|
||||||
<button id="btn-launch" class="btn btn-primary" data-i18n="btn.launch">Launch Server</button>
|
|
||||||
<button id="btn-open-dashboard" class="btn btn-secondary" style="display:none" data-i18n="btn.openDashboard">Open Dashboard</button>
|
|
||||||
<button id="btn-close" class="btn btn-ghost" data-i18n="btn.close">Close</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="wailsjs/runtime/runtime.js"></script>
|
|
||||||
<script src="wailsjs/go/main/App.js"></script>
|
|
||||||
<script src="app.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -1,508 +1,62 @@
|
|||||||
/* Edge AI Platform Installer — Modernized v0.2 */
|
/* visionA Local — splash screen */
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
:root {
|
html, body {
|
||||||
--primary: #6366f1;
|
height: 100%;
|
||||||
--primary-hover: #4f46e5;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft JhengHei', sans-serif;
|
||||||
--primary-light: #e0e7ff;
|
background: #0b1020;
|
||||||
--bg: #fafbff;
|
color: #e6e8f0;
|
||||||
--surface: #ffffff;
|
-webkit-font-smoothing: antialiased;
|
||||||
--border: #e2e8f0;
|
|
||||||
--text: #1e293b;
|
|
||||||
--text-secondary: #64748b;
|
|
||||||
--success: #10b981;
|
|
||||||
--warning: #f59e0b;
|
|
||||||
--error: #ef4444;
|
|
||||||
--danger: #dc2626;
|
|
||||||
--danger-hover: #b91c1c;
|
|
||||||
--radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
height: 100vh;
|
|
||||||
overflow: hidden;
|
|
||||||
user-select: none;
|
|
||||||
-webkit-user-select: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splash {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Header ─────────────────────────── */
|
|
||||||
#wizard-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 24px;
|
||||||
padding: 14px 24px;
|
padding: 48px;
|
||||||
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
|
|
||||||
color: white;
|
|
||||||
--wails-draggable: drag;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-left { display: flex; flex-direction: column; gap: 2px; min-width: 140px; }
|
.logo {
|
||||||
.logo-text { font-size: 15px; font-weight: 700; letter-spacing: -0.3px; }
|
font-size: 28px;
|
||||||
.version-text { font-size: 10px; opacity: 0.5; }
|
|
||||||
|
|
||||||
.header-center { flex: 1; display: flex; justify-content: center; }
|
|
||||||
|
|
||||||
.header-right { min-width: 100px; display: flex; justify-content: flex-end; }
|
|
||||||
|
|
||||||
.step-indicators { display: flex; align-items: center; gap: 4px; }
|
|
||||||
|
|
||||||
.step-dot {
|
|
||||||
width: 28px; height: 28px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 2px solid rgba(255,255,255,0.25);
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
font-size: 11px; font-weight: 700;
|
|
||||||
color: rgba(255,255,255,0.35);
|
|
||||||
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
}
|
|
||||||
.step-dot.active {
|
|
||||||
border-color: var(--primary);
|
|
||||||
background: var(--primary);
|
|
||||||
color: white;
|
|
||||||
box-shadow: 0 0 12px rgba(99, 102, 241, 0.5);
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
.step-dot.completed {
|
|
||||||
border-color: var(--success);
|
|
||||||
background: var(--success);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-line {
|
|
||||||
width: 14px; height: 2px;
|
|
||||||
background: rgba(255,255,255,0.15);
|
|
||||||
border-radius: 1px;
|
|
||||||
transition: background 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Language Switcher ─────────────── */
|
|
||||||
.lang-switch {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
--wails-draggable: no-drag;
|
|
||||||
}
|
|
||||||
.lang-sep {
|
|
||||||
color: rgba(255,255,255,0.3);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.lang-btn {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid rgba(255,255,255,0.25);
|
|
||||||
color: rgba(255,255,255,0.6);
|
|
||||||
padding: 3px 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
letter-spacing: 0.02em;
|
||||||
transition: all 0.2s;
|
color: #ffffff;
|
||||||
}
|
|
||||||
.lang-btn:hover {
|
|
||||||
border-color: rgba(255,255,255,0.5);
|
|
||||||
color: rgba(255,255,255,0.9);
|
|
||||||
}
|
|
||||||
.lang-btn.active {
|
|
||||||
background: rgba(255,255,255,0.15);
|
|
||||||
border-color: rgba(255,255,255,0.4);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Main Content ───────────────────── */
|
|
||||||
main {
|
|
||||||
flex: 1;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
padding: 32px 36px;
|
|
||||||
display: none;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow-y: auto;
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(20px);
|
|
||||||
transition: opacity 0.35s ease, transform 0.35s ease;
|
|
||||||
}
|
|
||||||
.step.active {
|
|
||||||
display: flex;
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-bottom: 24px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Info Card ──────────────────────── */
|
|
||||||
.info-card {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 14px 18px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 7px 0;
|
|
||||||
}
|
|
||||||
.info-row + .info-row { border-top: 1px solid var(--border); }
|
|
||||||
.info-label { font-size: 13px; color: var(--text-secondary); }
|
|
||||||
.info-value { font-size: 13px; font-weight: 500; }
|
|
||||||
|
|
||||||
.status-ok { color: var(--success); }
|
|
||||||
.status-installed { color: var(--success); }
|
|
||||||
.status-warn { color: var(--warning); }
|
|
||||||
.status-err { color: var(--error); }
|
|
||||||
.status-skipped { color: var(--text-secondary); }
|
|
||||||
|
|
||||||
/* ── Warning Card ───────────────────── */
|
|
||||||
.warning-card {
|
|
||||||
background: #fef3c7;
|
|
||||||
border: 1px solid #f59e0b;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 14px 18px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.warning-card p { margin: 6px 0; color: #92400e; }
|
|
||||||
.existing-path { font-family: "SF Mono", Menlo, monospace; font-size: 12px; }
|
|
||||||
|
|
||||||
/* ── Buttons ────────────────────────── */
|
|
||||||
.actions {
|
|
||||||
margin-top: auto;
|
|
||||||
padding-top: 20px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
padding: 9px 22px;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: var(--primary);
|
|
||||||
color: white;
|
|
||||||
box-shadow: 0 1px 3px rgba(99, 102, 241, 0.3);
|
|
||||||
}
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
|
||||||
background: var(--primary-hover);
|
|
||||||
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.4);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: var(--border);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
.btn-secondary:hover:not(:disabled) { background: #cbd5e1; }
|
|
||||||
|
|
||||||
.btn-ghost {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.btn-ghost:hover:not(:disabled) {
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--text);
|
|
||||||
border-color: #cbd5e1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger { background: var(--danger); color: white; }
|
|
||||||
.btn-danger:hover:not(:disabled) { background: var(--danger-hover); }
|
|
||||||
|
|
||||||
.btn-warning { background: var(--warning); color: white; }
|
|
||||||
.btn-warning:hover:not(:disabled) { background: #d97706; }
|
|
||||||
|
|
||||||
/* ── Form Groups ───────────────────── */
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-label {
|
|
||||||
display: block;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text);
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-hint {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-top: 4px;
|
|
||||||
line-height: 1.5;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Path Input ─────────────────────── */
|
|
||||||
.path-input-group {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-field {
|
|
||||||
flex: 1;
|
|
||||||
padding: 10px 14px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
font-size: 13px;
|
|
||||||
background: var(--surface);
|
|
||||||
color: var(--text);
|
|
||||||
transition: border-color 0.2s, box-shadow 0.2s;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
.input-field:focus {
|
|
||||||
border-color: var(--primary);
|
|
||||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.12);
|
|
||||||
}
|
|
||||||
.input-field::placeholder {
|
|
||||||
color: #94a3b8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-text {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-bottom: 12px;
|
|
||||||
min-height: 18px;
|
|
||||||
}
|
|
||||||
.status-text.error { color: var(--error); }
|
|
||||||
|
|
||||||
/* ── Component List ─────────────────── */
|
|
||||||
.component-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.component-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.2s, box-shadow 0.2s;
|
|
||||||
}
|
|
||||||
.component-item:hover {
|
|
||||||
border-color: var(--primary);
|
|
||||||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.08);
|
|
||||||
}
|
|
||||||
.component-item.required { opacity: 0.85; }
|
|
||||||
|
|
||||||
.component-check {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding-top: 1px;
|
|
||||||
}
|
|
||||||
.component-check input[type="checkbox"] { margin: 0; }
|
|
||||||
|
|
||||||
.component-info { display: flex; flex-direction: column; gap: 3px; }
|
|
||||||
.component-name { font-size: 13px; font-weight: 600; }
|
|
||||||
.component-desc { font-size: 11px; color: var(--text-secondary); line-height: 1.4; }
|
|
||||||
|
|
||||||
/* ── Progress ───────────────────────── */
|
|
||||||
.progress-container {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar {
|
|
||||||
flex: 1;
|
|
||||||
height: 10px;
|
|
||||||
background: var(--border);
|
|
||||||
border-radius: 5px;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-fill {
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(90deg, var(--primary), #818cf8, var(--primary));
|
|
||||||
background-size: 200% 100%;
|
|
||||||
border-radius: 5px;
|
|
||||||
transition: width 0.4s ease;
|
|
||||||
animation: progress-pulse 2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes progress-pulse {
|
|
||||||
0%, 100% { background-position: 0% 0%; }
|
|
||||||
50% { background-position: 100% 0%; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-percent {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--primary);
|
|
||||||
min-width: 40px;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-message {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-area {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 120px;
|
|
||||||
max-height: 220px;
|
|
||||||
background: #0f172a;
|
|
||||||
color: #94a3b8;
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 12px 14px;
|
|
||||||
font-family: "SF Mono", "Menlo", "Cascadia Code", monospace;
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 1.7;
|
|
||||||
overflow-y: auto;
|
|
||||||
box-shadow: inset 0 1px 4px rgba(0,0,0,0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-area .log-line { color: #94a3b8; }
|
|
||||||
.log-area .log-error { color: var(--error); }
|
|
||||||
.log-area .log-success { color: var(--success); }
|
|
||||||
|
|
||||||
/* ── Hardware List ──────────────────── */
|
|
||||||
.hardware-list { margin-bottom: 20px; }
|
|
||||||
|
|
||||||
.device-card {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
padding: 14px 18px;
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
}
|
|
||||||
.device-card:hover { border-color: var(--primary); }
|
|
||||||
|
|
||||||
.device-card .device-icon {
|
|
||||||
width: 40px; height: 40px;
|
|
||||||
background: var(--primary-light);
|
|
||||||
border-radius: 10px;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
font-size: 18px;
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.device-card .device-info { display: flex; flex-direction: column; gap: 3px; }
|
|
||||||
.device-card .device-name { font-size: 14px; font-weight: 600; }
|
|
||||||
.device-card .device-detail { font-size: 11px; color: var(--text-secondary); }
|
|
||||||
|
|
||||||
.device-scanning {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 32px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.spinner {
|
.spinner {
|
||||||
width: 28px; height: 28px;
|
width: 42px;
|
||||||
border: 3px solid var(--border);
|
height: 42px;
|
||||||
border-top-color: var(--primary);
|
border: 3px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-top-color: #6ea8ff;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 0.8s linear infinite;
|
animation: spin 0.9s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-devices {
|
.status {
|
||||||
text-align: center;
|
font-size: 14px;
|
||||||
padding: 28px;
|
color: #a7b0c6;
|
||||||
color: var(--text-secondary);
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
max-width: 560px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: rgba(220, 38, 38, 0.12);
|
||||||
|
border: 1px solid rgba(220, 38, 38, 0.4);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fca5a5;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
line-height: 1.5;
|
||||||
|
|
||||||
/* ── Complete Icon ──────────────────── */
|
|
||||||
.complete-icon {
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 12px;
|
|
||||||
color: var(--success);
|
|
||||||
animation: checkmark-pop 0.5s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes checkmark-pop {
|
|
||||||
0% { transform: scale(0.5); opacity: 0; }
|
|
||||||
60% { transform: scale(1.1); }
|
|
||||||
100% { transform: scale(1); opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Summary List ──────────────────── */
|
|
||||||
.summary-list {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 14px 18px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Scrollbar ──────────────────────── */
|
|
||||||
::-webkit-scrollbar { width: 6px; }
|
|
||||||
::-webkit-scrollbar-track { background: transparent; }
|
|
||||||
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
|
|
||||||
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
|
|
||||||
|
|
||||||
/* ── Step transition for newly shown steps ── */
|
|
||||||
@keyframes step-enter {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(24px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.step.active {
|
|
||||||
animation: step-enter 0.35s ease forwards;
|
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user