mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-29 20:30:00 +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 全程通过
347 lines
12 KiB
JavaScript
347 lines
12 KiB
JavaScript
/**
|
||
* Modal 弹窗组件
|
||
*/
|
||
|
||
import { t } from '../lib/i18n.js'
|
||
|
||
// 转义 HTML 属性值,防止双引号等字符破坏 HTML 结构
|
||
function escapeAttr(str) {
|
||
if (!str) return ''
|
||
return String(str)
|
||
.replace(/&/g, '&')
|
||
.replace(/"/g, '"')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
}
|
||
|
||
/**
|
||
* 自定义确认弹窗,替代原生 confirm()
|
||
* Tauri WebView 不支持原生 confirm/alert,必须用自定义弹窗
|
||
*
|
||
* 入参 message 支持两种:
|
||
* 1) string —— 旧用法(向后兼容)
|
||
* 2) { message, impact?, ...options } —— 结构化致命操作确认:
|
||
* - message: 主问题("删除 Agent 'main'?")
|
||
* - impact: 影响列表 string[],渲染成 ul(让小白看清楚删了会丢什么)
|
||
* - title/confirmText/cancelText/variant: 同 options 同名字段
|
||
*
|
||
* @param {string|object} message 确认消息或结构化对象
|
||
* @param {object} [options] 旧版 options 字段(仍兼容)
|
||
* @returns {Promise<boolean>} 用户选择确认返回 true,取消返回 false
|
||
*/
|
||
export function showConfirm(message, options = {}) {
|
||
// 结构化入参:把对象字段合并到 options,message 取其 .message 字段
|
||
let actualMessage = message
|
||
let impactList = null
|
||
if (message && typeof message === 'object') {
|
||
actualMessage = message.message ?? ''
|
||
impactList = Array.isArray(message.impact) ? message.impact.filter(Boolean) : null
|
||
// 对象字段优先于 options 同名字段
|
||
options = {
|
||
title: message.title ?? options.title,
|
||
confirmText: message.confirmText ?? options.confirmText,
|
||
cancelText: message.cancelText ?? options.cancelText,
|
||
variant: message.variant ?? options.variant,
|
||
}
|
||
}
|
||
|
||
const title = options.title || t('common.confirmAction')
|
||
const confirmText = options.confirmText || t('common.confirm')
|
||
const cancelText = options.cancelText || t('common.cancel')
|
||
const variant = options.variant === 'primary' ? 'btn-primary' : 'btn-danger'
|
||
|
||
const impactHtml = impactList && impactList.length
|
||
? `<ul class="modal-impact-list">${impactList.map(i => `<li>${escapeAttr(i)}</li>`).join('')}</ul>`
|
||
: ''
|
||
|
||
return new Promise((resolve) => {
|
||
const overlay = document.createElement('div')
|
||
overlay.className = 'modal-overlay'
|
||
overlay.innerHTML = `
|
||
<div class="modal" style="max-width:420px">
|
||
<div class="modal-title">${escapeAttr(title)}</div>
|
||
<div class="modal-body" style="font-size:var(--font-size-sm);color:var(--text-secondary);white-space:pre-wrap;line-height:1.6">${escapeAttr(actualMessage)}</div>
|
||
${impactHtml}
|
||
<div class="modal-actions">
|
||
<button class="btn btn-secondary btn-sm" data-action="cancel">${escapeAttr(cancelText)}</button>
|
||
<button class="btn ${variant} btn-sm" data-action="confirm">${escapeAttr(confirmText)}</button>
|
||
</div>
|
||
</div>
|
||
`
|
||
document.body.appendChild(overlay)
|
||
|
||
const close = (result) => {
|
||
overlay.remove()
|
||
resolve(result)
|
||
}
|
||
|
||
overlay.addEventListener('click', (e) => {
|
||
if (e.target === overlay) close(false)
|
||
})
|
||
overlay.querySelector('[data-action="cancel"]').onclick = () => close(false)
|
||
overlay.querySelector('[data-action="confirm"]').onclick = () => close(true)
|
||
overlay.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') { e.preventDefault(); close(true) }
|
||
else if (e.key === 'Escape') close(false)
|
||
})
|
||
// 聚焦取消按钮(致命操作默认不要默认聚焦确认)
|
||
overlay.querySelector('[data-action="cancel"]').focus()
|
||
})
|
||
}
|
||
|
||
export function showModal({ title, fields, onConfirm }) {
|
||
const overlay = document.createElement('div')
|
||
overlay.className = 'modal-overlay'
|
||
|
||
const fieldHtml = fields.map(f => {
|
||
if (f.type === 'checkbox') {
|
||
return `
|
||
<div class="form-group">
|
||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||
<input type="checkbox" data-name="${f.name}" ${f.value ? 'checked' : ''}>
|
||
<span class="form-label" style="margin:0">${f.label}</span>
|
||
</label>
|
||
${f.hint ? `<div class="form-hint">${f.hint}</div>` : ''}
|
||
</div>`
|
||
}
|
||
if (f.type === 'select') {
|
||
return `
|
||
<div class="form-group">
|
||
<label class="form-label">${f.label}</label>
|
||
<select class="form-input" data-name="${f.name}">
|
||
${f.options.map(o => `<option value="${o.value}" ${o.value === f.value ? 'selected' : ''}>${o.label}</option>`).join('')}
|
||
</select>
|
||
${f.hint ? `<div class="form-hint">${f.hint}</div>` : ''}
|
||
</div>`
|
||
}
|
||
return `
|
||
<div class="form-group">
|
||
<label class="form-label">${f.label}</label>
|
||
<input class="form-input" data-name="${f.name}" value="${escapeAttr(f.value)}" placeholder="${escapeAttr(f.placeholder)}"${f.readonly ? ' readonly style="opacity:0.6;cursor:not-allowed"' : ''}>
|
||
${f.hint ? `<div class="form-hint">${f.hint}</div>` : ''}
|
||
</div>`
|
||
}).join('')
|
||
|
||
overlay.innerHTML = `
|
||
<div class="modal">
|
||
<div class="modal-title">${title}</div>
|
||
${fieldHtml}
|
||
<div class="modal-actions">
|
||
<button class="btn btn-secondary btn-sm" data-action="cancel">${t('common.cancel')}</button>
|
||
<button class="btn btn-primary btn-sm" data-action="confirm">${t('common.confirm')}</button>
|
||
</div>
|
||
</div>
|
||
`
|
||
|
||
document.body.appendChild(overlay)
|
||
|
||
// 点击遮罩关闭
|
||
overlay.addEventListener('click', (e) => {
|
||
if (e.target === overlay) overlay.remove()
|
||
})
|
||
|
||
overlay.querySelector('[data-action="cancel"]').onclick = () => overlay.remove()
|
||
|
||
overlay.querySelector('[data-action="confirm"]').onclick = () => {
|
||
const result = {}
|
||
overlay.querySelectorAll('[data-name]').forEach(el => {
|
||
if (el.type === 'checkbox') {
|
||
result[el.dataset.name] = el.checked
|
||
} else {
|
||
result[el.dataset.name] = el.value
|
||
}
|
||
})
|
||
// 先调用回调,再移除 overlay,避免嵌套对话框时序问题
|
||
const callback = onConfirm
|
||
setTimeout(() => overlay.remove(), 0)
|
||
callback(result)
|
||
}
|
||
|
||
// 键盘事件:Enter 确认,Escape 关闭
|
||
const handleKey = (e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault()
|
||
overlay.querySelector('[data-action="confirm"]')?.click()
|
||
} else if (e.key === 'Escape') {
|
||
overlay.remove()
|
||
}
|
||
}
|
||
overlay.addEventListener('keydown', handleKey)
|
||
|
||
// 自动聚焦第一个输入框
|
||
const firstInput = overlay.querySelector('input, select')
|
||
if (firstInput) firstInput.focus()
|
||
}
|
||
|
||
/**
|
||
* 通用内容弹窗 — 支持自定义 HTML 和按钮
|
||
* @param {{ title, content, buttons, width }} opts
|
||
* buttons: [{ label, className, id }]
|
||
* @returns {HTMLElement} overlay 元素(带 .close() 方法)
|
||
*/
|
||
export function showContentModal({ title, content, buttons = [], width = 480 }) {
|
||
const overlay = document.createElement('div')
|
||
overlay.className = 'modal-overlay'
|
||
|
||
const btnsHtml = buttons.map(b =>
|
||
`<button class="${b.className || 'btn btn-primary btn-sm'}" id="${b.id || ''}">${b.label}</button>`
|
||
).join('')
|
||
|
||
overlay.innerHTML = `
|
||
<div class="modal" style="max-width:${width}px">
|
||
<div class="modal-title">${title}</div>
|
||
<div class="modal-content-body">${content}</div>
|
||
<div class="modal-actions">
|
||
<button class="btn btn-secondary btn-sm" data-action="cancel">${t('common.cancel')}</button>
|
||
${btnsHtml}
|
||
</div>
|
||
</div>
|
||
`
|
||
|
||
document.body.appendChild(overlay)
|
||
|
||
overlay.close = () => overlay.remove()
|
||
|
||
overlay.addEventListener('click', (e) => {
|
||
if (e.target === overlay) overlay.remove()
|
||
})
|
||
overlay.querySelector('[data-action="cancel"]').onclick = () => overlay.remove()
|
||
overlay.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') overlay.remove()
|
||
})
|
||
|
||
// 自动聚焦第一个输入框或按钮
|
||
const firstInput = overlay.querySelector('input, textarea, select')
|
||
if (firstInput) firstInput.focus()
|
||
|
||
return overlay
|
||
}
|
||
|
||
/**
|
||
* 升级进度弹窗 — 带进度条和实时日志
|
||
* @returns {{ appendLog, setProgress, setDone, setError, destroy }}
|
||
*/
|
||
export function showUpgradeModal(title) {
|
||
const overlay = document.createElement('div')
|
||
overlay.className = 'modal-overlay'
|
||
overlay.innerHTML = `
|
||
<div class="modal" style="max-width:520px">
|
||
<div class="modal-title">${title || t('common.upgradeOpenClaw')}</div>
|
||
<div class="upgrade-progress-wrap">
|
||
<div class="upgrade-progress-bar"><div class="upgrade-progress-fill" style="width:0%"></div></div>
|
||
<div class="upgrade-progress-text">${t('common.preparing')}</div>
|
||
</div>
|
||
<div class="upgrade-log-box"></div>
|
||
<div class="modal-actions">
|
||
<button class="btn btn-secondary btn-sm" data-action="close">${t('common.close')}</button>
|
||
</div>
|
||
</div>
|
||
`
|
||
document.body.appendChild(overlay)
|
||
|
||
const fill = overlay.querySelector('.upgrade-progress-fill')
|
||
const text = overlay.querySelector('.upgrade-progress-text')
|
||
const logBox = overlay.querySelector('.upgrade-log-box')
|
||
const closeBtn = overlay.querySelector('[data-action="close"]')
|
||
const _logLines = []
|
||
|
||
let _onClose = null
|
||
let _finished = false
|
||
let _taskBar = null
|
||
let _progressLabels = 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">${t('common.viewDetails')}</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') closeModal()
|
||
})
|
||
|
||
return {
|
||
appendLog(line) {
|
||
_logLines.push(line)
|
||
const div = document.createElement('div')
|
||
div.textContent = line
|
||
logBox.appendChild(div)
|
||
logBox.scrollTop = logBox.scrollHeight
|
||
},
|
||
appendHtmlLog(line) {
|
||
_logLines.push(line)
|
||
const div = document.createElement('div')
|
||
div.innerHTML = line
|
||
logBox.appendChild(div)
|
||
logBox.scrollTop = logBox.scrollHeight
|
||
},
|
||
getLogText() { return _logLines.join('\n') },
|
||
setProgressLabels(labels) { _progressLabels = labels },
|
||
setProgress(pct) {
|
||
fill.style.width = pct + '%'
|
||
const labels = _progressLabels || {}
|
||
let statusText
|
||
if (pct >= 100) statusText = labels.done || t('common.completed')
|
||
else if (pct >= 75) statusText = labels.installing || t('common.installingProgress')
|
||
else if (pct >= 30) statusText = labels.downloading || t('common.downloadingDependencies')
|
||
else statusText = labels.preparing || t('common.preparing')
|
||
text.textContent = statusText
|
||
updateTaskBar(statusText)
|
||
},
|
||
setDone(msg) {
|
||
_finished = true
|
||
text.textContent = msg || t('common.upgradeCompleted')
|
||
fill.style.width = '100%'
|
||
fill.classList.add('done')
|
||
if (_taskBar) { _taskBar.remove(); _taskBar = null }
|
||
closeBtn.focus()
|
||
},
|
||
setError(msg) {
|
||
_finished = true
|
||
text.textContent = msg || t('common.upgradeFailed')
|
||
fill.classList.add('error')
|
||
if (_taskBar) {
|
||
const span = _taskBar.querySelector('.upgrade-task-bar-text')
|
||
if (span) { span.textContent = msg || t('common.upgradeFailed'); span.style.color = 'var(--error)' }
|
||
}
|
||
closeBtn.focus()
|
||
},
|
||
onClose(fn) { _onClose = fn },
|
||
destroy() { overlay.remove(); if (_taskBar) { _taskBar.remove(); _taskBar = null } _onClose?.() },
|
||
}
|
||
}
|