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 = { students: '学生', rooms: '宿舍', transfers: '换宿', checkins: '入住记录', }; const SECTION_ORDER: AiReviewSectionType[] = [ 'students', 'rooms', 'transfers', 'checkins', ]; const SECTION_DEPENDENCIES: Record = { students: [], rooms: [], transfers: ['students', 'rooms'], checkins: [], }; function sectionType(section: Pick): 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 = { pending: '待确认', submitted: '已导入', failed: '失败', skipped: '已跳过', }; type GroupStatus = 'pending' | 'partial' | 'submitted' | 'failed' | 'importing'; const GROUP_STATUS_LABELS: Record = { 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['columns'] = section.columns.map((column) => ({ title: column.title, dataIndex: column.key, key: column.key, ellipsis: true, render: (value: unknown) => value === null || value === undefined || value === '' ? ( - ) : ( String(value) ), })); return ( 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) => void; } const ReviewPreview: React.FC = ({ 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 (
{review.title} {submitted ? ( 已导入 ) : expired ? ( 已失效 ) : anyRunning ? ( 导入中 ) : ( 待确认 )} {review.summary && ( {review.summary} )} {expired && ( )} 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 && ( {SECTION_TYPE_LABELS[activeType]} · 共 {group.length} 张表 / {typeTotal} 行 {GROUP_STATUS_LABELS[ groupStatus(sections, activeType, submittingKey, submittingGroup, activeType) ]} {groupDep && ( )} {!submitted && !expired && group.length > 0 && ( onAction?.('review:confirmGroup', { reviewId: review.id, type: activeType, }) } > )} {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 ( onAction?.('review:selectStep', { sectionKey: section.key }) } > {index + 1}. {section.title} {section.sheet ? ( ({section.sheet}) ) : null} {sectionCount(section)} 行 · {SECTION_STATUS_LABELS[status]} ); })} {activeSection && ( {activeSection.issues.length > 0 && ( {activeSection.issues.slice(0, 20).map((issue, issueIndex) => (
  • {issue}
  • ))} } /> )} {dependency && ( )} {activeStatus === 'failed' && ( )} {activeSection.resultSummary && activeStatus === 'submitted' && ( {sectionResultText(activeSection)} )}
    )}
    )} 共 {allRows} 行,含 {allIssues.length} 条提示 {!submitted && !expired && ( onAction?.('review:submit', { reviewId: review.id })} > )} {submitted && } {runtime.error && ( )}
    ); }; export interface DynamicReviewProps { review: AiReviewSchema; disabled?: boolean; messageId?: number; onSubmit: (reviewId: string) => void | Promise; onConfirmStep?: ( messageId: number | undefined, reviewId: string, sectionKey: string, ) => AiReviewSchema | Promise | void; onConfirmGroup?: ( messageId: number | undefined, reviewId: string, type: AiReviewSectionType, ) => AiReviewSchema | Promise | 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 = ({ review, disabled, messageId, onSubmit, onConfirmStep, onConfirmGroup, }) => { const [submitting, setSubmitting] = useState(false); const [submittingKey, setSubmittingKey] = useState(null); const [submittingGroup, setSubmittingGroup] = useState(false); const [activeKey, setActiveKey] = useState(undefined); const [activeType, setActiveType] = useState(undefined); const [localReview, setLocalReview] = useState(review); const [error, setError] = useState(null); const commandsRef = useRef([]); const [commands, setCommands] = useState([]); const idRef = useRef(''); 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 (
    {error && }
    ); }; export default DynamicReview;