Files
AgentLab/frontend/src/lib/schemaForm.ts
T

199 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 表单 agentlab.form/1 的前端那一半(应用平台 §3.3):字段顺序、控件、默认值、子集校验。
*
* 前端只校验目录规定的子集,后端用 jsonschema 复核、以后端为准;两边的错误格式都是 {path, code, message},
* 服务端 422 回来的错误和这里算出的错误挂到同一个位置。不支持 pattern:JS 和 Python 的正则语义不同,前端放行、
* 后端拒收的情形就是从这里来的(P5)。
*
* 只有一层:顶层 object,字段是 string / number / integer / boolean,数组只装枚举字符串(多选)。
*/
import type { FormError, FormField, FormSchema, FormWidget } from '../typesApp'
import { FORM_TEXT } from './termsApp'
/** 字段顺序:x-order 里写到的在前(按写的顺序),没写到的按 properties 的原顺序跟在后面;不存在的名字跳过 */
export function fieldOrder(schema: FormSchema): string[] {
const props = schema.properties ?? {}
const listed = (schema['x-order'] ?? []).filter((k, i, all) => k in props && all.indexOf(k) === i)
return [...listed, ...Object.keys(props).filter((k) => !listed.includes(k))]
}
/** 这个字段画成哪种控件:写了 x-widget 照它,没写按类型推 */
export function widgetOf(field: FormField): FormWidget {
if (field['x-widget']) return field['x-widget']
switch (field.type) {
case 'boolean': return 'checkbox'
case 'array': return 'multiselect'
case 'integer': return field.enum?.length ? 'select' : 'integer'
case 'number': return field.enum?.length ? 'select' : 'number'
default:
if (field.enum?.length) return 'select'
if (field.format === 'date') return 'date'
if (field.format === 'date-time') return 'datetime'
return (field.maxLength ?? 0) > 200 ? 'textarea' : 'text'
}
}
/** 选项:值和显示的字。x-enum-labels 和 enum 按位置对应,缺的用值本身 */
export function optionsOf(field: FormField): { value: string | number; label: string }[] {
const values = field.type === 'array' ? field.items?.enum ?? field.enum ?? [] : field.enum ?? []
const labels = field['x-enum-labels'] ?? []
return values.map((v, i) => ({ value: v, label: labels[i] ?? String(v) }))
}
/** 只读说明(note)不是值,不进提交的内容,也不校验 */
export const isNote = (field: FormField) => widgetOf(field) === 'note'
/** 初始值:各字段的 default;已经有值的不覆盖 */
export function withDefaults(schema: FormSchema, value: Record<string, unknown>): Record<string, unknown> {
const out = { ...value }
for (const [key, field] of Object.entries(schema.properties ?? {})) {
if (out[key] === undefined && field.default !== undefined && !isNote(field)) out[key] = field.default
}
return out
}
/** 必填字段的空:没给、空串、空数组。布尔的 false 是一个值 */
const isEmpty = (v: unknown) => v === undefined || v === null || (typeof v === 'string' && v.trim() === '')
|| (Array.isArray(v) && v.length === 0)
const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/
// RFC 3339:日期、时间,带时区(Z 或 ±hh:mm)。datetime 控件交出来的值总带本地时区
const DATE_TIME_RE = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2})(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/i
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const URI_RE = /^[a-z][a-z0-9+.-]*:\S+$/i
function validDate(text: string): boolean {
const m = DATE_RE.exec(text)
if (!m) return false
const d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]))
return d.getUTCFullYear() === +m[1] && d.getUTCMonth() === +m[2] - 1 && d.getUTCDate() === +m[3]
}
function formatOk(format: string, text: string): boolean {
switch (format) {
case 'date': return validDate(text)
case 'date-time': {
const m = DATE_TIME_RE.exec(text)
return !!m && validDate(m[1]) && +m[2] < 24 && +m[3] < 60
}
case 'email': return EMAIL_RE.test(text)
case 'uri': return URI_RE.test(text)
default: return true
}
}
/** JSON Schema 的长度按码点数,不按 UTF-16 单元:一个表情算一个字 */
const lengthOf = (s: string) => Array.from(s).length
/**
* 子集校验:返回 {path, code, message},path 是字段名(只有一层)。顺序同字段顺序,每个字段最多一条——先说最要紧的
* (没填、类型不对),改好了再说下一条,不一次堆三句
*/
export function validateForm(schema: FormSchema, values: Record<string, unknown>): FormError[] {
const errors: FormError[] = []
const required = new Set(schema.required ?? [])
for (const key of fieldOrder(schema)) {
const field = schema.properties[key]
if (isNote(field)) continue
const error = fieldError(field, values[key], required.has(key))
if (error) errors.push({ path: key, ...error })
}
return errors
}
function fieldError(field: FormField, v: unknown, required: boolean): Omit<FormError, 'path'> | null {
if (isEmpty(v)) return required ? { code: 'required', message: FORM_TEXT.required } : null
switch (field.type) {
case 'string': {
if (typeof v !== 'string') return { code: 'type', message: FORM_TEXT.type.string }
if (field.enum && !field.enum.includes(v)) return { code: 'enum', message: FORM_TEXT.enum }
if (field.minLength != null && lengthOf(v) < field.minLength) {
return { code: 'min_length', message: FORM_TEXT.minLength(field.minLength) }
}
if (field.maxLength != null && lengthOf(v) > field.maxLength) {
return { code: 'max_length', message: FORM_TEXT.maxLength(field.maxLength) }
}
if (field.format && !formatOk(field.format, v)) {
return { code: 'format', message: FORM_TEXT.format[field.format] ?? FORM_TEXT.type.string }
}
return null
}
case 'number':
case 'integer': {
if (typeof v !== 'number' || !Number.isFinite(v) || (field.type === 'integer' && !Number.isInteger(v))) {
return { code: 'type', message: FORM_TEXT.type[field.type] }
}
if (field.enum && !field.enum.includes(v)) return { code: 'enum', message: FORM_TEXT.enum }
if (field.minimum != null && v < field.minimum) return { code: 'minimum', message: FORM_TEXT.minimum(field.minimum) }
if (field.maximum != null && v > field.maximum) return { code: 'maximum', message: FORM_TEXT.maximum(field.maximum) }
return null
}
case 'boolean':
return typeof v === 'boolean' ? null : { code: 'type', message: FORM_TEXT.type.boolean }
case 'array': {
if (!Array.isArray(v) || v.some((x) => typeof x !== 'string')) return { code: 'type', message: FORM_TEXT.type.array }
const allowed = field.items?.enum ?? field.enum
if (allowed && v.some((x) => !allowed.includes(x))) return { code: 'enum', message: FORM_TEXT.enum }
if (field.maxItems != null && v.length > field.maxItems) {
return { code: 'max_items', message: FORM_TEXT.maxItems(field.maxItems) }
}
return null
}
default:
return null
}
}
/** 提交的内容:去掉没填的(undefined、空串)和只读说明;必填没填的交给校验拦 */
export function cleanValues(schema: FormSchema, values: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {}
for (const [key, field] of Object.entries(schema.properties ?? {})) {
const v = values[key]
if (isNote(field) || v === undefined || (typeof v === 'string' && v === '')) continue
out[key] = v
}
return out
}
/**
* 服务端的 path 可能写成 amount、/amount、values.amount、values/amount(jsonschema 的 absolute_path 拼出来的样子
* 各有不同):统一成字段名。认不出的原样返回,界面上列在表单顶上
*/
export function fieldOfPath(path: string): string {
return path.replace(/^\/+/, '').replace(/^values[./]/, '').split(/[./]/)[0] ?? ''
}
/**
* 取 422 的表单错误:传 ApiError 时认它原样的响应体(服务端 {detail, errors:[…]} 里的那一组),传别的按 {errors} 或数组认;
* 形状不对返回 null,调用方按普通错误提示
*/
export function formErrorsOf(source: unknown): FormError[] | null {
const obj = source && typeof source === 'object' && !Array.isArray(source) ? source as { body?: unknown; errors?: unknown } : null
const body = obj && 'body' in obj ? obj.body : obj
const raw = body && typeof body === 'object' && !Array.isArray(body) ? (body as { errors?: unknown }).errors : source
if (!Array.isArray(raw)) return null
const list = raw.filter((e): e is FormError => !!e && typeof e === 'object'
&& typeof (e as FormError).path === 'string' && typeof (e as FormError).message === 'string')
return list.length ? list : null
}
/** datetime 控件(本地时间,无时区)→ RFC 3339,带本地时区偏移;反过来取回控件能显示的样子 */
export function localToRfc3339(local: string): string {
if (!local) return ''
const d = new Date(local)
if (Number.isNaN(d.getTime())) return local
const off = -d.getTimezoneOffset()
const sign = off >= 0 ? '+' : '-'
const pad = (n: number) => String(Math.trunc(Math.abs(n))).padStart(2, '0')
const base = local.length === 16 ? `${local}:00` : local
return `${base}${sign}${pad(off / 60)}:${pad(off % 60)}`
}
export function rfc3339ToLocal(value: unknown): string {
if (typeof value !== 'string' || !value) return ''
const d = new Date(value)
if (Number.isNaN(d.getTime())) return ''
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}