Files
gongxue-base/apps/admin/src/components/AiChat/DynamicForm.tsx
wangziqi 67435e46ca feat(admin): 用户体验体系化提升与高危缺陷修复
UX 缺陷修复:
- 校验失败不再卡死弹窗按钮(Users/Roles/Bills)
- 押金收取/批量收取/添加分期防重复提交;切换房型重置勾选
- AI 表单/批量确认不再出现"假成功"
- Dashboard 各数据模块独立加载,单接口失败不再整页清零
- 房间可视化加载失败显示错误态而非永久转圈
- 学生编辑表单回填前重置,避免字段残留污染
- 覆盖式导入增加二次确认;恢复默认考勤时段确认并同步表单
- 金数据匹配关闭前确认,同步中禁止误关

体验提升:
- 新增统一 QueryErrorState/QueryEmpty,20+ 页面加载失败显示错误态与重试
- 全局 ErrorBoundary + RouteKeeper 逐页兜底
- 新增 usePageVisible/useVisibleRefetch,保活页面切回自动刷新数据
- 新增首次登录角色引导 RoleTour 与业务闭环 NextStepHint 引导卡
- 重构 A2UI:useSubmissionState/useXCardSurface 收敛状态与命令生命周期,
  ArtifactErrorBoundary 渲染降级,图表空数据占位
- AI 助手欢迎语与建议话术按角色定制,会话列表空态引导
- 更新 a2ui-contract.md 契约文档说明实现现状
2026-08-07 17:23:23 +08:00

242 lines
7.1 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.

import React, { useEffect, useMemo } 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';
import { useSubmissionState, useXCardSurface } from './useSubmissionState';
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 expired = form.status === 'expired';
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>
)}
{expired ? (
<Alert type="warning" showIcon title="表单已失效" description="此表单已被新的请求替代,请让助手重新生成。" />
) : finished ? (
<Alert type="success" showIcon title="已提交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 sid = surfaceId(form.id);
const { submitting, submitted, error, run } = useSubmissionState();
const { commands, pushCommands } = useXCardSurface(sid);
useEffect(() => {
const cmds: XAgentCommand_v0_9[] = [
{
version: 'v0.9',
createSurface: { surfaceId: sid, catalogId: FORM_CATALOG_ID },
},
{
version: 'v0.9',
updateDataModel: {
surfaceId: sid,
path: '/form',
value: { ...form, submitting, submitted, error },
},
},
{
version: 'v0.9',
updateComponents: {
surfaceId: sid,
components: [
{
id: 'root',
component: 'FormPreview',
form: { path: '/form' },
disabled: Boolean(disabled),
},
],
},
},
];
pushCommands(cmds);
}, [disabled, error, form, pushCommands, sid, submitted, submitting]);
const handleSubmit = (values: Record<string, unknown>) => {
void run(async () => {
await onSubmit(values);
});
};
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>
);
};