feat: AI 对话支持 A2UI 表单/审查/图表与 Excel 读取
This commit is contained in:
239
apps/admin/src/components/AiChat/DynamicForm.tsx
Normal file
239
apps/admin/src/components/AiChat/DynamicForm.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { XCard, registerCatalog } from '@ant-design/x-card';
|
||||
import type { ActionPayload, 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;
|
||||
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 runtime = form as unknown as {
|
||||
submitting?: boolean;
|
||||
submitted?: boolean;
|
||||
error?: string | null;
|
||||
};
|
||||
const submitting = Boolean(runtime.submitting);
|
||||
const initialValues = useMemo(
|
||||
() => Object.fromEntries((form?.fields ?? []).map((field) => [field.name, initialValue(field)])),
|
||||
[form?.fields],
|
||||
);
|
||||
if (!form) return null;
|
||||
const finished = Boolean(runtime.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>
|
||||
))}
|
||||
{runtime.error && (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={runtime.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>
|
||||
);
|
||||
};
|
||||
|
||||
export default DynamicForm;
|
||||
Reference in New Issue
Block a user