feat: v0.9.1 — 面板设置页、网络代理、后台安装、模型服务商扩展、多项修复

新功能:
- 新增独立面板设置页面(网络代理 + 代理测试 + 模型代理开关 + npm源)
- 网络代理支持:下载类操作走代理,自动绕过内网地址
- 安装/升级/卸载改为后台执行,不再阻塞界面
- 全局任务状态栏:关闭弹窗后顶部显示进度,可重新查看日志
- 安装/卸载完成后自动刷新界面状态
- 新增多个模型服务商快捷配置(硅基流动、火山引擎、阿里云百炼、智谱AI、MiniMax、NVIDIA NIM、胜算云)
- AI助手浮动按钮恢复,首次提示可拖动,实时聊天页隐藏

修复:
- 修复版本更新误判(本地版本高于远端不再误弹更新)
- 修复Windows下nvm/自定义Node路径CLI检测
- 修复npm EEXIST文件冲突(--force + 安装前自动清理)
- 修复汉化版-zh.x后缀版本比较错误
- 修复模型URL自动拼接/v1问题
- 修复切换版本后Gateway重装失败(PATH缓存刷新)
- 修复切换助手服务商时旧模型名残留

优化:
- macOS图标改用docs/logo.png统一生成
- 内置推荐版本号更新到OpenClaw 2026.3.13
- 错误诊断增强(EEXIST识别)
- 弹窗标题根据操作类型显示
- 新增版本维护文档
This commit is contained in:
晴天
2026-03-14 19:57:22 +08:00
parent c8ccb5dd4b
commit 394813a96c
88 changed files with 1807 additions and 513 deletions

View File

@@ -7,6 +7,7 @@
const BOT_ICON = '<svg viewBox="0 0 24 24"><path d="M12 8V4H8"/><rect x="5" y="8" width="14" height="12" rx="2"/><path d="M9 13h0"/><path d="M15 13h0"/><path d="M10 17h4"/></svg>'
const POS_KEY = 'clawpanel-fab-pos'
const ENABLE_AI_FAB = true
// ── 页面上下文收集器注册表 ──
const _contextProviders = {}
@@ -25,8 +26,14 @@ let _fab = null
/** 初始化 FAB */
export function initAIFab() {
if (!ENABLE_AI_FAB) {
document.querySelectorAll('.ai-fab').forEach(el => el.remove())
_fab = null
return null
}
if (_fab) return _fab
_fab = createFab()
showDragHintOnce(_fab.el)
return _fab
}
@@ -42,8 +49,13 @@ export function openAIDrawerWithError(errorCtx) {
// 不自动导航 — FAB 按钮会出现红点提示,用户主动点击时跳转
// 如果用户已在助手页,也会实时检测到
if (getCurrentRoute() !== '/assistant') {
// 让 FAB 显示红点
if (_fab?.el) _fab.el.classList.add('has-error')
if (_fab?.el) {
_fab.el.classList.add('has-error')
} else {
import('./toast.js')
.then(({ toast }) => toast('已保存诊断上下文,可从侧边栏进入「晴辰助手」继续处理', 'info'))
.catch(() => {})
}
} else {
// 已在助手页 → 直接触发 banner 显示
window.dispatchEvent(new CustomEvent('assistant-error-injected'))
@@ -172,10 +184,11 @@ function createFab() {
window.location.hash = '#/assistant'
}
// ── 路由变化时隐藏/显示 ──
// ── 路由变化时隐藏/显示(助手页和实时聊天页隐藏) ──
const HIDE_ROUTES = ['/assistant', '/chat']
function updateVisibility() {
const route = getCurrentRoute()
fab.style.display = route === '/assistant' ? 'none' : 'flex'
fab.style.display = HIDE_ROUTES.includes(route) ? 'none' : 'flex'
}
window.addEventListener('hashchange', updateVisibility)
@@ -209,3 +222,14 @@ function restorePosition(fab) {
}
} catch {}
}
const HINT_KEY = 'clawpanel-fab-hint-shown'
function showDragHintOnce(el) {
if (!el || localStorage.getItem(HINT_KEY)) return
const tip = document.createElement('div')
tip.className = 'ai-fab-hint'
tip.textContent = '长按可拖动'
el.appendChild(tip)
localStorage.setItem(HINT_KEY, '1')
setTimeout(() => tip.remove(), 4000)
}

View File

@@ -197,7 +197,7 @@ export function showUpgradeModal(title) {
</div>
<div class="upgrade-log-box"></div>
<div class="modal-actions">
<button class="btn btn-secondary btn-sm" data-action="close" disabled>关闭</button>
<button class="btn btn-secondary btn-sm" data-action="close">关闭</button>
</div>
</div>
`
@@ -210,9 +210,51 @@ export function showUpgradeModal(title) {
const _logLines = []
let _onClose = null
closeBtn.onclick = () => { overlay.remove(); _onClose?.() }
let _finished = false
let _taskBar = null
// 重新打开弹窗(从任务状态栏点击时)
function reopenModal() {
if (_taskBar) { _taskBar.remove(); _taskBar = null }
document.body.appendChild(overlay)
}
// 关闭弹窗:未完成时显示任务状态栏
function closeModal() {
overlay.remove()
if (!_finished) {
showTaskBar()
} else {
if (_taskBar) { _taskBar.remove(); _taskBar = null }
_onClose?.()
}
}
// 全局任务状态栏:关闭弹窗后显示在页面顶部
function showTaskBar() {
if (_taskBar) return
_taskBar = document.createElement('div')
_taskBar.className = 'upgrade-task-bar'
_taskBar.innerHTML = `
<span class="upgrade-task-bar-text">${text.textContent}</span>
<button class="btn btn-sm upgrade-task-bar-open">查看详情</button>
<button class="btn btn-sm btn-ghost upgrade-task-bar-dismiss">×</button>
`
_taskBar.querySelector('.upgrade-task-bar-open').onclick = reopenModal
_taskBar.querySelector('.upgrade-task-bar-dismiss').onclick = () => { _taskBar.remove(); _taskBar = null }
document.body.appendChild(_taskBar)
}
function updateTaskBar(statusText) {
if (_taskBar) {
const span = _taskBar.querySelector('.upgrade-task-bar-text')
if (span) span.textContent = statusText
}
}
closeBtn.onclick = closeModal
overlay.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !closeBtn.disabled) { overlay.remove(); _onClose?.() }
if (e.key === 'Escape') closeModal()
})
return {
@@ -233,25 +275,33 @@ export function showUpgradeModal(title) {
getLogText() { return _logLines.join('\n') },
setProgress(pct) {
fill.style.width = pct + '%'
if (pct >= 100) text.textContent = '完成'
else if (pct >= 75) text.textContent = '正在安装...'
else if (pct >= 30) text.textContent = '正在下载依赖...'
else text.textContent = '准备中...'
let statusText
if (pct >= 100) statusText = '完成'
else if (pct >= 75) statusText = '正在安装...'
else if (pct >= 30) statusText = '正在下载依赖...'
else statusText = '准备中...'
text.textContent = statusText
updateTaskBar(statusText)
},
setDone(msg) {
_finished = true
text.textContent = msg || '升级完成'
fill.style.width = '100%'
fill.classList.add('done')
closeBtn.disabled = false
if (_taskBar) { _taskBar.remove(); _taskBar = null }
closeBtn.focus()
},
setError(msg) {
_finished = true
text.textContent = msg || '升级失败'
fill.classList.add('error')
closeBtn.disabled = false
if (_taskBar) {
const span = _taskBar.querySelector('.upgrade-task-bar-text')
if (span) { span.textContent = msg || '升级失败'; span.style.color = 'var(--error)' }
}
closeBtn.focus()
},
onClose(fn) { _onClose = fn },
destroy() { overlay.remove(); _onClose?.() },
destroy() { overlay.remove(); if (_taskBar) { _taskBar.remove(); _taskBar = null } _onClose?.() },
}
}

View File

@@ -47,6 +47,7 @@ const NAV_ITEMS_FULL = [
{
section: '',
items: [
{ route: '/settings', label: '面板设置', icon: 'settings' },
{ route: '/chat-debug', label: '系统诊断', icon: 'debug' },
{ route: '/about', label: '关于', icon: 'about' },
]
@@ -64,6 +65,7 @@ const NAV_ITEMS_SETUP = [
{
section: '',
items: [
{ route: '/settings', label: '面板设置', icon: 'settings' },
{ route: '/chat-debug', label: '系统诊断', icon: 'debug' },
{ route: '/about', label: '关于', icon: 'about' },
]

View File

@@ -4,6 +4,8 @@
*/
const NPM_CMD = 'npm install -g @qingchencloud/openclaw-zh --registry https://registry.npmmirror.com'
const GIT_HTTPS_CMD = 'git config --global url."https://github.com/".insteadOf ssh://git@github.com/ && git config --global --add url."https://github.com/".insteadOf ssh://git@github.com && git config --global --add url."https://github.com/".insteadOf ssh://git@://github.com/ && git config --global --add url."https://github.com/".insteadOf git@github.com: && git config --global --add url."https://github.com/".insteadOf git://github.com/ && git config --global --add url."https://github.com/".insteadOf git+ssh://git@github.com/'
const GIT_HTTPS_ROOT_CMD = 'sudo git config --global url."https://github.com/".insteadOf ssh://git@github.com/ && sudo git config --global --add url."https://github.com/".insteadOf ssh://git@github.com && sudo git config --global --add url."https://github.com/".insteadOf ssh://git@://github.com/ && sudo git config --global --add url."https://github.com/".insteadOf git@github.com: && sudo git config --global --add url."https://github.com/".insteadOf git://github.com/ && sudo git config --global --add url."https://github.com/".insteadOf git+ssh://git@github.com/'
/**
* @param {string} errStr - npm 错误输出(可含流式日志)
@@ -11,6 +13,11 @@ const NPM_CMD = 'npm install -g @qingchencloud/openclaw-zh --registry https://re
*/
export function diagnoseInstallError(errStr) {
const s = errStr.toLowerCase()
const rootNpm = s.includes('/root/.npm/') || s.includes('/root/.config/') || s.includes('sudo npm')
const gitFixCommand = rootNpm ? GIT_HTTPS_ROOT_CMD : GIT_HTTPS_CMD
const gitFixHint = rootNpm
? 'GitHub SSH 认证失败。检测到本次安装实际由 root/sudo 执行,请先为 root 用户配置 HTTPS 替代规则后重试:'
: 'GitHub SSH 认证失败。ClawPanel 已尝试自动配置 HTTPS 替代,但可能未生效。请在终端手动执行:'
// ===== 1. Git 相关 =====
@@ -18,8 +25,8 @@ export function diagnoseInstallError(errStr) {
if (s.includes('permission denied (publickey)') || s.includes('host key verification failed')) {
return {
title: '安装失败 — Git SSH 认证被拒绝',
hint: 'GitHub SSH 认证失败。ClawPanel 已尝试自动配置 HTTPS 替代,但可能未生效。请在终端手动执行:',
command: 'git config --global url."https://github.com/".insteadOf ssh://git@github.com/ && git config --global --add url."https://github.com/".insteadOf git@github.com: && git config --global --add url."https://github.com/".insteadOf git://github.com/ && git config --global --add url."https://github.com/".insteadOf git+ssh://git@github.com/',
hint: gitFixHint,
command: gitFixCommand,
}
}
@@ -28,14 +35,14 @@ export function diagnoseInstallError(errStr) {
if (s.includes('permission denied') || s.includes('publickey') || s.includes('host key verification')) {
return {
title: '安装失败 — Git SSH 认证被拒绝',
hint: 'GitHub SSH 认证失败。ClawPanel 已尝试自动配置 HTTPS 替代,但可能未生效。请在终端手动执行后重试:',
command: 'git config --global url."https://github.com/".insteadOf ssh://git@github.com/ && git config --global --add url."https://github.com/".insteadOf git@github.com: && git config --global --add url."https://github.com/".insteadOf git://github.com/ && git config --global --add url."https://github.com/".insteadOf git+ssh://git@github.com/',
hint: rootNpm ? 'GitHub SSH 认证失败。检测到本次安装由 root/sudo 执行,请先为 root 用户配置 HTTPS 替代规则后重试:' : 'GitHub SSH 认证失败。ClawPanel 已尝试自动配置 HTTPS 替代,但可能未生效。请在终端手动执行后重试:',
command: gitFixCommand,
}
}
return {
title: '安装失败 — Git 拉取依赖错误',
hint: 'Git 操作失败exit 128。可能是网络问题或 SSH 认证失败。请先确认网络正常,然后在终端手动执行以下命令后重试:',
command: 'git config --global url."https://github.com/".insteadOf ssh://git@github.com/ && git config --global --add url."https://github.com/".insteadOf git@github.com: && git config --global --add url."https://github.com/".insteadOf git+ssh://git@github.com/',
hint: rootNpm ? 'Git 操作失败exit 128。检测到本次安装由 root/sudo 执行,请先确认网络正常,再为 root 用户执行以下 HTTPS 替代规则后重试:' : 'Git 操作失败exit 128。可能是网络问题或 SSH 认证失败。请先确认网络正常,然后在终端手动执行以下命令后重试:',
command: gitFixCommand,
}
}
@@ -59,6 +66,15 @@ export function diagnoseInstallError(errStr) {
}
}
// EEXIST文件已存在切换版本/源时常见)
if (s.includes('eexist') || s.includes('file already exists') || s.includes('file exists')) {
return {
title: '安装失败 — 文件冲突',
hint: '旧版本的 openclaw 命令文件仍然存在。ClawPanel 已尝试自动清理,如仍失败请手动处理后重试:',
command: 'npm install -g @qingchencloud/openclaw-zh --force --registry https://registry.npmmirror.com',
}
}
// ENOENT文件找不到 / -4058
if (s.includes('enoent') || s.includes('-4058') || s.includes('code -4058')) {
// 尝试从日志中提取具体缺失的路径

View File

@@ -14,11 +14,17 @@ export const API_TYPES = [
// 服务商快捷预设(晴辰云官方置顶)
export const PROVIDER_PRESETS = [
{ key: 'qtcool', label: '晴辰云', badge: '官方', baseUrl: 'https://gpt.qt.cool/v1', api: 'openai-completions', site: 'https://gpt.qt.cool/', desc: 'GPT-5 全系列开箱即用,更多模型持续接入中。每日签到送额度 · 邀请送余额 · 充值最低 3 折消耗 · 未消耗包退' },
{ key: 'shengsuanyun', label: '胜算云', hidden: true, baseUrl: 'https://router.shengsuanyun.com/api/v1', api: 'openai-completions', site: 'https://www.shengsuanyun.com/?from=CH_4BVI0BM2', desc: '国内知名 AI 模型聚合平台,支持多种主流模型' },
{ key: 'shengsuanyun', label: '胜算云', baseUrl: 'https://router.shengsuanyun.com/api/v1', api: 'openai-completions', site: 'https://www.shengsuanyun.com/?from=CH_4BVI0BM2', desc: '国内知名 AI 模型聚合平台,支持多种主流模型' },
{ key: 'siliconflow', label: '硅基流动', baseUrl: 'https://api.siliconflow.cn/v1', api: 'openai-completions', site: 'https://cloud.siliconflow.cn/i/PFrw2an5', desc: '高性价比推理平台,支持 DeepSeek、Qwen 等开源模型' },
{ key: 'volcengine', label: '火山引擎', baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', api: 'openai-completions', site: 'https://volcengine.com/L/Ph1OP5I3_GY', desc: '字节跳动旗下云平台,支持豆包等模型' },
{ key: 'aliyun', label: '阿里云百炼', baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', api: 'openai-completions', site: 'https://www.aliyun.com/benefit/ai/aistar?userCode=keahn2zr&clubBiz=subTask..12435175..10263..', desc: '阿里云 AI 大模型平台,支持通义千问全系列' },
{ key: 'zhipu', label: '智谱 AI', baseUrl: 'https://open.bigmodel.cn/api/paas/v4', api: 'openai-completions', site: 'https://www.bigmodel.cn/glm-coding?ic=3F6F9XYKTS', desc: '国产大模型领军企业,支持 GLM-4 全系列' },
{ key: 'minimax', label: 'MiniMax', baseUrl: 'https://api.minimax.chat/v1', api: 'openai-completions', site: 'https://platform.minimaxi.com/subscribe/coding-plan?code=7pUc5oLo4K&source=link', desc: '国产多模态大模型,支持 MiniMax-Text 系列' },
{ key: 'openai', label: 'OpenAI 官方', baseUrl: 'https://api.openai.com/v1', api: 'openai-completions' },
{ key: 'anthropic', label: 'Anthropic 官方', baseUrl: 'https://api.anthropic.com', api: 'anthropic-messages' },
{ key: 'deepseek', label: 'DeepSeek', baseUrl: 'https://api.deepseek.com/v1', api: 'openai-completions' },
{ key: 'google', label: 'Google Gemini', baseUrl: 'https://generativelanguage.googleapis.com/v1beta', api: 'google-gemini' },
{ key: 'nvidia', label: 'NVIDIA NIM', baseUrl: 'https://integrate.api.nvidia.com/v1', api: 'openai-completions', desc: '英伟达推理平台,支持 Llama、Mistral 等模型' },
{ key: 'ollama', label: 'Ollama (本地)', baseUrl: 'http://127.0.0.1:11434/v1', api: 'openai-completions' },
]

View File

@@ -205,6 +205,7 @@ export const api = {
// 面板配置 (clawpanel.json)
readPanelConfig: () => invoke('read_panel_config'),
writePanelConfig: (config) => invoke('write_panel_config', { config }),
testProxy: (url) => invoke('test_proxy', { url: url || null }),
// 安装/部署
checkInstallation: () => cachedInvoke('check_installation', {}, 60000),
@@ -212,7 +213,7 @@ export const api = {
checkNode: () => cachedInvoke('check_node', {}, 60000),
checkNodeAtPath: (nodeDir) => invoke('check_node_at_path', { nodeDir }),
scanNodePaths: () => invoke('scan_node_paths'),
saveCustomNodePath: (nodeDir) => invoke('save_custom_node_path', { nodeDir }).then(r => { invalidate('check_node'); invoke('invalidate_path_cache').catch(() => {}); return r }),
saveCustomNodePath: (nodeDir) => invoke('save_custom_node_path', { nodeDir }).then(r => { invalidate('check_node', 'get_services_status'); invoke('invalidate_path_cache').catch(() => {}); return r }),
invalidatePathCache: () => invoke('invalidate_path_cache'),
checkGit: () => cachedInvoke('check_git', {}, 60000),
autoInstallGit: () => invoke('auto_install_git'),

View File

@@ -4,7 +4,7 @@
import { registerRoute, initRouter, navigate, setDefaultRoute } from './router.js'
import { renderSidebar, openMobileSidebar } from './components/sidebar.js'
import { initTheme } from './lib/theme.js'
import { detectOpenclawStatus, isOpenclawReady, isGatewayRunning, onGatewayChange, startGatewayPoll, onGuardianGiveUp, resetAutoRestart, loadActiveInstance, getActiveInstance, onInstanceChange } from './lib/app-state.js'
import { detectOpenclawStatus, isOpenclawReady, isUpgrading, isGatewayRunning, onGatewayChange, startGatewayPoll, onGuardianGiveUp, resetAutoRestart, loadActiveInstance, getActiveInstance, onInstanceChange } from './lib/app-state.js'
import { wsClient } from './lib/ws-client.js'
import { api, checkBackendHealth, isBackendOnline, onBackendStatusChange } from './lib/tauri-api.js'
import { version as APP_VERSION } from '../package.json'
@@ -306,6 +306,7 @@ async function boot() {
registerRoute('/cron', () => import('./pages/cron.js'))
registerRoute('/usage', () => import('./pages/usage.js'))
registerRoute('/communication', () => import('./pages/communication.js'))
registerRoute('/settings', () => import('./pages/settings.js'))
renderSidebar(sidebar)
initRouter(content)
@@ -407,6 +408,30 @@ async function boot() {
if (isGatewayRunning()) autoConnectWebSocket()
})
}
// 全局监听后台任务完成/失败事件,自动刷新安装状态和侧边栏
if (window.__TAURI_INTERNALS__) {
import('@tauri-apps/api/event').then(async ({ listen }) => {
const refreshAfterTask = async () => {
// 清除 API 缓存,确保拿到最新状态
const { invalidate } = await import('./lib/tauri-api.js')
invalidate('check_installation', 'get_services_status', 'get_version_info')
await detectOpenclawStatus()
renderSidebar(sidebar)
// 如果安装完成后变为就绪,跳转到仪表盘
if (isOpenclawReady() && window.location.hash === '#/setup') {
navigate('/dashboard')
}
// 如果卸载后变为未就绪,跳转到 setup
if (!isOpenclawReady() && !isUpgrading()) {
setDefaultRoute('/setup')
navigate('/setup')
}
}
await listen('upgrade-done', refreshAfterTask)
await listen('upgrade-error', refreshAfterTask)
}).catch(() => {})
}
})
}
@@ -729,7 +754,7 @@ function startUpdateChecker() {
} catch {}
try {
const ver = await api.getVersionInfo()
lines.push(`- 版本: ${ver?.current || '?'} ${ver?.latest || '?'}`)
lines.push(`- 版本: 当前 ${ver?.current || '?'} / 推荐 ${ver?.recommended || '?'} / 最新 ${ver?.latest || '?'}${ver?.ahead_of_recommended ? ' / 当前版本高于推荐版' : ''}`)
} catch {}
return { detail: lines.join('\n') }
})

View File

@@ -84,6 +84,12 @@ async function loadData(page) {
const isInstalled = !!version.current
const sourceLabel = version.source === 'official' ? '官方版' : '汉化版'
const btnSm = 'padding:2px 8px;font-size:var(--font-size-xs)'
const hasRecommended = !!version.recommended
const aheadOfRecommended = isInstalled && hasRecommended && !!version.ahead_of_recommended
const driftFromRecommended = isInstalled && hasRecommended && !version.is_recommended && !aheadOfRecommended
const policyRiskHint = aheadOfRecommended
? `检测到你本地安装的是高于推荐稳定版的 ${version.current},可能存在接口、事件或配置兼容性问题。建议回退到 ${version.recommended};如果你要继续使用高版本,请自行验证兼容性并关注 issue / release。`
: '当前面板默认只保证推荐稳定版的兼容性;如果你要尝试其他版本或预览版,请自行验证兼容性。若希望面板尽快支持最新版特性,欢迎提交 issue 告诉我们。'
cards.innerHTML = `
<div class="stat-card">
@@ -95,15 +101,24 @@ async function loadData(page) {
<div class="stat-card-header"><span class="stat-card-label">OpenClaw · ${sourceLabel}</span></div>
<div class="stat-card-value">${version.current || '未安装'}</div>
<div class="stat-card-meta" style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
${isInstalled ? (version.update_available
? `<span style="color:var(--accent)">新版本: ${version.latest}</span>
<button class="btn btn-primary btn-sm" id="btn-upgrade-latest" style="${btnSm}">升级到最新</button>`
: '<span style="color:var(--success)">已是最新</span>') : ''}
${isInstalled && hasRecommended
? (aheadOfRecommended
? `<span style="color:var(--warning,#f59e0b)">当前版本高于推荐稳定版: ${version.recommended}</span>
<button class="btn btn-primary btn-sm" id="btn-apply-recommended" style="${btnSm}">回退到推荐版</button>`
: driftFromRecommended
? `<span style="color:var(--accent)">推荐稳定版: ${version.recommended}</span>
<button class="btn btn-primary btn-sm" id="btn-apply-recommended" style="${btnSm}">切换到推荐版</button>`
: '<span style="color:var(--success)">已是推荐稳定版</span>')
: ''}
${version.latest_update_available && version.latest ? `<span style="color:var(--text-tertiary)">最新上游: ${version.latest}</span>` : ''}
<button class="btn btn-${isInstalled ? 'secondary' : 'primary'} btn-sm" id="btn-version-mgmt" style="${btnSm}">
${isInstalled ? '切换版本' : '安装 OpenClaw'}
</button>
${isInstalled ? `<button class="btn btn-secondary btn-sm" id="btn-uninstall" style="${btnSm};color:var(--error)">卸载</button>` : ''}
</div>
<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary);line-height:1.6">
${policyRiskHint}
</div>
</div>
<div class="stat-card">
<div class="stat-card-header"><span class="stat-card-label">安装路径</span></div>
@@ -112,10 +127,9 @@ async function loadData(page) {
</div>
`
// 升级到最新
const upgLatestBtn = cards.querySelector('#btn-upgrade-latest')
if (upgLatestBtn) {
upgLatestBtn.onclick = () => doInstall(page, '升级 OpenClaw', version.source, null)
const applyRecommendedBtn = cards.querySelector('#btn-apply-recommended')
if (applyRecommendedBtn && version.recommended) {
applyRecommendedBtn.onclick = () => doInstall(page, aheadOfRecommended ? '回退到推荐稳定版' : '切换到推荐稳定版', version.source, version.recommended)
}
// 版本管理 / 安装
@@ -133,22 +147,25 @@ async function loadData(page) {
const modal = showUpgradeModal('卸载 OpenClaw')
modal.onClose(() => loadData(page))
modal.appendLog('开始卸载 OpenClaw...')
let unlistenLog, unlistenProgress
let unlistenLog, unlistenProgress, unlistenDone, unlistenError
const cleanup = () => { unlistenLog?.(); unlistenProgress?.(); unlistenDone?.(); unlistenError?.() }
try {
if (window.__TAURI_INTERNALS__) {
try {
const { listen } = await import('@tauri-apps/api/event')
unlistenLog = await listen('upgrade-log', (e) => modal.appendLog(e.payload))
unlistenProgress = await listen('upgrade-progress', (e) => modal.setProgress(e.payload))
} catch {}
const { listen } = await import('@tauri-apps/api/event')
unlistenLog = await listen('upgrade-log', (e) => modal.appendLog(e.payload))
unlistenProgress = await listen('upgrade-progress', (e) => modal.setProgress(e.payload))
unlistenDone = await listen('upgrade-done', (e) => { cleanup(); modal.setDone(typeof e.payload === 'string' ? e.payload : '卸载完成') })
unlistenError = await listen('upgrade-error', (e) => { cleanup(); modal.setError('卸载失败: ' + (e.payload || '未知错误')) })
await api.uninstallOpenclaw(false)
modal.appendLog('后台卸载任务已启动...')
} else {
const msg = await api.uninstallOpenclaw(false)
modal.setDone(typeof msg === 'string' ? msg : '卸载完成')
cleanup()
}
const msg = await api.uninstallOpenclaw(false)
modal.setDone(typeof msg === 'string' ? msg : '卸载完成')
} catch (e) {
cleanup()
modal.setError('卸载失败: ' + (e?.message || e))
} finally {
unlistenLog?.()
unlistenProgress?.()
}
}
}
@@ -187,6 +204,9 @@ async function showVersionPicker(page, currentVersion) {
<option value="">加载中...</option>
</select>
</div>
<div style="font-size:var(--font-size-xs);color:var(--text-tertiary);line-height:1.6;padding:10px 12px;border-radius:8px;background:var(--bg-tertiary)">
默认建议使用当前面板绑定的推荐稳定版。若手动切换到其它版本,尤其是预览版/最新版,请自行验证兼容性;如果你希望面板优先适配最新版功能,欢迎提交 issue。
</div>
<div style="display:flex;align-items:center;justify-content:space-between;min-height:18px">
<div id="oc-action-hint" style="font-size:var(--font-size-xs);color:var(--text-tertiary)"></div>
<div id="nightly-toggle" style="display:none"></div>
@@ -227,19 +247,20 @@ async function showVersionPicker(page, currentVersion) {
const targetSource = currentSelect
const targetVer = select.value
if (!targetVer || targetVer === '') { hintEl.textContent = ''; confirmBtn.disabled = true; return }
const targetTag = select.selectedIndex === 0 ? '(推荐稳定版)' : '(需自测兼容性)'
const sameSource = targetSource === (currentVersion.source === 'official' ? 'official' : 'chinese')
if (!isInstalled) {
confirmBtn.textContent = '安装'
hintEl.textContent = `将安装 ${targetSource === 'official' ? '原版' : '汉化版'} ${targetVer}`
hintEl.textContent = `将安装 ${targetSource === 'official' ? '原版' : '汉化版'} ${targetVer}${targetTag}`
confirmBtn.disabled = false
return
}
if (!sameSource) {
confirmBtn.textContent = '切换'
hintEl.innerHTML = `当前: <strong>${currentVersion.source === 'official' ? '原版' : '汉化版'} ${currentVersion.current}</strong> → <strong>${targetSource === 'official' ? '原版' : '汉化版'} ${targetVer}</strong>`
hintEl.innerHTML = `当前: <strong>${currentVersion.source === 'official' ? '原版' : '汉化版'} ${currentVersion.current}</strong> → <strong>${targetSource === 'official' ? '原版' : '汉化版'} ${targetVer}</strong>${targetTag}`
confirmBtn.disabled = false
return
}
@@ -256,15 +277,15 @@ async function showVersionPicker(page, currentVersion) {
if (cmp === 0) {
confirmBtn.textContent = '重新安装'
hintEl.textContent = `当前已是 ${targetVer}`
hintEl.textContent = `当前已是 ${targetVer}${targetTag}`
confirmBtn.disabled = false
} else if (cmp > 0) {
confirmBtn.textContent = '升级'
hintEl.innerHTML = `<span style="color:var(--accent)">${currentVersion.current}${targetVer}</span>`
hintEl.innerHTML = `<span style="color:var(--accent)">${currentVersion.current}${targetVer}${targetTag}</span>`
confirmBtn.disabled = false
} else {
confirmBtn.textContent = '降级'
hintEl.innerHTML = `<span style="color:var(--warning,#f59e0b)">${currentVersion.current}${targetVer}</span>`
hintEl.innerHTML = `<span style="color:var(--warning,#f59e0b)">${currentVersion.current}${targetVer}${targetTag}</span>`
confirmBtn.disabled = false
}
}
@@ -287,9 +308,9 @@ async function showVersionPicker(page, currentVersion) {
const stable = allVersions.filter(v => !v.includes('nightly') && !v.includes('canary') && !v.includes('alpha') && !v.includes('beta') && !v.includes('rc') && !v.includes('dev') && !v.includes('next'))
const versions = showNightly ? allVersions : (stable.length > 0 ? stable : allVersions)
const nightlyCount = allVersions.length - stable.length
select.innerHTML = versions.map(v => {
select.innerHTML = versions.map((v, idx) => {
const isCurrent = isInstalled && v === currentVersion.current && source === (currentVersion.source === 'official' ? 'official' : 'chinese')
return `<option value="${v}">${v}${isCurrent ? ' (当前)' : ''}</option>`
return `<option value="${v}">${v}${idx === 0 ? ' (推荐)' : ''}${isCurrent ? ' (当前)' : ''}</option>`
}).join('')
// nightly 切换提示
const toggleEl = overlay.querySelector('#nightly-toggle')
@@ -338,42 +359,57 @@ async function showVersionPicker(page, currentVersion) {
async function doInstall(page, title, source, version) {
const modal = showUpgradeModal(title)
modal.onClose(() => loadData(page))
let unlistenLog, unlistenProgress
let unlistenLog, unlistenProgress, unlistenDone, unlistenError
setUpgrading(true)
const cleanup = () => {
setUpgrading(false)
unlistenLog?.(); unlistenProgress?.(); unlistenDone?.(); unlistenError?.()
}
try {
if (window.__TAURI_INTERNALS__) {
try {
const { listen } = await import('@tauri-apps/api/event')
unlistenLog = await listen('upgrade-log', (e) => modal.appendLog(e.payload))
unlistenProgress = await listen('upgrade-progress', (e) => modal.setProgress(e.payload))
} catch {}
const { listen } = await import('@tauri-apps/api/event')
unlistenLog = await listen('upgrade-log', (e) => modal.appendLog(e.payload))
unlistenProgress = await listen('upgrade-progress', (e) => modal.setProgress(e.payload))
unlistenDone = await listen('upgrade-done', (e) => {
cleanup()
modal.setDone(typeof e.payload === 'string' ? e.payload : '操作完成')
})
unlistenError = await listen('upgrade-error', async (e) => {
cleanup()
const errStr = String(e.payload || '未知错误')
modal.appendLog(errStr)
const { diagnoseInstallError } = await import('../lib/error-diagnosis.js')
const fullLog = modal.getLogText() + '\n' + errStr
const diagnosis = diagnoseInstallError(fullLog)
modal.setError(diagnosis.title)
if (diagnosis.hint) modal.appendLog('')
if (diagnosis.hint) modal.appendHtmlLog(`${statusIcon('info', 14)} ${diagnosis.hint}`)
if (diagnosis.command) modal.appendHtmlLog(`${icon('clipboard', 14)} ${diagnosis.command}`)
if (window.__openAIDrawerWithError) {
window.__openAIDrawerWithError({ title: diagnosis.title, error: fullLog, scene: title, hint: diagnosis.hint })
}
})
await api.upgradeOpenclaw(source, version)
modal.appendLog('后台任务已启动,请等待完成...')
} else {
modal.appendLog('Web 模式:安装过程日志不可用,请等待完成...')
const msg = await api.upgradeOpenclaw(source, version)
modal.setDone(typeof msg === 'string' ? msg : (msg?.message || '操作完成'))
cleanup()
}
const msg = await api.upgradeOpenclaw(source, version)
modal.setDone(typeof msg === 'string' ? msg : (msg?.message || '操作完成'))
} catch (e) {
cleanup()
const errStr = String(e)
modal.appendLog(errStr)
const { diagnoseInstallError } = await import('../lib/error-diagnosis.js')
const fullLog = modal.getLogText() + '\n' + errStr
const diagnosis = diagnoseInstallError(fullLog)
modal.setError(diagnosis.title)
if (diagnosis.hint) modal.appendLog('')
if (diagnosis.hint) modal.appendHtmlLog(`${statusIcon('info', 14)} ${diagnosis.hint}`)
if (diagnosis.command) modal.appendHtmlLog(`${icon('clipboard', 14)} ${diagnosis.command}`)
if (window.__openAIDrawerWithError) {
window.__openAIDrawerWithError({
title: diagnosis.title,
error: fullLog,
scene: title,
hint: diagnosis.hint,
})
}
} finally {
setUpgrading(false)
unlistenLog?.()
unlistenProgress?.()
}
}

View File

@@ -2734,6 +2734,11 @@ function showSettings() {
baseUrlInput.value = btn.dataset.url
apiTypeSelect.value = btn.dataset.api
apiTypeSelect.dispatchEvent(new Event('change'))
// 切换服务商时清空模型和下拉列表,让用户重新选择或拉取
const modelInput = overlay.querySelector('#ast-model')
const modelDropdown = overlay.querySelector('#ast-model-dropdown')
if (modelInput) modelInput.value = ''
if (modelDropdown) { modelDropdown.innerHTML = ''; modelDropdown.style.display = 'none' }
// 高亮选中
overlay.querySelectorAll('.ast-preset-btn').forEach(b => b.style.opacity = '0.5')
btn.style.opacity = '1'

View File

@@ -158,8 +158,11 @@ function renderDebugInfo(el, info) {
} else if (info.version) {
html += `<table class="debug-table">
<tr><td>当前版本</td><td>${info.version.current || '(未知)'}</td></tr>
<tr><td>最新版本</td><td>${info.version.latest || '(未检测)'}</td></tr>
<tr><td>更新可用</td><td>${info.version.update_available ? `${statusIcon('warn')} 有新版本` : `${statusIcon('ok')} 已是最新`}</td></tr>
<tr><td>推荐稳定版</td><td>${info.version.recommended || '(未检测)'}</td></tr>
<tr><td>面板版本</td><td>${info.version.panel_version || '(未知)'}</td></tr>
<tr><td>最新上游</td><td>${info.version.latest || '(未检测)'}</td></tr>
<tr><td>偏离推荐版</td><td>${info.version.ahead_of_recommended ? `${statusIcon('warn')} 当前版本过高,建议回退` : info.version.is_recommended ? `${statusIcon('ok')} 已对齐` : `${statusIcon('warn')} 需要切换`}</td></tr>
<tr><td>最新上游可用</td><td>${info.version.latest_update_available ? `${statusIcon('warn')} 有更新` : `${statusIcon('ok')} 无更新`}</td></tr>
</table>`
}
html += `</div>`

View File

@@ -117,6 +117,9 @@ function renderStatCards(page, services, version, agents, config) {
const cardsEl = page.querySelector('#stat-cards')
const gw = services.find(s => s.label === 'ai.openclaw.gateway')
const runningCount = services.filter(s => s.running).length
const versionMeta = version.recommended
? `${version.ahead_of_recommended ? `当前版本高于推荐稳定版 ${version.recommended},可能不稳定` : version.is_recommended ? '稳定版 ' + version.recommended : '推荐稳定版 ' + version.recommended}${version.latest_update_available && version.latest ? ' · 最新上游 ' + version.latest : ''}`
: (version.latest_update_available && version.latest ? '最新上游: ' + version.latest : '版本信息未获取')
const defaultAgent = agents.find(a => a.id === 'main')?.name || 'main'
const modelCount = config?.models?.providers ? Object.values(config.models.providers).reduce((acc, p) => acc + (p.models?.length || 0), 0) : 0
@@ -136,7 +139,7 @@ function renderStatCards(page, services, version, agents, config) {
<span class="stat-card-label">版本 · ${version.source === 'official' ? '官方' : '汉化'}</span>
</div>
<div class="stat-card-value">${version.current || '未知'}</div>
<div class="stat-card-meta">${version.update_available ? '有新版本: ' + version.latest : '已是最新'}</div>
<div class="stat-card-meta">${versionMeta}</div>
</div>
<div class="stat-card">
<div class="stat-card-header">
@@ -439,10 +442,14 @@ function bindActions(page) {
btnUpdate.textContent = '检查中...'
try {
const info = await api.getVersionInfo()
if (info.update_available) {
toast(`发现新版本: ${info.latest}`, 'info')
if (info.ahead_of_recommended && info.recommended) {
toast(`当前本地版本 ${info.current || ''} 高于推荐稳定版 ${info.recommended},可能存在兼容风险`, 'warning')
} else if (info.update_available && info.recommended) {
toast(`发现推荐稳定版: ${info.recommended}`, 'info')
} else if (info.latest_update_available && info.latest) {
toast(`已对齐推荐稳定版,最新上游为 ${info.latest}`, 'info')
} else {
toast('已是最新版本', 'success')
toast('已对齐推荐稳定版', 'success')
}
} catch (e) {
toast('检查更新失败: ' + e, 'error')

View File

@@ -30,10 +30,6 @@ export async function render() {
</div>
<div id="version-bar"><div class="stat-card loading-placeholder" style="height:80px;margin-bottom:var(--space-lg)"></div></div>
<div id="services-list"><div class="stat-card loading-placeholder" style="height:64px"></div></div>
<div class="config-section" id="registry-section">
<div class="config-section-title">npm 源设置</div>
<div id="registry-bar"></div>
</div>
<div class="config-section" id="config-editor-section" style="display:none">
<div class="config-section-title">配置文件编辑</div>
<div class="form-hint" style="margin-bottom:var(--space-sm)">直接编辑 <code>openclaw.json</code> 主配置文件。保存前会自动创建备份,修改后可能需要重启 Gateway 生效。</div>
@@ -55,12 +51,6 @@ export async function render() {
</div>
`
// Docker 模式下隐藏 npm 源设置
if (isInDocker()) {
const regSection = page.querySelector('#registry-section')
if (regSection) regSection.style.display = 'none'
}
bindEvents(page)
loadAll(page)
return page
@@ -68,7 +58,6 @@ export async function render() {
async function loadAll(page) {
const tasks = [loadVersion(page), loadServices(page), loadBackups(page), loadConfigEditor(page)]
if (!isInDocker()) tasks.push(loadRegistry(page))
await Promise.all(tasks)
}
@@ -76,18 +65,25 @@ async function loadAll(page) {
// 后端检测到的当前安装源
let detectedSource = 'chinese'
let lastVersionInfo = null
async function loadVersion(page) {
const bar = page.querySelector('#version-bar')
try {
const info = await api.getVersionInfo()
lastVersionInfo = info
detectedSource = info.source || 'chinese'
const ver = info.current || '未知'
const hasUpdate = info.update_available
const hasRecommended = !!info.recommended
const aheadOfRecommended = !!info.current && hasRecommended && !!info.ahead_of_recommended
const driftFromRecommended = !!info.current && hasRecommended && !info.is_recommended && !aheadOfRecommended
const isChinese = detectedSource === 'chinese'
const sourceTag = isChinese ? '汉化优化版' : '官方原版'
const switchLabel = isChinese ? '切换到官方版' : '切换到汉化版'
const switchTarget = isChinese ? 'official' : 'chinese'
const policyNote = aheadOfRecommended
? `检测到当前本地版本 ${ver} 高于面板推荐稳定版 ${info.recommended},继续使用可能存在兼容或稳定性风险,建议尽快回退到推荐版。`
: '默认只建议当前面板已验证的推荐稳定版。如需尝试其它版本或最新特性,请到「关于」页手动切换版本并自行验证兼容性;若希望面板优先适配最新版,欢迎提交 issue。'
if (isInDocker()) {
bar.innerHTML = `
@@ -97,8 +93,8 @@ async function loadVersion(page) {
<span class="stat-card-label">当前版本 · <span style="color:var(--accent)">Docker 部署</span></span>
</div>
<div class="stat-card-value">${ver}</div>
<div class="stat-card-meta">${hasUpdate ? '新版本: ' + info.latest + '(请拉取新镜像更新)' : '已是最新版本'}</div>
${hasUpdate ? `<div style="margin-top:var(--space-sm)">
<div class="stat-card-meta">${info.latest_update_available ? '最新上游: ' + info.latest + '(请拉取新镜像更新)' : '已是当前镜像版本'}</div>
${info.latest_update_available ? `<div style="margin-top:var(--space-sm)">
<code style="font-size:var(--font-size-xs);background:var(--bg-tertiary);padding:4px 8px;border-radius:4px;user-select:all">docker pull ghcr.io/qingchencloud/openclaw:latest</code>
</div>` : ''}
</div>
@@ -112,11 +108,19 @@ async function loadVersion(page) {
<span class="stat-card-label">当前版本 · <span style="color:var(--accent)">${sourceTag}</span></span>
</div>
<div class="stat-card-value">${ver}</div>
<div class="stat-card-meta">${hasUpdate ? '新版本: ' + info.latest : '已是最新版本'}</div>
<div class="stat-card-meta">
${hasRecommended
? (aheadOfRecommended ? `当前版本高于推荐稳定版: ${info.recommended}` : driftFromRecommended ? `推荐稳定版: ${info.recommended}` : `已对齐推荐稳定版: ${info.recommended}`)
: '未获取到推荐稳定版'}
${info.latest_update_available && info.latest ? ` · 最新上游: ${info.latest}` : ''}
</div>
<div style="display:flex;gap:var(--space-sm);margin-top:var(--space-sm);flex-wrap:wrap">
${hasUpdate ? '<button class="btn btn-primary btn-sm" data-action="upgrade">升级到最新版</button>' : ''}
${aheadOfRecommended ? '<button class="btn btn-primary btn-sm" data-action="upgrade">回退到推荐版</button>' : driftFromRecommended ? '<button class="btn btn-primary btn-sm" data-action="upgrade">切换到推荐版</button>' : ''}
<button class="btn btn-secondary btn-sm" data-action="switch-source" data-source="${switchTarget}">${switchLabel}</button>
</div>
<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary);line-height:1.6">
${policyNote}
</div>
</div>
</div>
`
@@ -126,41 +130,6 @@ async function loadVersion(page) {
}
}
// ===== npm 源设置 =====
const REGISTRIES = [
{ label: '淘宝镜像 (推荐)', value: 'https://registry.npmmirror.com' },
{ label: 'npm 官方源', value: 'https://registry.npmjs.org' },
{ label: '华为云镜像', value: 'https://repo.huaweicloud.com/repository/npm/' },
]
async function loadRegistry(page) {
const bar = page.querySelector('#registry-bar')
try {
const current = await api.getNpmRegistry()
const isPreset = REGISTRIES.some(r => r.value === current)
bar.innerHTML = `
<div style="display:flex;align-items:center;gap:var(--space-sm);flex-wrap:wrap">
<select class="form-input" data-name="registry" style="max-width:320px">
${REGISTRIES.map(r => `<option value="${r.value}" ${r.value === current ? 'selected' : ''}>${r.label}</option>`).join('')}
<option value="custom" ${!isPreset ? 'selected' : ''}>自定义</option>
</select>
<input class="form-input" data-name="custom-registry" placeholder="https://..." value="${isPreset ? '' : escapeHtml(current)}" style="max-width:320px;${isPreset ? 'display:none' : ''}">
<button class="btn btn-primary btn-sm" data-action="save-registry">保存</button>
</div>
<div class="form-hint" style="margin-top:var(--space-xs)">升级和版本检测使用此源下载 npm 包,国内用户推荐淘宝镜像</div>
`
// 切换预设/自定义
const select = bar.querySelector('[data-name="registry"]')
const customInput = bar.querySelector('[data-name="custom-registry"]')
select.onchange = () => {
customInput.style.display = select.value === 'custom' ? '' : 'none'
}
} catch (e) {
bar.innerHTML = `<div style="color:var(--error)">加载失败: ${escapeHtml(String(e))}</div>`
}
}
// ===== 服务列表 =====
async function loadServices(page) {
@@ -313,9 +282,6 @@ function bindEvents(page) {
case 'refresh-services':
await loadServices(page)
break
case 'save-registry':
await handleSaveRegistry(btn, page)
break
}
} catch (e) {
toast(e.toString(), 'error')
@@ -539,60 +505,86 @@ async function handleSaveConfig(page, restart) {
// ===== 升级操作 =====
async function doUpgradeWithModal(source, page) {
const modal = showUpgradeModal()
let unlistenLog, unlistenProgress
async function doUpgradeWithModal(source, page, version = null) {
const modal = showUpgradeModal('升级 / 切换版本')
let unlistenLog, unlistenProgress, unlistenDone, unlistenError
setUpgrading(true)
// 清理所有监听
const cleanup = () => {
setUpgrading(false)
unlistenLog?.()
unlistenProgress?.()
unlistenDone?.()
unlistenError?.()
}
try {
// Tauri 环境下监听实时日志Web 模式跳过
if (window.__TAURI_INTERNALS__) {
try {
const { listen } = await import('@tauri-apps/api/event')
unlistenLog = await listen('upgrade-log', (e) => modal.appendLog(e.payload))
unlistenProgress = await listen('upgrade-progress', (e) => modal.setProgress(e.payload))
} catch { /* Web 模式无 Tauri event */ }
const { listen } = await import('@tauri-apps/api/event')
unlistenLog = await listen('upgrade-log', (e) => modal.appendLog(e.payload))
unlistenProgress = await listen('upgrade-progress', (e) => modal.setProgress(e.payload))
// 后台任务完成事件
unlistenDone = await listen('upgrade-done', (e) => {
cleanup()
modal.setDone(typeof e.payload === 'string' ? e.payload : '操作完成')
loadVersion(page)
})
// 后台任务失败事件
unlistenError = await listen('upgrade-error', (e) => {
cleanup()
const errStr = String(e.payload || '未知错误')
modal.appendLog(errStr)
const fullLog = modal.getLogText() + '\n' + errStr
const diagnosis = diagnoseInstallError(fullLog)
modal.setError(diagnosis.title)
if (diagnosis.hint) modal.appendLog('')
if (diagnosis.hint) modal.appendHtmlLog(`${statusIcon('info', 14)} ${diagnosis.hint}`)
if (diagnosis.command) modal.appendHtmlLog(`${icon('clipboard', 14)} ${diagnosis.command}`)
if (window.__openAIDrawerWithError) {
window.__openAIDrawerWithError({ title: diagnosis.title, error: fullLog, scene: '升级 OpenClaw', hint: diagnosis.hint })
}
})
// 发起后台任务(立即返回)
await api.upgradeOpenclaw(source, version)
modal.appendLog('后台任务已启动,请等待完成...')
} else {
// Web 模式仍然同步等待dev-api 后端没有 spawn
modal.appendLog('Web 模式:升级过程日志不可用,请等待完成...')
const msg = await api.upgradeOpenclaw(source, version)
modal.setDone(typeof msg === 'string' ? msg : (msg?.message || '升级完成'))
await loadVersion(page)
cleanup()
}
const msg = await api.upgradeOpenclaw(source)
modal.setDone(typeof msg === 'string' ? msg : (msg?.message || '升级完成'))
await loadVersion(page)
} catch (e) {
cleanup()
const errStr = String(e)
modal.appendLog(errStr)
const fullLog = modal.getLogText() + '\n' + errStr
const diagnosis = diagnoseInstallError(fullLog)
modal.setError(diagnosis.title)
if (diagnosis.hint) modal.appendLog('')
if (diagnosis.hint) modal.appendHtmlLog(`${statusIcon('info', 14)} ${diagnosis.hint}`)
if (diagnosis.command) modal.appendHtmlLog(`${icon('clipboard', 14)} ${diagnosis.command}`)
if (window.__openAIDrawerWithError) {
window.__openAIDrawerWithError({
title: diagnosis.title,
error: fullLog,
scene: '升级 OpenClaw',
hint: diagnosis.hint,
})
}
} finally {
setUpgrading(false)
unlistenLog?.()
unlistenProgress?.()
}
}
async function handleUpgrade(btn, page) {
const sourceLabel = detectedSource === 'official' ? '官方原版' : '汉化优化版'
const yes = await showConfirm(`确定要升级 OpenClaw 到最新${sourceLabel}吗?\n升级过程中 Gateway 会短暂中断。`)
const recommended = lastVersionInfo?.recommended
const yes = await showConfirm(`确定要将 OpenClaw 切换到当前面板推荐的稳定${sourceLabel}${recommended ? `${recommended}` : ''}吗?\n切换过程中 Gateway 会短暂中断。\n如果你想尝试最新版,请到「关于」页手动切换版本并自测兼容性。`)
if (!yes) return
await doUpgradeWithModal(detectedSource, page)
await doUpgradeWithModal(detectedSource, page, recommended || null)
}
async function handleSwitchSource(target, page) {
const targetLabel = target === 'official' ? '官方原版' : '汉化优化版'
const yes = await showConfirm(`确定要切换到${targetLabel}吗?\n这会安装对应的 npm 包,配置数据不受影响。`)
const recommended = target === 'official'
? (lastVersionInfo?.source === 'official' ? lastVersionInfo?.recommended : null)
: (lastVersionInfo?.source === 'chinese' ? lastVersionInfo?.recommended : null)
const yes = await showConfirm(`确定要切换到${targetLabel}${recommended ? `(推荐稳定版 ${recommended}` : '(将自动选择该来源的推荐稳定版)'}吗?\n这会安装对应的 npm 包,配置数据不受影响。\n如需尝试最新版,请到「关于」页手动切换版本。`)
if (!yes) return
await doUpgradeWithModal(target, page)
await doUpgradeWithModal(target, page, null)
}
// ===== Gateway 安装/卸载 =====
@@ -626,13 +618,3 @@ async function handleUninstallGateway(btn, page) {
btn.textContent = '卸载'
}
}
async function handleSaveRegistry(btn, page) {
const section = page.querySelector('#registry-section')
const select = section.querySelector('[data-name="registry"]')
const customInput = section.querySelector('[data-name="custom-registry"]')
const registry = select.value === 'custom' ? customInput.value.trim() : select.value
if (!registry) { toast('请输入源地址', 'error'); return }
await api.setNpmRegistry(registry)
toast('npm 源已保存', 'success')
}

246
src/pages/settings.js Normal file
View File

@@ -0,0 +1,246 @@
/**
* 面板设置页面
* 统一管理 ClawPanel 的网络代理、npm 源、模型代理等配置
*/
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
const isTauri = !!window.__TAURI_INTERNALS__
function escapeHtml(str) {
if (!str) return ''
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
const REGISTRIES = [
{ label: '淘宝镜像 (推荐)', value: 'https://registry.npmmirror.com' },
{ label: 'npm 官方源', value: 'https://registry.npmjs.org' },
{ label: '华为云镜像', value: 'https://repo.huaweicloud.com/repository/npm/' },
]
export async function render() {
const page = document.createElement('div')
page.className = 'page'
page.innerHTML = `
<div class="page-header">
<h1 class="page-title">面板设置</h1>
<p class="page-desc">管理 ClawPanel 的网络、代理和下载源配置</p>
</div>
<div class="config-section" id="proxy-section">
<div class="config-section-title">网络代理</div>
<div id="proxy-bar"><div class="stat-card loading-placeholder" style="height:48px"></div></div>
</div>
<div class="config-section" id="model-proxy-section">
<div class="config-section-title">模型请求代理</div>
<div id="model-proxy-bar"><div class="stat-card loading-placeholder" style="height:48px"></div></div>
</div>
<div class="config-section" id="registry-section">
<div class="config-section-title">npm 源设置</div>
<div id="registry-bar"><div class="stat-card loading-placeholder" style="height:48px"></div></div>
</div>
`
bindEvents(page)
loadAll(page)
return page
}
async function loadAll(page) {
const tasks = [loadProxyConfig(page), loadModelProxyConfig(page)]
tasks.push(loadRegistry(page))
await Promise.all(tasks)
}
// ===== 网络代理 =====
async function loadProxyConfig(page) {
const bar = page.querySelector('#proxy-bar')
if (!bar) return
try {
const cfg = await api.readPanelConfig()
const proxyUrl = cfg?.networkProxy?.url || ''
bar.innerHTML = `
<div style="display:flex;align-items:center;gap:var(--space-sm);flex-wrap:wrap">
<input class="form-input" data-name="proxy-url" placeholder="http://127.0.0.1:7897" value="${escapeHtml(proxyUrl)}" style="max-width:360px">
<button class="btn btn-primary btn-sm" data-action="save-proxy">保存</button>
<button class="btn btn-secondary btn-sm" data-action="test-proxy" ${proxyUrl ? '' : 'disabled'}>测试连通</button>
<button class="btn btn-secondary btn-sm" data-action="clear-proxy" ${proxyUrl ? '' : 'disabled'}>关闭代理</button>
</div>
<div id="proxy-test-result" style="margin-top:var(--space-xs);font-size:var(--font-size-xs);min-height:20px"></div>
<div class="form-hint" style="margin-top:var(--space-xs)">
设置后npm 安装/升级、版本检测、GitHub/Gitee 更新检查、ClawHub Skills 等下载类操作会走此代理。自动绕过 localhost 和内网地址。保存后新请求立即生效;如 Gateway 正在运行,建议重启一次服务。
</div>
`
} catch (e) {
bar.innerHTML = `<div style="color:var(--error)">加载失败: ${escapeHtml(String(e))}</div>`
}
}
// ===== 模型请求代理 =====
async function loadModelProxyConfig(page) {
const bar = page.querySelector('#model-proxy-bar')
if (!bar) return
try {
const cfg = await api.readPanelConfig()
const proxyUrl = cfg?.networkProxy?.url || ''
const modelProxy = !!cfg?.networkProxy?.proxyModelRequests
const hasProxy = !!proxyUrl
bar.innerHTML = `
<div style="display:flex;align-items:center;gap:var(--space-sm);flex-wrap:wrap">
<label style="display:flex;align-items:center;gap:6px;font-size:var(--font-size-sm);cursor:pointer">
<input type="checkbox" data-name="model-proxy-toggle" ${modelProxy ? 'checked' : ''} ${hasProxy ? '' : 'disabled'}>
模型测试和模型列表请求也走代理
</label>
<button class="btn btn-primary btn-sm" data-action="save-model-proxy">保存</button>
</div>
<div class="form-hint" style="margin-top:var(--space-xs)">
${hasProxy
? '默认关闭。部分用户的模型 API 地址本身就是国内中转或内网地址,走代理反而会连接失败。只有当你的模型服务商需要翻墙访问时才建议开启。'
: '请先在上方设置网络代理地址后,才能启用此选项。'
}
</div>
`
} catch (e) {
bar.innerHTML = `<div style="color:var(--error)">加载失败: ${escapeHtml(String(e))}</div>`
}
}
// ===== npm 源设置 =====
async function loadRegistry(page) {
const bar = page.querySelector('#registry-bar')
try {
const current = await api.getNpmRegistry()
const isPreset = REGISTRIES.some(r => r.value === current)
bar.innerHTML = `
<div style="display:flex;align-items:center;gap:var(--space-sm);flex-wrap:wrap">
<select class="form-input" data-name="registry" style="max-width:320px">
${REGISTRIES.map(r => `<option value="${r.value}" ${r.value === current ? 'selected' : ''}>${r.label}</option>`).join('')}
<option value="custom" ${!isPreset ? 'selected' : ''}>自定义</option>
</select>
<input class="form-input" data-name="custom-registry" placeholder="https://..." value="${isPreset ? '' : escapeHtml(current)}" style="max-width:320px;${isPreset ? 'display:none' : ''}">
<button class="btn btn-primary btn-sm" data-action="save-registry">保存</button>
</div>
<div class="form-hint" style="margin-top:var(--space-xs)">升级和版本检测使用此源下载 npm 包,国内用户推荐淘宝镜像</div>
`
const select = bar.querySelector('[data-name="registry"]')
const customInput = bar.querySelector('[data-name="custom-registry"]')
select.onchange = () => {
customInput.style.display = select.value === 'custom' ? '' : 'none'
}
} catch (e) {
bar.innerHTML = `<div style="color:var(--error)">加载失败: ${escapeHtml(String(e))}</div>`
}
}
// ===== 事件绑定 =====
function bindEvents(page) {
page.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-action]')
if (!btn) return
const action = btn.dataset.action
btn.disabled = true
try {
switch (action) {
case 'save-proxy':
await handleSaveProxy(page)
break
case 'test-proxy':
await handleTestProxy(page)
break
case 'clear-proxy':
await handleClearProxy(page)
break
case 'save-model-proxy':
await handleSaveModelProxy(page)
break
case 'save-registry':
await handleSaveRegistry(page)
break
}
} catch (e) {
toast(e.toString(), 'error')
} finally {
btn.disabled = false
}
})
}
function normalizeProxyUrl(value) {
const url = String(value || '').trim()
if (!url) return ''
if (!/^https?:\/\//i.test(url)) {
throw new Error('代理地址必须以 http:// 或 https:// 开头')
}
return url
}
async function handleTestProxy(page) {
const resultEl = page.querySelector('#proxy-test-result')
if (resultEl) resultEl.innerHTML = '<span style="color:var(--text-tertiary)">正在测试代理连通性...</span>'
try {
const r = await api.testProxy()
if (resultEl) {
resultEl.innerHTML = r.ok
? `<span style="color:var(--success)">✓ 代理连通HTTP ${r.status},耗时 ${r.elapsed_ms}ms${escapeHtml(r.target)}</span>`
: `<span style="color:var(--warning)">⚠ 代理可达但返回异常HTTP ${r.status}${r.elapsed_ms}ms</span>`
}
} catch (e) {
if (resultEl) resultEl.innerHTML = `<span style="color:var(--error)">✗ ${escapeHtml(String(e))}</span>`
}
}
async function handleSaveProxy(page) {
const input = page.querySelector('[data-name="proxy-url"]')
const proxyUrl = normalizeProxyUrl(input?.value || '')
if (!proxyUrl) {
toast('请输入代理地址,或点击"关闭代理"', 'error')
return
}
const cfg = await api.readPanelConfig()
if (!cfg.networkProxy || typeof cfg.networkProxy !== 'object') {
cfg.networkProxy = {}
}
cfg.networkProxy.url = proxyUrl
await api.writePanelConfig(cfg)
toast('网络代理已保存;如 Gateway 正在运行,建议重启服务', 'success')
await loadProxyConfig(page)
await loadModelProxyConfig(page)
}
async function handleClearProxy(page) {
const cfg = await api.readPanelConfig()
delete cfg.networkProxy
await api.writePanelConfig(cfg)
toast('网络代理已关闭', 'success')
await loadProxyConfig(page)
await loadModelProxyConfig(page)
}
async function handleSaveModelProxy(page) {
const toggle = page.querySelector('[data-name="model-proxy-toggle"]')
const checked = toggle?.checked || false
const cfg = await api.readPanelConfig()
if (!cfg.networkProxy || typeof cfg.networkProxy !== 'object') {
cfg.networkProxy = {}
}
cfg.networkProxy.proxyModelRequests = checked
await api.writePanelConfig(cfg)
toast(checked ? '模型请求将走代理' : '模型请求已关闭代理', 'success')
}
async function handleSaveRegistry(page) {
const select = page.querySelector('[data-name="registry"]')
const customInput = page.querySelector('[data-name="custom-registry"]')
const registry = select.value === 'custom' ? customInput.value.trim() : select.value
if (!registry) { toast('请输入源地址', 'error'); return }
await api.setNpmRegistry(registry)
toast('npm 源已保存', 'success')
}

View File

@@ -48,13 +48,14 @@ async function runDetect(page) {
<div class="stat-card loading-placeholder" style="height:48px;margin-top:8px"></div>
`
// 清除缓存,确保拿到最新检测结果
invalidate('check_node', 'check_git', 'get_services_status', 'check_installation')
invalidate('get_version_info', 'check_node', 'check_git', 'get_services_status', 'check_installation')
// 并行检测 Node.js、Git、OpenClaw CLI、配置文件
const [nodeRes, gitRes, clawRes, configRes] = await Promise.allSettled([
const [nodeRes, gitRes, clawRes, configRes, versionRes] = await Promise.allSettled([
api.checkNode(),
api.checkGit(),
api.getServicesStatus(),
api.checkInstallation(),
api.getVersionInfo(),
])
const node = nodeRes.status === 'fulfilled' ? nodeRes.value : { installed: false }
@@ -63,6 +64,7 @@ async function runDetect(page) {
&& clawRes.value?.length > 0
&& clawRes.value[0]?.cli_installed !== false
let config = configRes.status === 'fulfilled' ? configRes.value : { installed: false }
const version = versionRes.status === 'fulfilled' ? versionRes.value : null
// CLI 已装但配置缺失 → 自动创建默认配置
if (cliOk && !config.installed) {
@@ -81,7 +83,7 @@ async function runDetect(page) {
api.configureGitHttps().catch(() => {})
}
renderSteps(page, { node, git, cliOk, config })
renderSteps(page, { node, git, cliOk, config, version })
}
function stepIcon(ok) {
@@ -89,7 +91,7 @@ function stepIcon(ok) {
return `<span style="color:${color};font-weight:700;width:18px;display:inline-block">${ok ? '✓' : '✗'}</span>`
}
function renderSteps(page, { node, git, cliOk, config }) {
function renderSteps(page, { node, git, cliOk, config, version }) {
const stepsEl = page.querySelector('#setup-steps')
const nodeOk = node.installed
const gitOk = git?.installed || false
@@ -163,7 +165,12 @@ function renderSteps(page, { node, git, cliOk, config }) {
${stepIcon(cliOk)} OpenClaw CLI
</div>
${cliOk
? `<p style="color:var(--success);font-size:var(--font-size-sm)">CLI 可用</p>`
? `<p style="color:var(--success);font-size:var(--font-size-sm)">CLI 可用</p>
${version?.ahead_of_recommended && version?.recommended
? `<div style="margin-top:8px;padding:8px 12px;background:var(--bg-tertiary);border-radius:var(--radius-sm);font-size:var(--font-size-xs);color:var(--warning,#f59e0b);line-height:1.6">
检测到当前本地 OpenClaw ${version.current || ''} 高于当前面板推荐稳定版 ${version.recommended},可能存在兼容或稳定性风险。建议稍后到「关于」页回退到推荐版。
</div>`
: ''}`
: renderInstallSection()
}
</div>
@@ -297,7 +304,10 @@ function renderInstallSection() {
return `
<p style="color:var(--text-secondary);font-size:var(--font-size-sm);margin-bottom:var(--space-sm)">
选择版本后点击安装,将自动执行 npm 全局安装
点击安装,将默认安装当前 ClawPanel 版本绑定的推荐稳定版;如需升降级,可稍后到「关于」页面切换版本
</p>
<p style="color:var(--text-tertiary);font-size:var(--font-size-xs);line-height:1.6;margin:-4px 0 var(--space-sm)">
如果你是为了体验最新版功能,建议先安装推荐稳定版再手动切换;若希望面板优先适配最新版,欢迎提交 issue。
</p>
<div style="display:flex;gap:var(--space-sm);margin-bottom:var(--space-sm)">
<label class="setup-source-option" style="flex:1;cursor:pointer">
@@ -499,90 +509,116 @@ function bindEvents(page, nodeOk, detectState) {
installBtn.addEventListener('click', async () => {
const source = page.querySelector('input[name="install-source"]:checked')?.value || 'chinese'
const registry = page.querySelector('#registry-select')?.value
const modal = showUpgradeModal()
const modal = showUpgradeModal('安装 OpenClaw')
let unlistenLog, unlistenProgress
setUpgrading(true)
try {
if (window.__TAURI_INTERNALS__) {
try {
const { listen } = await import('@tauri-apps/api/event')
unlistenLog = await listen('upgrade-log', (e) => modal.appendLog(e.payload))
unlistenProgress = await listen('upgrade-progress', (e) => modal.setProgress(e.payload))
} catch { /* Web 模式无 Tauri event */ }
} else {
modal.appendLog('Web 模式:安装日志不可用,请等待完成...')
}
// 先设置镜像源
if (registry) {
modal.appendLog(`设置 npm 镜像源: ${registry}`)
try { await api.setNpmRegistry(registry) } catch {}
}
const msg = await api.upgradeOpenclaw(source)
modal.setDone(msg)
// 安装成功后自动安装 Gateway
modal.appendLog('正在安装 Gateway 服务...')
try {
await api.installGateway()
modal.appendHtmlLog(`${statusIcon('ok', 14)} Gateway 服务已安装`)
} catch (e) {
modal.appendHtmlLog(`${statusIcon('warn', 14)} Gateway 安装失败: ${e}`)
}
// 确保 openclaw.json 有关键默认值,否则 Gateway 启动不了或功能受限
try {
const config = await api.readOpenclawConfig()
if (config) {
let patched = false
if (!config.gateway) config.gateway = {}
if (!config.gateway.mode) {
config.gateway.mode = 'local'
patched = true
modal.appendHtmlLog(`${statusIcon('ok', 14)} 已设置 Gateway 运行模式为 local`)
}
if (!config.tools || config.tools.profile !== 'full') {
config.tools = { profile: 'full', sessions: { visibility: 'all' }, ...(config.tools || {}) }
config.tools.profile = 'full'
if (!config.tools.sessions) config.tools.sessions = {}
config.tools.sessions.visibility = 'all'
patched = true
modal.appendHtmlLog(`${statusIcon('ok', 14)} 已开启 Agent 工具全部权限`)
}
if (patched) await api.writeOpenclawConfig(config)
}
} catch (e) {
modal.appendHtmlLog(`${statusIcon('warn', 14)} 自动配置失败: ${e}`)
}
toast('OpenClaw 安装成功', 'success')
setTimeout(() => window.location.reload(), 1500)
} catch (e) {
const errStr = String(e)
modal.appendLog(errStr)
// 等待 Tauri 事件队列中残留的 npm 日志行被 JS 处理完毕,
// 确保 getLogText() 包含完整输出(含 exit code / ENOENT 等关键行)
await new Promise(r => setTimeout(r, 150))
const fullLog = modal.getLogText() + '\n' + errStr
const diagnosis = diagnoseInstallError(fullLog)
modal.setError(diagnosis.title)
if (diagnosis.hint) modal.appendLog('')
if (diagnosis.hint) modal.appendHtmlLog(`${statusIcon('info', 14)} ${diagnosis.hint}`)
if (diagnosis.command) modal.appendHtmlLog(`${icon('clipboard', 14)} ${diagnosis.command}`)
if (window.__openAIDrawerWithError) {
window.__openAIDrawerWithError({
title: diagnosis.title,
error: fullLog,
scene: '初始安装 OpenClaw',
hint: diagnosis.hint,
})
}
} finally {
const cleanup = () => {
setUpgrading(false)
unlistenLog?.()
unlistenProgress?.()
unlistenDone?.()
unlistenError?.()
}
let unlistenDone, unlistenError
try {
if (window.__TAURI_INTERNALS__) {
const { listen } = await import('@tauri-apps/api/event')
unlistenLog = await listen('upgrade-log', (e) => modal.appendLog(e.payload))
unlistenProgress = await listen('upgrade-progress', (e) => modal.setProgress(e.payload))
// 后台任务完成:继续安装 Gateway + 自动配置
unlistenDone = await listen('upgrade-done', async (e) => {
cleanup()
modal.setDone(typeof e.payload === 'string' ? e.payload : '安装完成')
// 安装成功后自动安装 Gateway
modal.appendLog('正在安装 Gateway 服务...')
try {
await api.installGateway()
modal.appendHtmlLog(`${statusIcon('ok', 14)} Gateway 服务已安装`)
} catch (ge) {
modal.appendHtmlLog(`${statusIcon('warn', 14)} Gateway 安装失败: ${ge}`)
}
// 确保 openclaw.json 有关键默认值
try {
const config = await api.readOpenclawConfig()
if (config) {
let patched = false
if (!config.gateway) config.gateway = {}
if (!config.gateway.mode) {
config.gateway.mode = 'local'
patched = true
modal.appendHtmlLog(`${statusIcon('ok', 14)} 已设置 Gateway 运行模式为 local`)
}
if (!config.tools || config.tools.profile !== 'full') {
config.tools = { profile: 'full', sessions: { visibility: 'all' }, ...(config.tools || {}) }
config.tools.profile = 'full'
if (!config.tools.sessions) config.tools.sessions = {}
config.tools.sessions.visibility = 'all'
patched = true
modal.appendHtmlLog(`${statusIcon('ok', 14)} 已开启 Agent 工具全部权限`)
}
if (patched) await api.writeOpenclawConfig(config)
}
} catch (ce) {
modal.appendHtmlLog(`${statusIcon('warn', 14)} 自动配置失败: ${ce}`)
}
toast('OpenClaw 安装成功', 'success')
setTimeout(() => window.location.reload(), 1500)
})
// 后台任务失败
unlistenError = await listen('upgrade-error', async (e) => {
cleanup()
const errStr = String(e.payload || '未知错误')
modal.appendLog(errStr)
await new Promise(r => setTimeout(r, 150))
const fullLog = modal.getLogText() + '\n' + errStr
const diagnosis = diagnoseInstallError(fullLog)
modal.setError(diagnosis.title)
if (diagnosis.hint) modal.appendLog('')
if (diagnosis.hint) modal.appendHtmlLog(`${statusIcon('info', 14)} ${diagnosis.hint}`)
if (diagnosis.command) modal.appendHtmlLog(`${icon('clipboard', 14)} ${diagnosis.command}`)
if (window.__openAIDrawerWithError) {
window.__openAIDrawerWithError({ title: diagnosis.title, error: fullLog, scene: '初始安装 OpenClaw', hint: diagnosis.hint })
}
})
// 先设置镜像源
if (registry) {
modal.appendLog(`设置 npm 镜像源: ${registry}`)
try { await api.setNpmRegistry(registry) } catch {}
}
// 发起后台任务(立即返回)
await api.upgradeOpenclaw(source)
modal.appendLog('后台安装任务已启动,请等待完成...')
} else {
// Web 模式:同步等待
modal.appendLog('Web 模式:安装日志不可用,请等待完成...')
if (registry) {
modal.appendLog(`设置 npm 镜像源: ${registry}`)
try { await api.setNpmRegistry(registry) } catch {}
}
const msg = await api.upgradeOpenclaw(source)
modal.setDone(msg)
toast('OpenClaw 安装成功', 'success')
setTimeout(() => window.location.reload(), 1500)
cleanup()
}
} catch (e) {
cleanup()
const errStr = String(e)
modal.appendLog(errStr)
const fullLog = modal.getLogText() + '\n' + errStr
const diagnosis = diagnoseInstallError(fullLog)
modal.setError(diagnosis.title)
}
})
}

View File

@@ -154,3 +154,25 @@
.ast-error-toggle:hover {
color: var(--text-primary);
}
/* FAB 首次拖动提示 */
.ai-fab-hint {
position: absolute;
top: -32px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.75);
color: #fff;
font-size: 11px;
padding: 4px 10px;
border-radius: 6px;
white-space: nowrap;
pointer-events: none;
animation: fab-hint-fade 4s ease forwards;
}
@keyframes fab-hint-fade {
0% { opacity: 0; transform: translateX(-50%) translateY(4px); }
15% { opacity: 1; transform: translateX(-50%) translateY(0); }
75% { opacity: 1; }
100% { opacity: 0; }
}

View File

@@ -725,6 +725,51 @@
word-break: break-all;
}
/* 全局任务状态栏:关闭弹窗后显示在顶部 */
.upgrade-task-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 9999;
display: flex;
align-items: center;
gap: 12px;
padding: 8px 20px;
background: var(--accent);
color: #fff;
font-size: 13px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
animation: task-bar-slide-in 0.3s ease;
}
.upgrade-task-bar-text {
flex: 1;
}
.upgrade-task-bar .btn {
color: #fff;
border-color: rgba(255,255,255,0.3);
background: rgba(255,255,255,0.15);
font-size: 12px;
padding: 2px 10px;
}
.upgrade-task-bar .btn:hover {
background: rgba(255,255,255,0.25);
}
.upgrade-task-bar .btn-ghost {
background: none;
border: none;
font-size: 16px;
padding: 0 4px;
opacity: 0.7;
}
.upgrade-task-bar .btn-ghost:hover {
opacity: 1;
}
@keyframes task-bar-slide-in {
from { transform: translateY(-100%); }
to { transform: translateY(0); }
}
/* Gateway 配置页 — 选项卡片 */
.gw-option-cards {
display: grid;