mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-31 05:10:14 +08:00
面向小白用户的产品定位重塑,从七大 UX 痛点逐一改造:
## U1 错误友好度(59 处改造)
- 新工具 src/lib/humanize-error.js:自动把后端原始错误(fetch failed、ENETUNREACH、ENOENT 等)
映射成「主行 + hint 行动建议 + 折叠技术详情」三段式结构化对象
- toast 组件升级支持 { message, hint, raw } 结构化入参,向后完全兼容
- 14 个 page 文件中所有 toast(t('xxx.failed') + ': ' + e, 'error') 替换为 toast(humanizeError(e, t(...)), 'error')
- common.js 加 error.* / errorHint.* 共 13 个新 i18n 键(11 语言):
网络/Gateway 未启动/命令缺失/权限/超时/限流/未找到/鉴权/服务繁忙/通用
## U2 致命操作强确认(14 处改造)
- showConfirm 升级支持结构化对象 { message, impact[], title, confirmText, cancelText, variant }
- 加 .modal-impact-list 红边样式(让小白看清楚删了会丢什么)
- 14 处致命操作改造,每处显示影响列表 + 红色「删除/移除/重置」按钮 + 灰色「保留」取消:
· agents.js 删除 Agent(动态显示 N 个绑定影响)
· channels.js 移除平台(动态算 N 个 binding)+ 移除 Agent binding
· memory.js 删除记忆文件
· services.js 卸载 Gateway(3 段影响)+ 删除备份
· models.js 批量删模型
· chat.js 删除会话 + 重置会话
· dreaming.js 重置梦境日记 + 清空 grounded 短期记忆
· agent-detail.js 解除渠道绑定
· cron.js 删除任务(OpenClaw + Hermes 两端)
- skills.js 原生 confirm() 改 showConfirm
- hermes-cron.js 原生 confirm() 改 showConfirm,顺手修末尾多余 `}` 的 syntax 残留
## U3-C 空状态 emoji+CTA(5 页面)
- 通用 .empty-state 组件(大 emoji + 标题 + 副本 + CTA 按钮 + 紧凑变体)
- agents.js: 🤖 + 「+ 新建 Agent」CTA
- memory.js: 🧠 + 「+ 新建记忆文件」CTA(紧凑版)
- cron.js: ⏰ + 「+ 新建任务」CTA
- skills.js: 🛠️ + 「技能商店」CTA(点击切 Tab)
- channels.js: 💬 + 紧凑提示
- CTA 巧妙复用页面顶部已有按钮的 click,零重复逻辑
## U3-B Dashboard 新手任务卡片
- 蓝紫渐变卡片,4 步任务自动检测:启动 Gateway / 添加模型 / 创建 Agent / 第一次聊天
- 已完成:✓ 徽章 + 删除线 + 60% 透明
- 未完成:编号徽章 + 蓝色 CTA 按钮跳对应页面
- 全部完成 → 庆祝条「🎉 全部搞定!」+ 关闭按钮
- localStorage 标记,用户主动关闭后永久隐藏
- 14 个新 i18n 键,文案小白化(Gateway 是「发动机」/ Agent 是「分身」/ 模型给 AI 装「大脑」)
## U3-A 术语表页(/glossary)
- 25 个核心术语 × 4 大分类(核心 8 / 模型 6 / 接入 5 / 进阶 6)
- 搜索框实时过滤 + Tab 切换分类 + 卡片网格布局
- 每条术语:「比喻 + 一句话」描述(避免循环引用)+ 「打开页面 →」CTA 直达配置
- 3 语言(zh-CN / en / zh-TW)完整翻译,其他 8 语言 fallback
- 双引擎(OpenClaw + Hermes)共用路由
- dashboard quick-actions 加「📖 面板术语」入口
## U3-D 术语 ⓘ tooltip
- 通用 src/lib/term-tooltip.js helper:termHelpHtml(id) + attachTermTooltips(root)
- 8 个高频术语精简表(OAuth / Webhook / Bot Token / API Key / Token / Context Window / Binding / Scope)
- channels.js 字段 label 智能匹配关键词自动追加 ⓘ(覆盖 8 个渠道全部敏感字段)
- models.js 添加/编辑 provider 的 API Key label 也加 ⓘ
- 点 ⓘ → 弹小型 modal 含解释 + 「打开术语表 →」CTA
- attachTermTooltips 内部去重,可安全多次调用
## 累计交付
- 4 个新文件(humanize-error.js / term-tooltip.js / glossary.js page / glossary.js i18n)
- 6 个升级文件(toast / modal / components.css / dashboard / channels / models)
- 14 个 page 错误 toast 友好化(59 处)
- 14 处致命操作强确认
- 5 处空状态升级 + Dashboard 新手卡片 + 术语表 + ⓘ tooltip
- 109 个新 i18n 键(11 语言)
- Build 全程通过
400 lines
15 KiB
JavaScript
400 lines
15 KiB
JavaScript
/**
|
|
* 扩展工具页面
|
|
* cftunnel 隧道管理 + ClawApp 状态
|
|
*/
|
|
import { api } from '../lib/tauri-api.js'
|
|
import { toast } from '../components/toast.js'
|
|
import { humanizeError } from '../lib/humanize-error.js'
|
|
import { statusIcon } from '../lib/icons.js'
|
|
import { t } from '../lib/i18n.js'
|
|
|
|
// HTML 转义,防止 XSS
|
|
function escapeHtml(str) {
|
|
if (!str) return ''
|
|
return String(str)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
}
|
|
|
|
export async function render() {
|
|
const page = document.createElement('div')
|
|
page.className = 'page'
|
|
|
|
page.innerHTML = `
|
|
<div class="page-header">
|
|
<h1 class="page-title">${t('ext.title')}</h1>
|
|
<p class="page-desc">${t('ext.desc')}</p>
|
|
</div>
|
|
<div id="cftunnel-card" class="config-section">
|
|
<div class="config-section-title">${t('ext.cftunnelTitle')}</div>
|
|
<div class="form-hint" style="margin-bottom:var(--space-md)">${t('ext.cftunnelDesc')}</div>
|
|
<div id="cftunnel-content"><div class="stat-card loading-placeholder" style="height:64px"></div></div>
|
|
</div>
|
|
<div id="clawapp-card" class="config-section">
|
|
<div class="config-section-title">${t('ext.clawappTitle')}</div>
|
|
<div class="form-hint" style="margin-bottom:var(--space-md)">${t('ext.clawappDesc')}</div>
|
|
<div id="clawapp-content"><div class="stat-card loading-placeholder" style="height:64px"></div></div>
|
|
</div>
|
|
`
|
|
|
|
bindEvents(page)
|
|
loadAll(page)
|
|
return page
|
|
}
|
|
|
|
async function loadAll(page) {
|
|
await Promise.all([
|
|
loadCftunnel(page),
|
|
loadClawapp(page),
|
|
])
|
|
}
|
|
|
|
// ===== cftunnel =====
|
|
|
|
async function loadCftunnel(page) {
|
|
const el = page.querySelector('#cftunnel-content')
|
|
try {
|
|
const status = await api.getCftunnelStatus()
|
|
renderCftunnel(el, status)
|
|
} catch (e) {
|
|
el.innerHTML = `<div style="color:var(--error)">${t('common.loadFailed')}: ${e}</div>`
|
|
}
|
|
}
|
|
|
|
function renderCftunnel(el, s) {
|
|
if (!s.installed) {
|
|
el.innerHTML = `
|
|
<div style="color:var(--text-tertiary);margin-bottom:var(--space-md)">${t('ext.cftunnelNotInstalled')}</div>
|
|
<div style="display:flex;gap:var(--space-sm);align-items:center">
|
|
<button class="btn btn-primary btn-sm" data-action="install-cftunnel">${t('ext.installBtn')}</button>
|
|
<a class="btn btn-secondary btn-sm" href="https://github.com/qingchencloud/cftunnel" target="_blank" rel="noopener">${t('ext.viewDocs')}</a>
|
|
</div>
|
|
<div id="install-progress-area"></div>
|
|
`
|
|
return
|
|
}
|
|
|
|
const running = s.running
|
|
const routes = s.routes || []
|
|
|
|
el.innerHTML = `
|
|
<div class="stat-cards" style="margin-bottom:var(--space-md)">
|
|
<div class="stat-card">
|
|
<div class="stat-card-header">
|
|
<span class="stat-card-label">${t('ext.status')}</span>
|
|
<span class="status-dot ${running ? 'running' : 'stopped'}"></span>
|
|
</div>
|
|
<div class="stat-card-value">${running ? t('ext.running') : t('ext.stopped')}</div>
|
|
<div class="stat-card-meta">${s.tunnel_name || ''}${s.pid ? ' (PID: ' + s.pid + ')' : ''}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-card-header"><span class="stat-card-label">${t('ext.version')}</span></div>
|
|
<div class="stat-card-value" style="font-size:var(--font-size-md)">${s.version || t('ext.unknown')}</div>
|
|
<div class="stat-card-meta">${routes.length} ${t('ext.routes')}</div>
|
|
</div>
|
|
</div>
|
|
<div style="display:flex;gap:var(--space-sm);margin-bottom:var(--space-md)">
|
|
${running
|
|
? '<button class="btn btn-danger btn-sm" data-action="cftunnel-down">' + t('ext.stopTunnel') + '</button>'
|
|
: '<button class="btn btn-primary btn-sm" data-action="cftunnel-up">' + t('ext.startTunnel') + '</button>'
|
|
}
|
|
<button class="btn btn-secondary btn-sm" data-action="cftunnel-logs">${t('ext.viewLogs')}</button>
|
|
<button class="btn btn-secondary btn-sm" data-action="cftunnel-refresh">${t('ext.refresh')}</button>
|
|
</div>
|
|
${renderRoutes(routes)}
|
|
<div id="cftunnel-logs-area"></div>
|
|
`
|
|
}
|
|
|
|
function renderRoutes(routes) {
|
|
if (!routes.length) return '<div style="color:var(--text-tertiary);padding:var(--space-md) 0">' + t('ext.noRoutes') + '</div>'
|
|
return `
|
|
<div class="tunnel-routes">
|
|
${routes.map(r => `
|
|
<div class="tunnel-route-card">
|
|
<div class="tunnel-route-header">
|
|
<span class="tunnel-route-name">${escapeHtml(r.name)}</span>
|
|
<span class="tunnel-route-badge">
|
|
<span class="status-dot running" style="width:6px;height:6px"></span>
|
|
${t('ext.active')}
|
|
</span>
|
|
</div>
|
|
<div class="tunnel-route-domain">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--accent)">
|
|
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
|
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
|
|
</svg>
|
|
<a href="https://${escapeHtml(r.domain)}" target="_blank" rel="noopener">${escapeHtml(r.domain)}</a>
|
|
</div>
|
|
<div class="tunnel-route-service">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--text-tertiary)">
|
|
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"></rect>
|
|
<rect x="2" y="14" width="20" height="8" rx="2" ry="2"></rect>
|
|
<line x1="6" y1="6" x2="6.01" y2="6"></line>
|
|
<line x1="6" y1="18" x2="6.01" y2="18"></line>
|
|
</svg>
|
|
<span>${t('ext.localService')}:</span>
|
|
<code>${escapeHtml(r.service)}</code>
|
|
</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
`
|
|
}
|
|
|
|
// ===== ClawApp =====
|
|
|
|
async function loadClawapp(page) {
|
|
const el = page.querySelector('#clawapp-content')
|
|
try {
|
|
const status = await api.getClawappStatus()
|
|
renderClawapp(el, status)
|
|
} catch (e) {
|
|
el.innerHTML = `<div style="color:var(--error)">${t('common.loadFailed')}: ${e}</div>`
|
|
}
|
|
}
|
|
|
|
function renderClawapp(el, s) {
|
|
if (!s.installed) {
|
|
el.innerHTML = `
|
|
<div style="color:var(--text-tertiary);margin-bottom:var(--space-md)">${t('ext.clawappNotInstalled')}</div>
|
|
<div style="display:flex;gap:var(--space-sm);align-items:center">
|
|
<button class="btn btn-primary btn-sm" data-action="install-clawapp">${t('ext.installBtn')}</button>
|
|
<a class="btn btn-secondary btn-sm" href="https://github.com/qingchencloud/clawapp" target="_blank" rel="noopener">${t('ext.viewDocs')}</a>
|
|
</div>
|
|
<div id="install-clawapp-progress-area"></div>
|
|
`
|
|
return
|
|
}
|
|
|
|
const running = s.running
|
|
el.innerHTML = `
|
|
<div class="stat-cards" style="margin-bottom:var(--space-md)">
|
|
<div class="stat-card">
|
|
<div class="stat-card-header">
|
|
<span class="stat-card-label">${t('ext.status')}</span>
|
|
<span class="status-dot ${running ? 'running' : 'stopped'}"></span>
|
|
</div>
|
|
<div class="stat-card-value">${running ? t('ext.running') : t('ext.stopped')}</div>
|
|
<div class="stat-card-meta">${s.pid ? 'PID: ' + s.pid : ''}${s.port ? ' ' + t('ext.port') + ': ' + s.port : ''}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-card-header"><span class="stat-card-label">${t('ext.accessUrl')}</span></div>
|
|
<div class="stat-card-value" style="font-size:var(--font-size-sm)">${s.url || 'http://localhost:3210'}</div>
|
|
<div class="stat-card-meta">${t('ext.publicUrl')}: chat.qrj.ai</div>
|
|
</div>
|
|
</div>
|
|
<div style="display:flex;gap:var(--space-sm)">
|
|
<a class="btn btn-primary btn-sm" href="${s.url || 'http://localhost:3210'}" target="_blank" rel="noopener">${t('ext.openClawapp')}</a>
|
|
<a class="btn btn-secondary btn-sm" href="https://chat.qrj.ai" target="_blank" rel="noopener">${t('ext.openPublicUrl')}</a>
|
|
<button class="btn btn-secondary btn-sm" data-action="clawapp-refresh">${t('ext.refresh')}</button>
|
|
</div>
|
|
`
|
|
}
|
|
|
|
// ===== 事件绑定 =====
|
|
|
|
function bindEvents(page) {
|
|
page.addEventListener('click', async (e) => {
|
|
const btn = e.target.closest('[data-action]')
|
|
if (!btn) return
|
|
const action = btn.dataset.action
|
|
|
|
switch (action) {
|
|
case 'cftunnel-up':
|
|
await handleCftunnelAction(page, 'up')
|
|
break
|
|
case 'cftunnel-down':
|
|
await handleCftunnelAction(page, 'down')
|
|
break
|
|
case 'cftunnel-logs':
|
|
await handleCftunnelLogs(page)
|
|
break
|
|
case 'cftunnel-refresh':
|
|
await loadCftunnel(page)
|
|
break
|
|
case 'clawapp-refresh':
|
|
await loadClawapp(page)
|
|
break
|
|
case 'install-cftunnel':
|
|
await handleInstallCftunnel(page)
|
|
break
|
|
case 'install-clawapp':
|
|
await handleInstallClawapp(page)
|
|
break
|
|
}
|
|
})
|
|
}
|
|
|
|
async function handleCftunnelAction(page, action) {
|
|
const label = action === 'up' ? t('ext.start') : t('ext.stop')
|
|
const btn = page.querySelector(`[data-action="cftunnel-${action === 'up' ? 'up' : 'down'}"]`)
|
|
if (btn) { btn.classList.add('btn-loading'); btn.disabled = true; btn.textContent = `${label}...` }
|
|
try {
|
|
await api.cftunnelAction(action)
|
|
toast(t('ext.tunnelActionDone', { action: label }), 'success')
|
|
await loadCftunnel(page)
|
|
} catch (e) {
|
|
toast(humanizeError(e, t('ext.tunnelActionFail', { action: label })), 'error')
|
|
if (btn) { btn.classList.remove('btn-loading'); btn.disabled = false; btn.textContent = label }
|
|
}
|
|
}
|
|
|
|
async function handleCftunnelLogs(page) {
|
|
const area = page.querySelector('#cftunnel-logs-area')
|
|
if (!area) return
|
|
// 切换显示
|
|
if (area.innerHTML) {
|
|
area.innerHTML = ''
|
|
return
|
|
}
|
|
try {
|
|
const logs = await api.getCftunnelLogs(30)
|
|
area.innerHTML = `
|
|
<div style="margin-top:var(--space-md)">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--space-sm)">
|
|
<span style="font-weight:600;font-size:var(--font-size-sm)">${t('ext.recentLogs')}</span>
|
|
<button class="btn btn-secondary btn-sm" data-action="cftunnel-logs">${t('ext.collapse')}</button>
|
|
</div>
|
|
<pre class="log-viewer">${escapeHtml(logs) || t('ext.noLogs')}</pre>
|
|
</div>
|
|
`
|
|
} catch (e) {
|
|
area.innerHTML = `<div style="color:var(--error);margin-top:var(--space-sm)">${t('ext.readLogsFailed')}: ${e}</div>`
|
|
}
|
|
}
|
|
|
|
async function handleInstallCftunnel(page) {
|
|
const area = page.querySelector('#install-progress-area')
|
|
if (!area) return
|
|
|
|
// 显示进度条
|
|
area.innerHTML = `
|
|
<div style="margin-top:var(--space-lg)">
|
|
<div class="upgrade-progress-wrap">
|
|
<div class="upgrade-progress-bar">
|
|
<div class="upgrade-progress-fill" id="install-progress-fill" style="width:0%"></div>
|
|
</div>
|
|
<div class="upgrade-progress-text" id="install-progress-text">${t('ext.preparing')}</div>
|
|
</div>
|
|
<div class="upgrade-log-box" id="install-log-box"></div>
|
|
</div>
|
|
`
|
|
|
|
const progressFill = area.querySelector('#install-progress-fill')
|
|
const progressText = area.querySelector('#install-progress-text')
|
|
const logBox = area.querySelector('#install-log-box')
|
|
|
|
let unlistenLog, unlistenProgress
|
|
try {
|
|
if (window.__TAURI_INTERNALS__) {
|
|
try {
|
|
const { listen } = await import('@tauri-apps/api/event')
|
|
unlistenLog = await listen('install-log', (e) => {
|
|
logBox.textContent += e.payload + '\n'
|
|
logBox.scrollTop = logBox.scrollHeight
|
|
})
|
|
unlistenProgress = await listen('install-progress', (e) => {
|
|
const progress = e.payload
|
|
progressFill.style.width = progress + '%'
|
|
progressText.textContent = t('ext.installing') + ` ${progress}%`
|
|
})
|
|
} catch { /* Web mode no Tauri event */ }
|
|
} else {
|
|
logBox.textContent += t('ext.webModeNoLogs') + '\n'
|
|
}
|
|
|
|
await api.installCftunnel()
|
|
|
|
progressFill.classList.add('done')
|
|
progressText.innerHTML = `${statusIcon('ok', 14)} ${t('ext.installDone')}`
|
|
toast(t('ext.installSuccess', { name: 'cftunnel' }), 'success')
|
|
|
|
// 3 秒后刷新状态
|
|
setTimeout(() => loadCftunnel(page), 3000)
|
|
} catch (e) {
|
|
progressFill.classList.add('error')
|
|
progressText.innerHTML = `${statusIcon('err', 14)} ${t('ext.installFailed')}`
|
|
logBox.textContent += '\n' + t('ext.error') + ': ' + e
|
|
toast(humanizeError(e, t('ext.installFailed')), 'error')
|
|
if (window.__openAIDrawerWithError) {
|
|
window.__openAIDrawerWithError({
|
|
title: t('ext.installFailedTitle', { name: 'cftunnel' }),
|
|
error: logBox.textContent,
|
|
scene: t('ext.installScene', { name: 'cftunnel' }),
|
|
hint: String(e),
|
|
})
|
|
}
|
|
} finally {
|
|
unlistenLog?.()
|
|
unlistenProgress?.()
|
|
}
|
|
}
|
|
|
|
async function handleInstallClawapp(page) {
|
|
const area = page.querySelector('#install-clawapp-progress-area')
|
|
if (!area) return
|
|
|
|
area.innerHTML = `
|
|
<div style="margin-top:var(--space-lg)">
|
|
<div class="upgrade-progress-wrap">
|
|
<div class="upgrade-progress-bar">
|
|
<div class="upgrade-progress-fill" id="install-clawapp-progress-fill" style="width:0%"></div>
|
|
</div>
|
|
<div class="upgrade-progress-text" id="install-clawapp-progress-text">${t('ext.preparing')}</div>
|
|
</div>
|
|
<div class="upgrade-log-box" id="install-clawapp-log-box"></div>
|
|
</div>
|
|
`
|
|
|
|
const progressFill = area.querySelector('#install-clawapp-progress-fill')
|
|
const progressText = area.querySelector('#install-clawapp-progress-text')
|
|
const logBox = area.querySelector('#install-clawapp-log-box')
|
|
|
|
let unlistenLog, unlistenProgress
|
|
try {
|
|
if (window.__TAURI_INTERNALS__) {
|
|
try {
|
|
const { listen } = await import('@tauri-apps/api/event')
|
|
unlistenLog = await listen('install-log', (e) => {
|
|
logBox.textContent += e.payload + '\n'
|
|
logBox.scrollTop = logBox.scrollHeight
|
|
})
|
|
unlistenProgress = await listen('install-progress', (e) => {
|
|
const progress = e.payload
|
|
progressFill.style.width = progress + '%'
|
|
progressText.textContent = t('ext.installing') + ` ${progress}%`
|
|
})
|
|
} catch { /* Web mode no Tauri event */ }
|
|
} else {
|
|
logBox.textContent += t('ext.webModeNoLogs') + '\n'
|
|
}
|
|
|
|
await api.installClawapp()
|
|
|
|
progressFill.classList.add('done')
|
|
progressText.innerHTML = `${statusIcon('ok', 14)} ${t('ext.installDone')}`
|
|
toast(t('ext.installSuccess', { name: 'ClawApp' }), 'success')
|
|
|
|
setTimeout(() => loadClawapp(page), 3000)
|
|
} catch (e) {
|
|
progressFill.classList.add('error')
|
|
progressText.innerHTML = `${statusIcon('err', 14)} ${t('ext.installFailed')}`
|
|
logBox.textContent += '\n' + t('ext.error') + ': ' + e
|
|
toast(humanizeError(e, t('ext.installFailed')), 'error')
|
|
if (window.__openAIDrawerWithError) {
|
|
window.__openAIDrawerWithError({
|
|
title: t('ext.installFailedTitle', { name: 'ClawApp' }),
|
|
error: logBox.textContent,
|
|
scene: t('ext.installScene', { name: 'ClawApp' }),
|
|
hint: String(e),
|
|
})
|
|
}
|
|
} finally {
|
|
unlistenLog?.()
|
|
unlistenProgress?.()
|
|
}
|
|
}
|