mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-29 20:30:00 +08:00
延续上一轮小白 UX 改造的尾声三连:
## 1. toast 智能行动按钮(U2 收尾)
- humanizeError 输出新增 action 字段:{ label, route?, handler?, kind }
- 自动按错误 kind 给默认按钮:
· gatewayDown → [去启动 Gateway] → /services
· cmdMissing / permission → [打开设置] → /settings
· auth → [检查 API Key] → /models
· network / timeout / rateLimit / generic → 不给(重试由用户控制)
- toast 结构化分支渲染 .toast-action-btn 按钮,点击后用 navigate(route) 或调 handler,并自动关闭 toast
- common.js 加 errorAction.* 三个按钮文案 i18n(11 语言)
## 2. ⓘ 拓展到 gateway / agents
- gateway.js: token label 后加 ⓘ(apikey 术语),renderConfig 末尾 attachTermTooltips
- agents.js: addAgent 弹窗 workspace 字段 label 加 ⓘ,setTimeout 扫 document.body 绑定
- term-tooltip.js 精简表新增 4 个术语:workspace / provider / baseurl + scope(已有)
## 3. sidebar 加术语表入口
- 在两个引擎(OpenClaw + Hermes)的最后一个 section 加「术语」条目
- sidebar.js i18n 新增 glossary 键(11 语言)
- 之前只能从 dashboard quick-actions 进入,现在 sidebar 永久可达
## 4. 顺手 bug 修复
- gateway.js 文件末尾历史残留的多余 `}` (line 348) syntax 错误,删除
- 与之前 hermes/cron.js 同类问题,本次改 ⓘ 时被 node --check 暴露
## 累计变动
- 10 个文件修改
- 7 个新 i18n 键(11 语言)
- Build OK
141 lines
5.2 KiB
JavaScript
141 lines
5.2 KiB
JavaScript
/**
|
||
* 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)
|
||
}
|
||
|
||
// 不同错误类型默认对应的行动按钮(label 走 i18n,route 直接跳转)
|
||
const DEFAULT_ACTIONS = {
|
||
gatewayDown: { labelKey: 'common.errorAction.startGateway', route: '/services' },
|
||
cmdMissing: { labelKey: 'common.errorAction.openSettings', route: '/settings' },
|
||
permission: { labelKey: 'common.errorAction.openSettings', route: '/settings' },
|
||
auth: { labelKey: 'common.errorAction.checkApiKey', route: '/models' },
|
||
// network / timeout / busy / rateLimit / notFound / generic:不给默认 action(重试由用户自己控制)
|
||
}
|
||
|
||
/**
|
||
* @param {unknown} e - 原始错误(Error / string / Tauri Result)
|
||
* @param {string} [context] - 操作上下文文案(如 t('channels.saveFailed'))
|
||
* @returns {{ message: string, hint: string, raw: string, action?: { label, route?, handler? } }}
|
||
*/
|
||
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}`)
|
||
const result = { message, hint, raw: rawTruncated, kind }
|
||
|
||
const defaultAction = DEFAULT_ACTIONS[kind]
|
||
if (defaultAction) {
|
||
result.action = { label: t(defaultAction.labelKey), route: defaultAction.route }
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
/**
|
||
* 便捷帮手:直接拿格式化后的字符串(无 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
|
||
}
|