diff --git a/src/lib/config-schema.js b/src/lib/config-schema.js new file mode 100644 index 0000000..b4acbed --- /dev/null +++ b/src/lib/config-schema.js @@ -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} 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 + } +} diff --git a/src/locales/modules/common.js b/src/locales/modules/common.js index 996a449..2937fc1 100644 --- a/src/locales/modules/common.js +++ b/src/locales/modules/common.js @@ -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 副标题旁的快捷跳转) diff --git a/src/pages/gateway.js b/src/pages/gateway.js index 012ca02..39f4859 100644 --- a/src/pages/gateway.js +++ b/src/pages/gateway.js @@ -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'