mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-30 12:50:14 +08:00
feat(openclaw): P1-6 config.schema RPC 写入校验 - 防小白改坏配置
OpenClaw 内核 config.set/patch 写入时已经会校验,但用户要等点保存才看到错误。
本 PR 把校验提前到前端,让用户改完字段立刻看到红字提示。
## 新增工具 src/lib/config-schema.js
- getFieldSchema(path) — 调内核 config.schema.lookup,带 5 分钟缓存
- validateField(path, value) — 拿 schema 后做基础约束校验
- validateFieldSync(schema, value, path) — 已有 schema 时同步校验
- clearSchemaCache(path?) — 清缓存(极少需要)
## 校验维度(不引入 ajv,保持 vanilla JS 体积)
- required — 必填空值检查
- type — string / number / integer / boolean / array / object / null
(含「数字字符串当数字看」的容错)
- enum — 枚举白名单
- minimum / maximum — 数值范围
- pattern — 字符串正则
## 友好错误文案(i18n)
- common.error.schemaRequired / schemaType / schemaEnum / schemaMin / schemaMax / schemaPattern
- 6 个键 × 11 语言全覆盖
- 替换 schema 给的英文 path 后用户看到的是:
「Gateway 端口不能小于 1024」「gateway.port 应该是「integer」类型」
## 集成示范:gateway.js
- saveConfig 开头先 validateField('gateway.port', port)
- 失败 → toast 红字 + focus 回 port 输入框 + early return
- 通过 → 走原流程
- 内核无该 schema 时降级放行(不阻断保存)
## 设计哲学
- 内核仍是最终守门人(config.set/patch 会再校验)
- 本模块的价值是「立即反馈」+「未来动态 UI 渲染」基础
- 容错优先:schema.lookup 失败时静默放行,不影响老内核兼容性
## 累计
- 1 新文件(config-schema.js)
- 2 修改(gateway.js / common.js)
- 6 个新 i18n 键 × 11 语言
- 后续可在 memory/dreaming/security/cron 等页面同样接入
This commit is contained in:
184
src/lib/config-schema.js
Normal file
184
src/lib/config-schema.js
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Config Schema 工具(P1-6)
|
||||
*
|
||||
* 对接 OpenClaw 内核的 config.schema / config.schema.lookup RPC:
|
||||
* - 让前端写入前能做基础字段校验(类型/枚举/范围/必填/正则)
|
||||
* - 让前端能感知内核新版加的字段(动态 UI 渲染基础)
|
||||
*
|
||||
* 设计原则:
|
||||
* - 不引入 ajv 等重型 schema validator,保持 vanilla JS 体积
|
||||
* - 只做最常见的 6 种校验(type/enum/minimum/maximum/pattern/required)
|
||||
* - 内核仍然是最终守门人(config.set/patch 会再校验一次)
|
||||
* - 本模块的价值是「**立即反馈**」:用户改完字段立刻看到错误,不必等点保存
|
||||
*
|
||||
* 用法:
|
||||
* import { validateField } from './config-schema.js'
|
||||
* const result = await validateField('gateway.port', 'abc')
|
||||
* // { ok: false, message: 'Gateway 端口应该是数字(当前:abc)' }
|
||||
*/
|
||||
import { wsClient } from './ws-client.js'
|
||||
import { t } from './i18n.js'
|
||||
|
||||
// schema.lookup 结果缓存(5 分钟,避免每次按键都重查)
|
||||
const _cache = new Map()
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000
|
||||
|
||||
/**
|
||||
* 拿指定 path 的字段 schema(带缓存 + 容错)
|
||||
*
|
||||
* @param {string} path 配置字段路径,如 'gateway.port'
|
||||
* @returns {Promise<object|null>} schema 子树;不可用 / 不支持 / 不存在时返回 null
|
||||
*/
|
||||
export async function getFieldSchema(path) {
|
||||
if (!path) return null
|
||||
const cached = _cache.get(path)
|
||||
if (cached && Date.now() - cached.t < CACHE_TTL_MS) return cached.v
|
||||
|
||||
try {
|
||||
const result = await wsClient.request('config.schema.lookup', { path })
|
||||
_cache.set(path, { t: Date.now(), v: result || null })
|
||||
return result || null
|
||||
} catch (e) {
|
||||
// 内核版本太老 / 不支持该方法时静默 fallback
|
||||
_cache.set(path, { t: Date.now(), v: null })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清缓存(极少需要 — 内核 schema 在 ClawPanel 一次会话内不会变)
|
||||
*/
|
||||
export function clearSchemaCache(path) {
|
||||
if (path) _cache.delete(path)
|
||||
else _cache.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验一个字段值是否符合 schema。
|
||||
*
|
||||
* @param {string} path 字段路径
|
||||
* @param {unknown} value 要校验的值
|
||||
* @returns {Promise<{ok: boolean, message?: string, code?: string}>}
|
||||
* - ok=true 通过(或无法校验 — schema 不可用时降级放行)
|
||||
* - ok=false 含 message(友好 i18n 文案)和 code(类型识别)
|
||||
*/
|
||||
export async function validateField(path, value) {
|
||||
const schema = await getFieldSchema(path)
|
||||
if (!schema) return { ok: true } // 无 schema 不阻止用户保存(降级放行)
|
||||
|
||||
// schema.lookup 返回结构:{ schema: {...}, type: '...', ... }
|
||||
// 实际字段约束通常在 schema.schema 子对象里,也兼容直接放在根上
|
||||
const constraints = schema.schema || schema
|
||||
|
||||
return checkConstraints(constraints, value, path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步版校验(已有 schema 时用,避免重复 await)
|
||||
*/
|
||||
export function validateFieldSync(schema, value, path = '') {
|
||||
if (!schema) return { ok: true }
|
||||
const constraints = schema.schema || schema
|
||||
return checkConstraints(constraints, value, path)
|
||||
}
|
||||
|
||||
function checkConstraints(c, value, path) {
|
||||
if (!c || typeof c !== 'object') return { ok: true }
|
||||
|
||||
// required (空值检查 — undefined/null/空串)
|
||||
if (c.required && (value === undefined || value === null || value === '')) {
|
||||
return { ok: false, code: 'required', message: t('common.error.schemaRequired', { path }) }
|
||||
}
|
||||
|
||||
// 跳过空值的其它校验
|
||||
if (value === undefined || value === null || value === '') return { ok: true }
|
||||
|
||||
// type
|
||||
if (c.type) {
|
||||
const types = Array.isArray(c.type) ? c.type : [c.type]
|
||||
const actual = jsType(value)
|
||||
const ok = types.some(t => typeMatches(t, value, actual))
|
||||
if (!ok) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'type',
|
||||
message: t('common.error.schemaType', { path, expected: types.join('/'), actual }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// enum
|
||||
if (Array.isArray(c.enum) && c.enum.length) {
|
||||
if (!c.enum.includes(value)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'enum',
|
||||
message: t('common.error.schemaEnum', { path, allowed: c.enum.join(' / ') }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// minimum / maximum
|
||||
if (typeof value === 'number' || (typeof value === 'string' && !isNaN(Number(value)))) {
|
||||
const num = Number(value)
|
||||
if (typeof c.minimum === 'number' && num < c.minimum) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'minimum',
|
||||
message: t('common.error.schemaMin', { path, min: c.minimum }),
|
||||
}
|
||||
}
|
||||
if (typeof c.maximum === 'number' && num > c.maximum) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'maximum',
|
||||
message: t('common.error.schemaMax', { path, max: c.maximum }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pattern (string regex)
|
||||
if (c.pattern && typeof value === 'string') {
|
||||
try {
|
||||
const re = new RegExp(c.pattern)
|
||||
if (!re.test(value)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'pattern',
|
||||
message: t('common.error.schemaPattern', { path }),
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 非法 pattern,忽略
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
function jsType(v) {
|
||||
if (v === null) return 'null'
|
||||
if (Array.isArray(v)) return 'array'
|
||||
return typeof v
|
||||
}
|
||||
|
||||
function typeMatches(schemaType, value, actual) {
|
||||
switch (schemaType) {
|
||||
case 'integer':
|
||||
return Number.isInteger(value) || (typeof value === 'string' && /^-?\d+$/.test(value))
|
||||
case 'number':
|
||||
return typeof value === 'number' || (typeof value === 'string' && !isNaN(Number(value)))
|
||||
case 'string':
|
||||
return typeof value === 'string'
|
||||
case 'boolean':
|
||||
return typeof value === 'boolean'
|
||||
case 'array':
|
||||
return Array.isArray(value)
|
||||
case 'object':
|
||||
return actual === 'object'
|
||||
case 'null':
|
||||
return value === null
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user