255 lines
7.4 KiB
TypeScript
255 lines
7.4 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||
import {
|
||
XCard,
|
||
registerCatalog,
|
||
type ActionPayload,
|
||
type XAgentCommand_v0_9,
|
||
} from '@ant-design/x-card';
|
||
import {
|
||
Alert,
|
||
Button,
|
||
DatePicker,
|
||
Flex,
|
||
Form,
|
||
Input,
|
||
InputNumber,
|
||
Select,
|
||
Typography,
|
||
} from 'antd';
|
||
import dayjs from 'dayjs';
|
||
import type { AiFormField, AiFormSchema } from './types';
|
||
|
||
const FORM_CATALOG_ID = 'gongxue-form-catalog';
|
||
|
||
registerCatalog({
|
||
catalogId: FORM_CATALOG_ID,
|
||
components: {
|
||
FormPreview: {
|
||
type: 'object',
|
||
properties: {
|
||
form: { type: 'object' },
|
||
disabled: { type: 'boolean' },
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
function surfaceId(formId: string): string {
|
||
return `form-${formId}`;
|
||
}
|
||
|
||
function initialValue(field: AiFormField): unknown {
|
||
if (field.type === 'date' && typeof field.defaultValue === 'string') {
|
||
const parsed = dayjs(field.defaultValue);
|
||
return parsed.isValid() ? parsed : undefined;
|
||
}
|
||
return field.defaultValue;
|
||
}
|
||
|
||
function normalizeValues(
|
||
fields: AiFormField[],
|
||
raw: Record<string, unknown>,
|
||
): Record<string, unknown> {
|
||
const values: Record<string, unknown> = {};
|
||
for (const field of fields) {
|
||
const value = raw[field.name];
|
||
if (value === undefined || value === null || value === '') continue;
|
||
values[field.name] =
|
||
field.type === 'date' && dayjs.isDayjs(value) ? value.format('YYYY-MM-DD') : value;
|
||
}
|
||
return values;
|
||
}
|
||
|
||
interface FormPreviewProps {
|
||
form?: AiFormSchema & {
|
||
submitting?: boolean;
|
||
submitted?: boolean;
|
||
error?: string | null;
|
||
};
|
||
disabled?: boolean;
|
||
onAction?: (name: string, context: Record<string, unknown>) => void;
|
||
}
|
||
|
||
/**
|
||
* A2UI component registered for the `gongxue-form-catalog` catalog.
|
||
* Receives the validated form schema through data binding and reports
|
||
* normalized values back through the `form:submit` action.
|
||
*/
|
||
const FormPreview: React.FC<FormPreviewProps> = ({ form, disabled, onAction }) => {
|
||
const submitting = Boolean(form?.submitting);
|
||
const initialValues = useMemo(
|
||
() =>
|
||
Object.fromEntries((form?.fields ?? []).map((field) => [field.name, initialValue(field)])),
|
||
[form?.fields],
|
||
);
|
||
if (!form) return null;
|
||
const finished = Boolean(form.submitted) || form.status === 'submitted';
|
||
|
||
const handleFinish = (values: Record<string, unknown>) => {
|
||
onAction?.('form:submit', { values: normalizeValues(form.fields, values) });
|
||
};
|
||
|
||
return (
|
||
<Flex vertical gap={8}>
|
||
<Typography.Text strong>{form.title}</Typography.Text>
|
||
{form.description && (
|
||
<Typography.Text type="secondary" className="ai-chat-dynamic-form__desc">
|
||
{form.description}
|
||
</Typography.Text>
|
||
)}
|
||
{finished ? (
|
||
<Alert type="success" showIcon message="已提交,AI 正在处理…" />
|
||
) : (
|
||
<Form
|
||
layout="vertical"
|
||
size="small"
|
||
initialValues={initialValues}
|
||
onFinish={(values) => void handleFinish(values as Record<string, unknown>)}
|
||
disabled={disabled || submitting}
|
||
requiredMark={false}
|
||
>
|
||
{form.fields.map((field) => (
|
||
<Form.Item
|
||
key={field.name}
|
||
name={field.name}
|
||
label={field.label}
|
||
rules={[
|
||
{
|
||
required: field.required,
|
||
message: field.required
|
||
? field.type === 'select' || field.type === 'date'
|
||
? `请选择${field.label}`
|
||
: `请输入${field.label}`
|
||
: undefined,
|
||
},
|
||
]}
|
||
>
|
||
{field.type === 'textarea' ? (
|
||
<Input.TextArea rows={3} placeholder={field.placeholder} />
|
||
) : field.type === 'number' ? (
|
||
<InputNumber
|
||
className="ai-chat-dynamic-form__number"
|
||
placeholder={field.placeholder}
|
||
/>
|
||
) : field.type === 'select' ? (
|
||
<Select
|
||
allowClear={!field.required}
|
||
placeholder={field.placeholder}
|
||
options={field.options}
|
||
/>
|
||
) : field.type === 'date' ? (
|
||
<DatePicker
|
||
className="ai-chat-dynamic-form__date"
|
||
placeholder={field.placeholder}
|
||
/>
|
||
) : (
|
||
<Input placeholder={field.placeholder} />
|
||
)}
|
||
</Form.Item>
|
||
))}
|
||
{form.error && (
|
||
<Alert
|
||
type="error"
|
||
showIcon
|
||
title={form.error}
|
||
className="ai-chat-dynamic-form__error"
|
||
/>
|
||
)}
|
||
<Button type="primary" htmlType="submit" loading={submitting} disabled={disabled}>
|
||
{form.submitLabel || '提交'}
|
||
</Button>
|
||
</Form>
|
||
)}
|
||
</Flex>
|
||
);
|
||
};
|
||
|
||
export interface DynamicFormProps {
|
||
form: AiFormSchema;
|
||
disabled?: boolean;
|
||
onSubmit: (values: Record<string, unknown>) => void | Promise<void>;
|
||
}
|
||
|
||
/**
|
||
* A2UI form rendered through the official @ant-design/x-card renderer.
|
||
* The validated schema is bound into the surface data model; submit
|
||
* success/failure/loading transitions are pushed as incremental commands.
|
||
*/
|
||
export const DynamicForm: React.FC<DynamicFormProps> = ({ form, disabled, onSubmit }) => {
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [submitted, setSubmitted] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const commandsRef = useRef<XAgentCommand_v0_9[]>([]);
|
||
const [commands, setCommands] = useState<XAgentCommand_v0_9[]>([]);
|
||
const idRef = useRef<string>('');
|
||
|
||
useEffect(() => {
|
||
const sid = surfaceId(form.id);
|
||
if (idRef.current !== sid) {
|
||
commandsRef.current = [];
|
||
idRef.current = sid;
|
||
}
|
||
const cmds = commandsRef.current;
|
||
if (cmds.length === 0) {
|
||
cmds.push({
|
||
version: 'v0.9',
|
||
createSurface: { surfaceId: sid, catalogId: FORM_CATALOG_ID },
|
||
});
|
||
}
|
||
cmds.push({
|
||
version: 'v0.9',
|
||
updateDataModel: {
|
||
surfaceId: sid,
|
||
path: '/form',
|
||
value: { ...form, submitting, submitted, error },
|
||
},
|
||
});
|
||
cmds.push({
|
||
version: 'v0.9',
|
||
updateComponents: {
|
||
surfaceId: sid,
|
||
components: [
|
||
{
|
||
id: 'root',
|
||
component: 'FormPreview',
|
||
form: { path: '/form' },
|
||
disabled: Boolean(disabled),
|
||
},
|
||
],
|
||
},
|
||
});
|
||
setCommands([...cmds]);
|
||
}, [disabled, error, form, submitted, submitting]);
|
||
|
||
const handleSubmit = async (values: Record<string, unknown>) => {
|
||
if (submitting) return;
|
||
setSubmitting(true);
|
||
setError(null);
|
||
try {
|
||
await onSubmit(values);
|
||
setSubmitted(true);
|
||
} catch (reason) {
|
||
setError(reason instanceof Error ? reason.message : '提交失败,请稍后重试');
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const handleAction = (payload: ActionPayload) => {
|
||
if (payload.name !== 'form:submit') return;
|
||
const values =
|
||
payload.context?.values && typeof payload.context.values === 'object'
|
||
? (payload.context.values as Record<string, unknown>)
|
||
: {};
|
||
void handleSubmit(values);
|
||
};
|
||
|
||
return (
|
||
<div className="ai-chat-dynamic-form">
|
||
<XCard.Box components={{ FormPreview }} commands={commands} onAction={handleAction}>
|
||
<XCard.Card id={surfaceId(form.id)} />
|
||
</XCard.Box>
|
||
</div>
|
||
);
|
||
};
|