mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-30 04:40:18 +08:00
feat(ux): 小白 UX 全面改造 - 错误友好度 + 致命操作强确认 + 空状态 + 新手引导 + 术语表
面向小白用户的产品定位重塑,从七大 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 全程通过
This commit is contained in:
125
src/lib/humanize-error.js
Normal file
125
src/lib/humanize-error.js
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* humanize-error.js — 把后端原始 Error / Tauri Result 字符串映射成小白能看懂的友好文案。
|
||||
*
|
||||
* 用法:
|
||||
* import { humanizeError } from '../lib/humanize-error.js'
|
||||
* try { ... } catch (e) {
|
||||
* toast(humanizeError(e, t('channels.loadFailed')), 'error')
|
||||
* }
|
||||
*
|
||||
* 返回值:
|
||||
* {
|
||||
* message: string // 用户主行(默认走 context 或匹配到的友好键)
|
||||
* hint: string // 副行小灰字行动建议
|
||||
* raw: string // 原始错误字符串(折叠在「技术详情」里)
|
||||
* }
|
||||
*
|
||||
* 设计原则:
|
||||
* - 永远返回对象(toast 组件需要稳定 shape)
|
||||
* - 永远保留 raw 给开发者排查
|
||||
* - hint 不强制覆盖 context;context 已含「做什么 + 失败」时 message 用 context
|
||||
*/
|
||||
|
||||
import { t } from './i18n.js'
|
||||
|
||||
const PATTERNS = [
|
||||
// 网络
|
||||
{
|
||||
key: 'network',
|
||||
re: /(failed to fetch|networkerror|networkfailure|enetunreach|econnreset|econnrefused|ehostunreach|err_network|fetch failed|connection refused|connection reset|getaddrinfo|dns error|no route to host|aborted|broken pipe|connect timed out|tcp connect)/i,
|
||||
},
|
||||
// Gateway 未启动(特殊的 connection refused / port not listen 情况)
|
||||
{
|
||||
key: 'gatewayDown',
|
||||
re: /(gateway[^a-z]*(not[^a-z]*(running|ready|reachable)|down|offline|未启动)|managed gateway|未运行|gateway[^a-z]*未就绪)/i,
|
||||
},
|
||||
// 命令未找到 / 二进制丢失
|
||||
{
|
||||
key: 'cmdMissing',
|
||||
re: /(command not found|not recognized as|no such file or directory|enoent|不是.*命令|未找到.*命令|cannot find|missing executable|exec format error)/i,
|
||||
},
|
||||
// 权限
|
||||
{
|
||||
key: 'permission',
|
||||
re: /(permission denied|eacces|operation not permitted|access is denied|拒绝访问|无权限|权限不足|forbidden)/i,
|
||||
},
|
||||
// 鉴权(401/403/无效 token/api key)
|
||||
{
|
||||
key: 'auth',
|
||||
re: /(401|unauthori[sz]ed|invalid (api[_ ]?key|token|credentials)|authentication[^a-z]*(failed|required)|signature.*verification.*failed|身份验证|未授权)/i,
|
||||
},
|
||||
// 限流
|
||||
{
|
||||
key: 'rateLimit',
|
||||
re: /(429|too many requests|rate[_ ]?limit|quota[^a-z]*(exceeded|reached)|limit.*reached|流量限制|超过.*配额)/i,
|
||||
},
|
||||
// 超时
|
||||
{
|
||||
key: 'timeout',
|
||||
re: /(timeout|timed out|deadline exceeded|超时)/i,
|
||||
},
|
||||
// 资源不存在(404)
|
||||
{
|
||||
key: 'notFound',
|
||||
re: /(\b404\b|not found|does not exist|未找到|不存在|no such)/i,
|
||||
},
|
||||
// 服务繁忙(500-504 / "busy" / "unavailable")
|
||||
{
|
||||
key: 'busy',
|
||||
re: /(\b5\d\d\b|service unavailable|server error|internal server|temporarily unavailable|busy|繁忙)/i,
|
||||
},
|
||||
]
|
||||
|
||||
const RAW_MAX = 240 // 原始错误字符串保留长度(折叠区显示用,过长截断防止 toast 撑大)
|
||||
|
||||
/**
|
||||
* 把任意 error / 字符串 / Tauri Result 转成原始字符串。
|
||||
*/
|
||||
function toRawString(e) {
|
||||
if (e == null) return ''
|
||||
if (typeof e === 'string') return e
|
||||
if (e instanceof Error) return e.message || e.stack || String(e)
|
||||
if (typeof e === 'object') {
|
||||
// Tauri invoke 失败时通常是字符串;如果是 object,看常见字段
|
||||
if (typeof e.message === 'string') return e.message
|
||||
if (typeof e.error === 'string') return e.error
|
||||
try { return JSON.stringify(e) } catch { return String(e) }
|
||||
}
|
||||
return String(e)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} e - 原始错误(Error / string / Tauri Result)
|
||||
* @param {string} [context] - 操作上下文文案(如 t('channels.saveFailed'))
|
||||
* @returns {{ message: string, hint: string, raw: string }}
|
||||
*/
|
||||
export function humanizeError(e, context) {
|
||||
const raw = toRawString(e).trim()
|
||||
const rawTruncated = raw.length > RAW_MAX ? raw.slice(0, RAW_MAX) + '…' : raw
|
||||
|
||||
// 1) 用 context 作为主行(已经是用户视角文案,比如「保存失败」)
|
||||
// 没有 context 时用通用「操作未完成」
|
||||
// 2) 匹配关键字定位具体原因 → 生成 hint
|
||||
const ctx = (context && String(context).trim()) || ''
|
||||
let kind = 'generic'
|
||||
for (const p of PATTERNS) {
|
||||
if (p.re.test(raw)) {
|
||||
kind = p.key
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const message = ctx || t(`common.error.${kind}`)
|
||||
const hint = t(`common.errorHint.${kind}`)
|
||||
|
||||
return { message, hint, raw: rawTruncated }
|
||||
}
|
||||
|
||||
/**
|
||||
* 便捷帮手:直接拿格式化后的字符串(无 hint 折叠 UI,给老 API 兼容)。
|
||||
* humanizeErrorText(e, ctx) -> "保存失败 · 网络不通"
|
||||
*/
|
||||
export function humanizeErrorText(e, context) {
|
||||
const h = humanizeError(e, context)
|
||||
return h.hint ? `${h.message} · ${h.hint}` : h.message
|
||||
}
|
||||
114
src/lib/term-tooltip.js
Normal file
114
src/lib/term-tooltip.js
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 术语提示 — 在页面中嵌入「ⓘ」按钮,点击弹出术语解释小卡片
|
||||
*
|
||||
* 用法:
|
||||
* import { termHelpHtml, attachTermTooltips } from '../lib/term-tooltip.js'
|
||||
*
|
||||
* // 在页面 HTML 中插入按钮:
|
||||
* <label>${t('webhook')} ${termHelpHtml('webhook')}</label>
|
||||
*
|
||||
* // 渲染完成后绑定 click:
|
||||
* attachTermTooltips(rootEl)
|
||||
*
|
||||
* 数据源:内嵌一份精简的术语映射表(仅高频术语),
|
||||
* 与 `pages/glossary.js` 完整术语表互补。
|
||||
*/
|
||||
import { t, getLang } from './i18n.js'
|
||||
import { showContentModal } from '../components/modal.js'
|
||||
import { navigate } from '../router.js'
|
||||
|
||||
// 高频术语精简映射(与 glossary.js 数据保持一致;仅纳入页面 ⓘ 旁要弹出的)
|
||||
const TERMS = {
|
||||
oauth: {
|
||||
zhCN: { name: 'OAuth', desc: '一种「不用给密码也能让别人代你登录」的协议。授权 ClawPanel 接入 Discord 时走的就是 OAuth,比直接给 token 更安全。' },
|
||||
en: { name: 'OAuth', desc: 'A protocol that lets services log in on your behalf without sharing your password. ClawPanel uses OAuth when connecting Discord — safer than handing over a raw token.' },
|
||||
zhTW: { name: 'OAuth', desc: '一種「不用給密碼也能讓別人代你登入」的協定。授權 ClawPanel 接入 Discord 時走的就是 OAuth,比直接給 token 更安全。' },
|
||||
},
|
||||
webhook: {
|
||||
zhCN: { name: 'Webhook', desc: '一个外部应用「打你电话」的号码。比如 Discord 收到消息后就请求这个地址通知 ClawPanel,触发 AI 回复。' },
|
||||
en: { name: 'Webhook', desc: 'A URL external apps "call back" to. e.g. when Discord receives a message it pings this URL so ClawPanel knows and triggers the AI to respond.' },
|
||||
zhTW: { name: 'Webhook', desc: '一個外部應用「打你電話」的號碼。例如 Discord 收到訊息後就請求這個位址通知 ClawPanel,觸發 AI 回覆。' },
|
||||
},
|
||||
bottoken: {
|
||||
zhCN: { name: 'Bot Token', desc: 'Telegram/Discord 等平台给你的机器人发的「身份卡」。把它配到 ClawPanel,AI 就能以这个机器人的身份说话。' },
|
||||
en: { name: 'Bot Token', desc: 'The "ID card" issued by Telegram/Discord/etc. to your bot. Once you put it in ClawPanel, the AI can speak as this bot identity.' },
|
||||
zhTW: { name: 'Bot Token', desc: 'Telegram/Discord 等平台給你的機器人發的「身分卡」。把它配到 ClawPanel,AI 就能以這個機器人的身分說話。' },
|
||||
},
|
||||
apikey: {
|
||||
zhCN: { name: 'API Key', desc: '类似服务商发的「会员卡密码」。AI 调用要扣费,凭这把钥匙服务商才知道是你在用,并按使用量计费。' },
|
||||
en: { name: 'API Key', desc: 'Like a "member-card password" issued by the provider. AI calls cost money — this key tells the provider it\'s you so they can bill correctly.' },
|
||||
zhTW: { name: 'API Key', desc: '類似服務商發的「會員卡密碼」。AI 呼叫要扣費,憑這把鑰匙服務商才知道是你在用,並按使用量計費。' },
|
||||
},
|
||||
token: {
|
||||
zhCN: { name: 'Token(计费单位)', desc: 'AI 模型按「Token」收费 —— 大致相当于一个汉字、一个英文单词或半个标点。一次对话用 1000 Token = 大概 700 字。' },
|
||||
en: { name: 'Token', desc: 'The unit AI models bill in. Roughly equals one character (Chinese), one English word, or half a punctuation mark. 1000 tokens ≈ 750 English words.' },
|
||||
zhTW: { name: 'Token(計費單位)', desc: 'AI 模型按「Token」收費 —— 大致相當於一個中文字、一個英文單字或半個標點。一次對話用 1000 Token = 大概 700 字。' },
|
||||
},
|
||||
context: {
|
||||
zhCN: { name: '上下文窗口', desc: 'AI 一次对话能「记住」多少字。比如 32K = 大概 2 万汉字。超出窗口的早期对话 AI 会忘掉。' },
|
||||
en: { name: 'Context Window', desc: 'How much text the AI can "remember" in one chat. e.g. 32K ≈ 24K English words. Anything older than the window gets forgotten.' },
|
||||
zhTW: { name: '上下文視窗', desc: 'AI 一次對話能「記住」多少字。例如 32K = 大概 2 萬中文字。超出視窗的早期對話 AI 會忘掉。' },
|
||||
},
|
||||
binding: {
|
||||
zhCN: { name: '绑定', desc: '把「哪个 Agent」和「哪个渠道」配对的规则。比如「营销 Agent 接 Discord,技术 Agent 接 Slack」就是两条 Binding。' },
|
||||
en: { name: 'Binding', desc: 'A rule pairing "which Agent" with "which Channel". e.g. "marketing Agent to Discord, technical Agent to Slack" is two bindings.' },
|
||||
zhTW: { name: '綁定', desc: '把「哪個 Agent」和「哪個頻道」配對的規則。' },
|
||||
},
|
||||
scope: {
|
||||
zhCN: { name: '权限范围(Scope)', desc: '机器人能做什么的「白名单」—— 比如读消息、发消息、改群成员等。Scope 给少了功能不足,给多了有安全风险。' },
|
||||
en: { name: 'Scope (Permissions)', desc: 'A whitelist of what the bot is allowed to do — read messages, send messages, manage members, etc. Too few = missing features; too many = security risk.' },
|
||||
zhTW: { name: '權限範圍(Scope)', desc: '機器人能做什麼的「白名單」—— 例如讀訊息、發訊息、改群成員等。Scope 給少了功能不足,給多了有安全風險。' },
|
||||
},
|
||||
}
|
||||
|
||||
function pickLang(item) {
|
||||
const lang = getLang()
|
||||
if (lang.startsWith('zh-CN')) return item.zhCN
|
||||
if (lang.startsWith('zh-TW')) return item.zhTW || item.zhCN
|
||||
return item.en
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回一个 ⓘ 按钮的 HTML 字符串。点击后弹出术语解释 modal。
|
||||
* @param {string} termId
|
||||
* @returns {string} HTML
|
||||
*/
|
||||
export function termHelpHtml(termId) {
|
||||
if (!TERMS[termId]) return ''
|
||||
return `<button type="button" class="term-help" data-term-help="${termId}" aria-label="${t('glossary.title')}" tabindex="-1">ⓘ</button>`
|
||||
}
|
||||
|
||||
/**
|
||||
* 在容器里扫描所有 [data-term-help] 按钮,给加 click handler 弹 modal。
|
||||
* 重复调用安全(用 dataset 标记防重复绑定)。
|
||||
* @param {HTMLElement} root
|
||||
*/
|
||||
export function attachTermTooltips(root) {
|
||||
if (!root) return
|
||||
root.querySelectorAll('[data-term-help]').forEach(btn => {
|
||||
if (btn.dataset.termHelpBound === '1') return
|
||||
btn.dataset.termHelpBound = '1'
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const termId = btn.dataset.termHelp
|
||||
const term = TERMS[termId]
|
||||
if (!term) return
|
||||
const txt = pickLang(term)
|
||||
const overlay = showContentModal({
|
||||
title: txt.name,
|
||||
content: `<p style="font-size:var(--font-size-sm);color:var(--text-secondary);line-height:1.7;margin:0">${escapeHtml(txt.desc)}</p>`,
|
||||
buttons: [{ label: t('glossary.title') + ' →', className: 'btn btn-primary btn-sm', id: 'btn-go-glossary' }],
|
||||
width: 420,
|
||||
})
|
||||
overlay.querySelector('#btn-go-glossary')?.addEventListener('click', () => {
|
||||
overlay.close?.()
|
||||
navigate('/glossary')
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
Reference in New Issue
Block a user