feat: AI 对话支持 A2UI 表单/审查/图表与 Excel 读取

This commit is contained in:
2026-08-04 14:41:40 +08:00
parent f07ffdc64c
commit 50c44e4410
51 changed files with 11588 additions and 175 deletions

View File

@@ -0,0 +1,699 @@
import React, { useEffect, 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, Flex, Popconfirm, Steps, Table, Tag, Typography } from 'antd';
import type { TableProps } from 'antd';
import type {
AiReviewRow,
AiReviewSchema,
AiReviewSection,
AiReviewSectionStatus,
AiReviewSectionType,
} from './types';
const REVIEW_CATALOG_ID = 'gongxue-review-catalog';
registerCatalog({
catalogId: REVIEW_CATALOG_ID,
components: {
ReviewPreview: {
type: 'object',
properties: {
review: { type: 'object' },
disabled: { type: 'boolean' },
activeKey: { type: 'string' },
activeType: { type: 'string' },
submittingKey: { type: ['string', 'null'] },
submittingGroup: { type: 'boolean' },
error: { type: ['string', 'null'] },
},
},
},
});
function surfaceId(reviewId: string): string {
return `review-${reviewId}`;
}
const SECTION_TYPE_LABELS: Record<AiReviewSectionType, string> = {
students: '学生',
rooms: '宿舍',
transfers: '换宿',
checkins: '入住记录',
};
const SECTION_ORDER: AiReviewSectionType[] = [
'students',
'rooms',
'transfers',
'checkins',
];
const SECTION_DEPENDENCIES: Record<AiReviewSectionType, AiReviewSectionType[]> = {
students: [],
rooms: [],
transfers: ['students', 'rooms'],
checkins: [],
};
function sectionType(section: Pick<AiReviewSection, 'key' | 'type'>): AiReviewSectionType {
if (
section.type === 'students' ||
section.type === 'rooms' ||
section.type === 'transfers' ||
section.type === 'checkins'
) {
return section.type;
}
const key = section.key as AiReviewSectionType;
if (key === 'students' || key === 'rooms' || key === 'transfers' || key === 'checkins') {
return key;
}
const prefix = SECTION_ORDER.find((type) => section.key.startsWith(`${type}_`));
return prefix ?? 'students';
}
function sectionCount(section: AiReviewSection): number {
return section.rows.length;
}
function sectionStatus(section: AiReviewSection): AiReviewSectionStatus {
return section.status ?? 'pending';
}
function sectionResultText(section: AiReviewSection): string {
if (!section.resultSummary) return '';
try {
const parsed = JSON.parse(section.resultSummary) as { message?: unknown };
if (typeof parsed.message === 'string') return parsed.message;
} catch {
// Older data may store a plain text summary.
}
return section.resultSummary;
}
const SECTION_STATUS_LABELS: Record<AiReviewSectionStatus, string> = {
pending: '待确认',
submitted: '已导入',
failed: '失败',
skipped: '已跳过',
};
type GroupStatus = 'pending' | 'partial' | 'submitted' | 'failed' | 'importing';
const GROUP_STATUS_LABELS: Record<GroupStatus, string> = {
pending: '待确认',
partial: '部分完成',
submitted: '已导入',
failed: '失败',
importing: '导入中',
};
function groupSections(
sections: AiReviewSection[],
type: AiReviewSectionType,
): AiReviewSection[] {
return sections.filter((section) => sectionType(section) === type);
}
function groupStatus(
sections: AiReviewSection[],
type: AiReviewSectionType,
submittingKey: string | null,
submittingGroup: boolean,
activeType?: AiReviewSectionType,
): GroupStatus {
const items = groupSections(sections, type);
if (items.length === 0) return 'pending';
if (
(submittingGroup && type === activeType) ||
items.some((item) => submittingKey === item.key)
) {
return 'importing';
}
if (items.some((item) => sectionStatus(item) === 'failed')) return 'failed';
if (items.every((item) => sectionStatus(item) === 'submitted')) return 'submitted';
return 'partial';
}
function dependencyHint(
sections: AiReviewSection[],
type: AiReviewSectionType,
): { step: number; title: string } | null {
for (const dependencyType of SECTION_DEPENDENCIES[type] ?? []) {
const matches = groupSections(sections, dependencyType);
if (matches.length === 0) {
return { step: -1, title: SECTION_TYPE_LABELS[dependencyType] };
}
for (const section of matches) {
if (sectionStatus(section) !== 'submitted') {
return { step: sections.indexOf(section), title: section.title };
}
}
}
return null;
}
function errorMessage(reason: unknown): string {
if (reason instanceof Error) return reason.message;
if (reason && typeof reason === 'object' && 'message' in reason) {
return String((reason as { message?: unknown }).message ?? '确认失败,请稍后重试');
}
return '确认失败,请稍后重试';
}
function SectionTable({ section }: { section: AiReviewSection }) {
const columns: TableProps<AiReviewRow>['columns'] = section.columns.map((column) => ({
title: column.title,
dataIndex: column.key,
key: column.key,
ellipsis: true,
render: (value: unknown) =>
value === null || value === undefined || value === '' ? (
<Typography.Text type="secondary">-</Typography.Text>
) : (
String(value)
),
}));
return (
<Table<AiReviewRow>
size="small"
rowKey="__rowKey"
columns={columns}
dataSource={section.rows.map((row, index) => ({ ...row, __rowKey: `row-${index}` }))}
pagination={{ pageSize: 10, size: 'small', hideOnSinglePage: true }}
scroll={{ x: 'max-content' }}
/>
);
}
interface ReviewPreviewProps {
review?: AiReviewSchema;
disabled?: boolean;
onAction?: (name: string, context: Record<string, unknown>) => void;
}
const ReviewPreview: React.FC<ReviewPreviewProps> = ({ review, disabled, onAction }) => {
if (!review) return null;
const submitted = review.status === 'submitted';
const expired = review.status === 'expired';
const runtime = review as unknown as {
submitting?: boolean;
activeKey?: string;
activeType?: string;
submittingKey?: string | null;
submittingGroup?: boolean;
error?: string | null;
};
const submitting = Boolean(runtime.submitting);
const submittingKey = runtime.submittingKey ?? null;
const submittingGroup = Boolean(runtime.submittingGroup);
const sections = review.sections;
const presentTypes = SECTION_ORDER.filter((type) =>
sections.some((section) => sectionType(section) === type),
);
const activeType = presentTypes.includes(runtime.activeType as AiReviewSectionType)
? (runtime.activeType as AiReviewSectionType)
: presentTypes[0];
if (!activeType) return null;
const activeSection =
sections.find((section) => section.key === runtime.activeKey) ??
groupSections(sections, activeType)[0];
const activeStatus = activeSection ? sectionStatus(activeSection) : 'pending';
const dependency =
activeSection === undefined ? null : dependencyHint(sections, sectionType(activeSection));
const typeItems = presentTypes.map((type, index) => {
const items = groupSections(sections, type);
const status = groupStatus(sections, type, submittingKey, submittingGroup, activeType);
const stepStatus: 'finish' | 'error' | 'process' | 'wait' =
status === 'submitted'
? 'finish'
: status === 'failed'
? 'error'
: status === 'importing' || type === activeType
? 'process'
: 'wait';
return {
key: type,
title: `${SECTION_TYPE_LABELS[type]}${items.reduce((sum, item) => sum + sectionCount(item), 0)}`,
content: GROUP_STATUS_LABELS[status],
status: stepStatus,
index,
};
});
const group = groupSections(sections, activeType);
const typeTotal = group.reduce((sum, section) => sum + sectionCount(section), 0);
const groupDep = dependencyHint(sections, activeType);
const groupReady =
!submitted &&
!expired &&
!disabled &&
!submitting &&
!submittingKey &&
!submittingGroup &&
group.length > 0 &&
!group.every((section) => sectionStatus(section) === 'submitted') &&
!groupDep;
const anyRunning = submitting || Boolean(submittingKey) || submittingGroup;
const allIssues = sections.flatMap((section) => section.issues);
const allRows = sections.reduce((sum, section) => sum + sectionCount(section), 0);
return (
<div className="ai-chat-review-card">
<Flex justify="space-between" align="center" wrap gap={8}>
<Typography.Text strong className="ai-chat-review-card__title">
{review.title}
</Typography.Text>
{submitted ? (
<Tag color="success"></Tag>
) : expired ? (
<Tag></Tag>
) : anyRunning ? (
<Tag color="processing"></Tag>
) : (
<Tag color="gold"></Tag>
)}
</Flex>
{review.summary && (
<Typography.Paragraph type="secondary" className="ai-chat-review-card__summary">
{review.summary}
</Typography.Paragraph>
)}
{expired && (
<Alert
type="warning"
showIcon
message="此导入预览已被新的预览替代,已失效"
description="如需导入,请使用最新的预览卡。"
/>
)}
<Steps
size="small"
current={Math.max(0, typeItems.findIndex((item) => item.key === activeType))}
items={typeItems.map((item) => ({
key: item.key,
title: item.title,
content: item.content,
status: item.status,
}))}
onChange={(index) => {
const type = typeItems[index]?.key;
if (type) onAction?.('review:selectType', { type });
}}
/>
{activeType && (
<Flex vertical gap={8} className="ai-chat-review-card__group">
<Flex justify="space-between" align="center" wrap gap={8}>
<Flex vertical gap={2}>
<Typography.Text strong>
{SECTION_TYPE_LABELS[activeType]} · {group.length} / {typeTotal}
</Typography.Text>
<Typography.Text type="secondary">
{GROUP_STATUS_LABELS[
groupStatus(sections, activeType, submittingKey, submittingGroup, activeType)
]}
</Typography.Text>
</Flex>
{groupDep && (
<Alert
type="warning"
showIcon
message={
groupDep.step === -1
? `${groupDep.title}」分表尚未生成或导入,请先确认前置步骤`
: `请先确认第 ${groupDep.step + 1} 步「${groupDep.title}`
}
/>
)}
{!submitted && !expired && group.length > 0 && (
<Popconfirm
title={`确认导入本组 ${group.length} 张表?`}
description={`本组共 ${typeTotal} 行,确认后将按顺序逐表导入。`}
okText="确认导入"
cancelText="取消"
disabled={!groupReady}
onConfirm={() =>
onAction?.('review:confirmGroup', {
reviewId: review.id,
type: activeType,
})
}
>
<Button
type="primary"
loading={submittingGroup}
disabled={!groupReady}
>
{groupStatus(sections, activeType, submittingKey, submittingGroup, activeType) ===
'submitted'
? '已导入'
: `确认本组 ${group.length} 张表`}
</Button>
</Popconfirm>
)}
</Flex>
<Flex vertical gap={8} className="ai-chat-review-card__sheets">
{group.map((section, index) => {
const status = sectionStatus(section);
const dep = dependencyHint(sections, sectionType(section));
const canConfirm =
!submitted &&
!expired &&
!disabled &&
!anyRunning &&
status !== 'submitted' &&
status !== 'skipped' &&
!dep;
return (
<Flex
key={section.key}
justify="space-between"
align="center"
wrap
gap={8}
className="ai-chat-review-card__sheet"
onClick={() =>
onAction?.('review:selectStep', { sectionKey: section.key })
}
>
<Flex vertical gap={2} style={{ minWidth: 160 }}>
<Typography.Text>
{index + 1}. {section.title}
{section.sheet ? (
<Typography.Text type="secondary">{section.sheet}</Typography.Text>
) : null}
</Typography.Text>
<Typography.Text type="secondary">
{sectionCount(section)} · {SECTION_STATUS_LABELS[status]}
</Typography.Text>
</Flex>
<Button
size="small"
loading={submittingKey === section.key}
disabled={!canConfirm}
onClick={(event) => {
event.stopPropagation();
onAction?.('review:confirmStep', {
reviewId: review.id,
sectionKey: section.key,
});
}}
>
{status === 'failed'
? '重试导入本步'
: status === 'submitted'
? '已导入'
: status === 'skipped'
? '已跳过'
: expired
? '已失效'
: '确认导入本步'}
</Button>
</Flex>
);
})}
</Flex>
{activeSection && (
<Flex vertical gap={8} className="ai-chat-review-card__step">
{activeSection.issues.length > 0 && (
<Alert
type="warning"
showIcon
message={`${activeSection.title}${activeSection.issues.length} 条待处理`}
description={
<ul className="ai-chat-review__issues">
{activeSection.issues.slice(0, 20).map((issue, issueIndex) => (
<li key={issueIndex}>{issue}</li>
))}
</ul>
}
/>
)}
<SectionTable section={activeSection} />
{dependency && (
<Alert
type="warning"
showIcon
message={
dependency.step === -1
? `${dependency.title}」分表尚未生成或导入,请先确认前置步骤`
: `请先确认第 ${dependency.step + 1} 步「${dependency.title}`
}
/>
)}
{activeStatus === 'failed' && (
<Alert type="error" showIcon message="本步导入失败,可重试" />
)}
{activeSection.resultSummary && activeStatus === 'submitted' && (
<Typography.Text type="secondary" className="ai-chat-review-card__step-result">
{sectionResultText(activeSection)}
</Typography.Text>
)}
</Flex>
)}
</Flex>
)}
<Flex justify="space-between" align="center" wrap gap={8} className="ai-chat-review-card__footer">
<Typography.Text type="secondary">
{allRows} {allIssues.length}
</Typography.Text>
{!submitted && !expired && (
<Popconfirm
title={`确认导入全部 ${sections.length} 张表?`}
description={`全部共 ${allRows} 行,将按类型与依赖顺序逐表导入。`}
okText="确认导入"
cancelText="取消"
disabled={submitting || anyRunning || disabled}
onConfirm={() => onAction?.('review:submit', { reviewId: review.id })}
>
<Button
type="primary"
loading={submitting}
disabled={disabled || anyRunning}
>
</Button>
</Popconfirm>
)}
</Flex>
{submitted && <Alert type="success" showIcon message="已确认导入,数据已入库" />}
{runtime.error && (
<Alert
type="error"
showIcon
message={runtime.error}
className="ai-chat-review-card__step-error"
/>
)}
</div>
);
};
export interface DynamicReviewProps {
review: AiReviewSchema;
disabled?: boolean;
messageId?: number;
onSubmit: (reviewId: string) => void | Promise<void>;
onConfirmStep?: (
messageId: number | undefined,
reviewId: string,
sectionKey: string,
) => AiReviewSchema | Promise<AiReviewSchema> | void;
onConfirmGroup?: (
messageId: number | undefined,
reviewId: string,
type: AiReviewSectionType,
) => AiReviewSchema | Promise<AiReviewSchema> | void;
}
/**
* Batch-import review card rendered through the official A2UI renderer
* (@ant-design/x-card). Sections are grouped by business type; each sheet is
* confirmed independently, the whole type group can be confirmed together, or
* everything can be confirmed in one flow.
*/
export const DynamicReview: React.FC<DynamicReviewProps> = ({
review,
disabled,
messageId,
onSubmit,
onConfirmStep,
onConfirmGroup,
}) => {
const [submitting, setSubmitting] = useState(false);
const [submittingKey, setSubmittingKey] = useState<string | null>(null);
const [submittingGroup, setSubmittingGroup] = useState(false);
const [activeKey, setActiveKey] = useState<string | undefined>(undefined);
const [activeType, setActiveType] = useState<AiReviewSectionType | undefined>(undefined);
const [localReview, setLocalReview] = useState<AiReviewSchema>(review);
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(() => {
setLocalReview(review);
const types = SECTION_ORDER.filter((type) =>
review.sections.some((section) => sectionType(section) === type),
);
const preferredType =
activeType && types.includes(activeType) ? activeType : types[0];
setActiveType(preferredType);
setActiveKey((current) =>
current &&
review.sections.some(
(section) => section.key === current && sectionType(section) === preferredType,
)
? current
: review.sections.find((section) => sectionType(section) === preferredType)?.key,
);
}, [activeType, review]);
useEffect(() => {
const sid = surfaceId(localReview.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: REVIEW_CATALOG_ID },
});
}
cmds.push({
version: 'v0.9',
updateDataModel: {
surfaceId: sid,
path: '/review',
value: {
...localReview,
submitting,
activeKey,
activeType,
submittingKey,
submittingGroup,
error,
},
},
});
cmds.push({
version: 'v0.9',
updateComponents: {
surfaceId: sid,
components: [
{
id: 'root',
component: 'ReviewPreview',
review: { path: '/review' },
disabled: Boolean(disabled),
},
],
},
});
setCommands([...cmds]);
}, [activeKey, activeType, disabled, error, localReview, submitting, submittingGroup, submittingKey]);
const handleSubmit = async (reviewId: string) => {
if (submitting) return;
setSubmitting(true);
setError(null);
try {
await onSubmit(reviewId);
} catch (reason) {
setError(errorMessage(reason));
} finally {
setSubmitting(false);
}
};
const handleConfirmStep = async (reviewId: string, sectionKey: string) => {
if (submittingKey) return;
setSubmittingKey(sectionKey);
setError(null);
try {
const updated = await onConfirmStep?.(messageId, reviewId, sectionKey);
if (updated) setLocalReview(updated);
} catch (reason) {
setError(errorMessage(reason));
} finally {
setSubmittingKey(null);
}
};
const handleConfirmGroup = async (reviewId: string, type: AiReviewSectionType) => {
if (submittingGroup) return;
setSubmittingGroup(true);
setError(null);
try {
const updated = await onConfirmGroup?.(messageId, reviewId, type);
if (updated) setLocalReview(updated);
} catch (reason) {
setError(errorMessage(reason));
} finally {
setSubmittingGroup(false);
}
};
const handleAction = (payload: ActionPayload) => {
const context = payload.context ?? {};
if (payload.name === 'review:submit') {
const reviewId =
typeof context.reviewId === 'string' ? context.reviewId : localReview.id;
void handleSubmit(reviewId);
return;
}
if (payload.name === 'review:selectType') {
const type = context.type as AiReviewSectionType | undefined;
if (type && SECTION_ORDER.includes(type)) {
setActiveType(type);
setActiveKey(
localReview.sections.find((section) => sectionType(section) === type)?.key,
);
}
return;
}
if (payload.name === 'review:selectStep') {
if (typeof context.sectionKey === 'string') {
const section = localReview.sections.find(
(item) => item.key === context.sectionKey,
);
setActiveKey(context.sectionKey);
if (section) setActiveType(sectionType(section));
}
return;
}
if (payload.name === 'review:confirmStep') {
const reviewId =
typeof context.reviewId === 'string' ? context.reviewId : localReview.id;
if (typeof context.sectionKey === 'string') {
void handleConfirmStep(reviewId, context.sectionKey);
}
return;
}
if (payload.name === 'review:confirmGroup') {
const reviewId =
typeof context.reviewId === 'string' ? context.reviewId : localReview.id;
const type = context.type as AiReviewSectionType | undefined;
if (type && SECTION_ORDER.includes(type)) {
void handleConfirmGroup(reviewId, type);
}
}
};
return (
<div className="ai-chat-review">
<XCard.Box
components={{ ReviewPreview }}
commands={commands}
onAction={handleAction}
>
<XCard.Card id={surfaceId(localReview.id)} />
</XCard.Box>
{error && <Alert type="error" showIcon message={error} className="ai-chat-review__error" />}
</div>
);
};
export default DynamicReview;