mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-29 20:30:00 +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
|
||||
}
|
||||
}
|
||||
@@ -96,6 +96,13 @@ export default {
|
||||
auth: _('请检查 API key/账号信息是否正确,或重新登录', 'Check that the API key / account info is correct, or sign in again', '請檢查 API key/帳號資訊是否正確,或重新登入', 'API キー/アカウント情報が正しいか確認するか、再度ログインしてください', 'API 키/계정 정보가 올바른지 확인하거나 다시 로그인하세요', 'Hãy kiểm tra API key/thông tin tài khoản, hoặc đăng nhập lại', 'Verifica que la API key / cuenta sea correcta, o vuelve a iniciar sesión', 'Verifique se a API key / conta está correta, ou faça login novamente', 'Проверьте API-ключ / данные учётной записи или войдите снова', 'Vérifiez que la clé API / les identifiants sont corrects ou reconnectez-vous', 'Prüfen Sie API-Key / Anmeldedaten oder melden Sie sich erneut an'),
|
||||
rateLimit: _('已触发限流,请稍后重试,或在模型/渠道配置中放宽频率', 'Rate limit reached. Retry later, or raise the limit in the model/channel settings', '已觸發限流,請稍後重試,或在模型/頻道設定中放寬頻率', 'レート制限に達しました。後で再試行するか、モデル/チャンネル設定で上限を緩めてください', '속도 제한에 도달했습니다. 잠시 후 다시 시도하거나 모델/채널 설정에서 한도를 완화하세요', 'Đã đạt giới hạn tốc độ, thử lại sau hoặc nới lỏng giới hạn trong cài đặt mô hình/kênh', 'Se alcanzó el límite de tasa, reintenta luego o aumenta el límite en la configuración', 'Limite de taxa atingido, tente mais tarde ou aumente o limite nas configurações', 'Достигнут лимит запросов, повторите позже или увеличьте лимит в настройках', 'Limite de débit atteinte, réessayez plus tard ou augmentez la limite dans les paramètres', 'Ratenlimit erreicht, später erneut versuchen oder das Limit in den Einstellungen erhöhen'),
|
||||
generic: _('请稍后重试;如问题持续,请到日志页查看详情', 'Try again later; if the issue persists, check the Logs page for details', '請稍後重試;如問題持續,請到日誌頁查看詳情', '後で再試行してください。問題が続く場合はログページで詳細を確認してください', '잠시 후 다시 시도하세요; 문제가 계속되면 로그 페이지에서 자세히 확인하세요', 'Vui lòng thử lại sau; nếu lỗi vẫn xảy ra, hãy kiểm tra trang Nhật ký', 'Reintenta más tarde; si persiste, revisa la página de Registros', 'Tente novamente mais tarde; se persistir, verifique a página de Logs', 'Повторите позже; если ошибка не уходит — посмотрите страницу Логи', 'Réessayez plus tard ; si le problème persiste, consultez la page Journaux', 'Versuchen Sie es später erneut; bei anhaltendem Problem die Protokoll-Seite prüfen'),
|
||||
// schema 校验(P1-6)— 写入前的字段级即时校验
|
||||
schemaRequired: _('{path} 是必填字段', '{path} is required', '{path} 是必填欄位', '{path} は必須項目です', '{path}은(는) 필수입니다', '{path} là bắt buộc', '{path} es obligatorio', '{path} é obrigatório', '{path} обязательно', '{path} est requis', '{path} ist erforderlich'),
|
||||
schemaType: _('{path} 应该是「{expected}」类型,当前是「{actual}」', '{path} should be of type "{expected}", got "{actual}"', '{path} 應該是「{expected}」型別,目前是「{actual}」', '{path} は「{expected}」型である必要があります(現在は「{actual}」)', '{path}은(는) "{expected}" 타입이어야 합니다 (현재: {actual})', '{path} phải là kiểu "{expected}", hiện đang là "{actual}"', '{path} debe ser del tipo "{expected}", actual: "{actual}"', '{path} deve ser do tipo "{expected}", atual: "{actual}"', '{path} должно быть типа "{expected}", сейчас: "{actual}"', '{path} doit être de type « {expected} », actuel : « {actual} »', '{path} muss vom Typ „{expected}" sein, ist aktuell „{actual}"'),
|
||||
schemaEnum: _('{path} 取值必须在 {allowed} 之中', '{path} must be one of: {allowed}', '{path} 取值必須在 {allowed} 之中', '{path} は {allowed} のいずれかである必要があります', '{path}은(는) 다음 중 하나여야 합니다: {allowed}', '{path} phải là một trong: {allowed}', '{path} debe ser uno de: {allowed}', '{path} deve ser um de: {allowed}', '{path} должно быть одним из: {allowed}', '{path} doit être l\'une de : {allowed}', '{path} muss eines von: {allowed} sein'),
|
||||
schemaMin: _('{path} 不能小于 {min}', '{path} must be ≥ {min}', '{path} 不能小於 {min}', '{path} は {min} 以上である必要があります', '{path}은(는) {min} 이상이어야 합니다', '{path} không được nhỏ hơn {min}', '{path} no puede ser menor que {min}', '{path} não pode ser menor que {min}', '{path} не может быть меньше {min}', '{path} ne peut pas être inférieur à {min}', '{path} darf nicht kleiner als {min} sein'),
|
||||
schemaMax: _('{path} 不能大于 {max}', '{path} must be ≤ {max}', '{path} 不能大於 {max}', '{path} は {max} 以下である必要があります', '{path}은(는) {max} 이하여야 합니다', '{path} không được lớn hơn {max}', '{path} no puede ser mayor que {max}', '{path} não pode ser maior que {max}', '{path} не может быть больше {max}', '{path} ne peut pas être supérieur à {max}', '{path} darf nicht größer als {max} sein'),
|
||||
schemaPattern: _('{path} 格式不正确', '{path} format is invalid', '{path} 格式不正確', '{path} の形式が正しくありません', '{path} 형식이 올바르지 않습니다', '{path} sai định dạng', '{path} tiene formato inválido', '{path} com formato inválido', '{path} имеет неверный формат', '{path} a un format invalide', '{path}-Format ist ungültig'),
|
||||
},
|
||||
errorRawLabel: _('技术详情', 'Technical details', '技術詳情', '技術的詳細', '기술 정보', 'Chi tiết kỹ thuật', 'Detalles técnicos', 'Detalhes técnicos', 'Технические подробности', 'Détails techniques', 'Technische Details'),
|
||||
// 智能行动按钮(toast 副标题旁的快捷跳转)
|
||||
|
||||
@@ -297,6 +297,15 @@ function bindConfigEvents(el) {
|
||||
|
||||
async function saveConfig(page, state) {
|
||||
const port = parseInt(page.querySelector('#gw-port')?.value) || 18789
|
||||
|
||||
// P1-6: 用内核 config.schema.lookup 即时校验 port(无 schema 时降级放行)
|
||||
const portCheck = await validateField('gateway.port', port)
|
||||
if (!portCheck.ok) {
|
||||
toast(portCheck.message, 'error')
|
||||
page.querySelector('#gw-port')?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
const bindRadio = page.querySelector('input[name="gw-bind"]:checked')
|
||||
const bind = bindRadio?.value || 'loopback'
|
||||
const mode = 'local'
|
||||
|
||||
Reference in New Issue
Block a user