feat: AI 对话支持 A2UI 表单/审查/图表与 Agent 工具
This commit is contained in:
@@ -1,15 +1,35 @@
|
||||
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';
|
||||
import {
|
||||
XCard,
|
||||
registerCatalog,
|
||||
type ActionPayload,
|
||||
type XAgentCommand_v0_9,
|
||||
} from '@ant-design/x-card';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Flex,
|
||||
Popconfirm,
|
||||
Steps,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
type TableProps,
|
||||
} from 'antd';
|
||||
import type { AiReviewRow, AiReviewSchema, AiReviewSection, AiReviewSectionType } from './types';
|
||||
import {
|
||||
GROUP_STATUS_LABELS,
|
||||
SECTION_ORDER,
|
||||
SECTION_STATUS_LABELS,
|
||||
SECTION_TYPE_LABELS,
|
||||
dependencyHint,
|
||||
groupSections,
|
||||
groupStatus,
|
||||
sectionCount,
|
||||
sectionResultText,
|
||||
sectionStatus,
|
||||
sectionType,
|
||||
} from './reviewSection';
|
||||
|
||||
const REVIEW_CATALOG_ID = 'gongxue-review-catalog';
|
||||
|
||||
@@ -35,125 +55,6 @@ 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) {
|
||||
@@ -188,7 +89,14 @@ function SectionTable({ section }: { section: AiReviewSection }) {
|
||||
}
|
||||
|
||||
interface ReviewPreviewProps {
|
||||
review?: AiReviewSchema;
|
||||
review?: AiReviewSchema & {
|
||||
submitting?: boolean;
|
||||
activeKey?: string;
|
||||
activeType?: AiReviewSectionType;
|
||||
submittingKey?: string | null;
|
||||
submittingGroup?: boolean;
|
||||
error?: string | null;
|
||||
};
|
||||
disabled?: boolean;
|
||||
onAction?: (name: string, context: Record<string, unknown>) => void;
|
||||
}
|
||||
@@ -197,27 +105,19 @@ const ReviewPreview: React.FC<ReviewPreviewProps> = ({ review, disabled, onActio
|
||||
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 submitting = Boolean(review.submitting);
|
||||
const submittingKey = review.submittingKey ?? null;
|
||||
const submittingGroup = Boolean(review.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)
|
||||
const activeType = presentTypes.includes(review.activeType as AiReviewSectionType)
|
||||
? (review.activeType as AiReviewSectionType)
|
||||
: presentTypes[0];
|
||||
if (!activeType) return null;
|
||||
const activeSection =
|
||||
sections.find((section) => section.key === runtime.activeKey) ??
|
||||
sections.find((section) => section.key === review.activeKey) ??
|
||||
groupSections(sections, activeType)[0];
|
||||
const activeStatus = activeSection ? sectionStatus(activeSection) : 'pending';
|
||||
const dependency =
|
||||
@@ -289,7 +189,10 @@ const ReviewPreview: React.FC<ReviewPreviewProps> = ({ review, disabled, onActio
|
||||
)}
|
||||
<Steps
|
||||
size="small"
|
||||
current={Math.max(0, typeItems.findIndex((item) => item.key === activeType))}
|
||||
current={Math.max(
|
||||
0,
|
||||
typeItems.findIndex((item) => item.key === activeType),
|
||||
)}
|
||||
items={typeItems.map((item) => ({
|
||||
key: item.key,
|
||||
title: item.title,
|
||||
@@ -309,16 +212,18 @@ const ReviewPreview: React.FC<ReviewPreviewProps> = ({ review, disabled, onActio
|
||||
{SECTION_TYPE_LABELS[activeType]} · 共 {group.length} 张表 / {typeTotal} 行
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
{GROUP_STATUS_LABELS[
|
||||
groupStatus(sections, activeType, submittingKey, submittingGroup, activeType)
|
||||
]}
|
||||
{
|
||||
GROUP_STATUS_LABELS[
|
||||
groupStatus(sections, activeType, submittingKey, submittingGroup, activeType)
|
||||
]
|
||||
}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
{groupDep && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={
|
||||
title={
|
||||
groupDep.step === -1
|
||||
? `「${groupDep.title}」分表尚未生成或导入,请先确认前置步骤`
|
||||
: `请先确认第 ${groupDep.step + 1} 步「${groupDep.title}」`
|
||||
@@ -339,11 +244,7 @@ const ReviewPreview: React.FC<ReviewPreviewProps> = ({ review, disabled, onActio
|
||||
})
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={submittingGroup}
|
||||
disabled={!groupReady}
|
||||
>
|
||||
<Button type="primary" loading={submittingGroup} disabled={!groupReady}>
|
||||
{groupStatus(sections, activeType, submittingKey, submittingGroup, activeType) ===
|
||||
'submitted'
|
||||
? '已导入'
|
||||
@@ -372,9 +273,7 @@ const ReviewPreview: React.FC<ReviewPreviewProps> = ({ review, disabled, onActio
|
||||
wrap
|
||||
gap={8}
|
||||
className="ai-chat-review-card__sheet"
|
||||
onClick={() =>
|
||||
onAction?.('review:selectStep', { sectionKey: section.key })
|
||||
}
|
||||
onClick={() => onAction?.('review:selectStep', { sectionKey: section.key })}
|
||||
>
|
||||
<Flex vertical gap={2} style={{ minWidth: 160 }}>
|
||||
<Typography.Text>
|
||||
@@ -419,7 +318,7 @@ const ReviewPreview: React.FC<ReviewPreviewProps> = ({ review, disabled, onActio
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={`${activeSection.title}:${activeSection.issues.length} 条待处理`}
|
||||
title={`${activeSection.title}:${activeSection.issues.length} 条待处理`}
|
||||
description={
|
||||
<ul className="ai-chat-review__issues">
|
||||
{activeSection.issues.slice(0, 20).map((issue, issueIndex) => (
|
||||
@@ -434,7 +333,7 @@ const ReviewPreview: React.FC<ReviewPreviewProps> = ({ review, disabled, onActio
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={
|
||||
title={
|
||||
dependency.step === -1
|
||||
? `「${dependency.title}」分表尚未生成或导入,请先确认前置步骤`
|
||||
: `请先确认第 ${dependency.step + 1} 步「${dependency.title}」`
|
||||
@@ -453,7 +352,13 @@ const ReviewPreview: React.FC<ReviewPreviewProps> = ({ review, disabled, onActio
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
<Flex justify="space-between" align="center" wrap gap={8} className="ai-chat-review-card__footer">
|
||||
<Flex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap
|
||||
gap={8}
|
||||
className="ai-chat-review-card__footer"
|
||||
>
|
||||
<Typography.Text type="secondary">
|
||||
共 {allRows} 行,含 {allIssues.length} 条提示
|
||||
</Typography.Text>
|
||||
@@ -466,22 +371,18 @@ const ReviewPreview: React.FC<ReviewPreviewProps> = ({ review, disabled, onActio
|
||||
disabled={submitting || anyRunning || disabled}
|
||||
onConfirm={() => onAction?.('review:submit', { reviewId: review.id })}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={submitting}
|
||||
disabled={disabled || anyRunning}
|
||||
>
|
||||
<Button type="primary" loading={submitting} disabled={disabled || anyRunning}>
|
||||
全部确认并入库
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Flex>
|
||||
{submitted && <Alert type="success" showIcon message="已确认导入,数据已入库" />}
|
||||
{runtime.error && (
|
||||
{review.error && (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={runtime.error}
|
||||
title={review.error}
|
||||
className="ai-chat-review-card__step-error"
|
||||
/>
|
||||
)}
|
||||
@@ -525,6 +426,8 @@ export const DynamicReview: React.FC<DynamicReviewProps> = ({
|
||||
const [submittingGroup, setSubmittingGroup] = useState(false);
|
||||
const [activeKey, setActiveKey] = useState<string | undefined>(undefined);
|
||||
const [activeType, setActiveType] = useState<AiReviewSectionType | undefined>(undefined);
|
||||
const activeTypeRef = useRef<AiReviewSectionType | undefined>(activeType);
|
||||
activeTypeRef.current = activeType;
|
||||
const [localReview, setLocalReview] = useState<AiReviewSchema>(review);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const commandsRef = useRef<XAgentCommand_v0_9[]>([]);
|
||||
@@ -537,7 +440,9 @@ export const DynamicReview: React.FC<DynamicReviewProps> = ({
|
||||
review.sections.some((section) => sectionType(section) === type),
|
||||
);
|
||||
const preferredType =
|
||||
activeType && types.includes(activeType) ? activeType : types[0];
|
||||
activeTypeRef.current && types.includes(activeTypeRef.current)
|
||||
? activeTypeRef.current
|
||||
: types[0];
|
||||
setActiveType(preferredType);
|
||||
setActiveKey((current) =>
|
||||
current &&
|
||||
@@ -547,7 +452,7 @@ export const DynamicReview: React.FC<DynamicReviewProps> = ({
|
||||
? current
|
||||
: review.sections.find((section) => sectionType(section) === preferredType)?.key,
|
||||
);
|
||||
}, [activeType, review]);
|
||||
}, [review]);
|
||||
|
||||
useEffect(() => {
|
||||
const sid = surfaceId(localReview.id);
|
||||
@@ -593,7 +498,16 @@ export const DynamicReview: React.FC<DynamicReviewProps> = ({
|
||||
},
|
||||
});
|
||||
setCommands([...cmds]);
|
||||
}, [activeKey, activeType, disabled, error, localReview, submitting, submittingGroup, submittingKey]);
|
||||
}, [
|
||||
activeKey,
|
||||
activeType,
|
||||
disabled,
|
||||
error,
|
||||
localReview,
|
||||
submitting,
|
||||
submittingGroup,
|
||||
submittingKey,
|
||||
]);
|
||||
|
||||
const handleSubmit = async (reviewId: string) => {
|
||||
if (submitting) return;
|
||||
@@ -639,8 +553,7 @@ export const DynamicReview: React.FC<DynamicReviewProps> = ({
|
||||
const handleAction = (payload: ActionPayload) => {
|
||||
const context = payload.context ?? {};
|
||||
if (payload.name === 'review:submit') {
|
||||
const reviewId =
|
||||
typeof context.reviewId === 'string' ? context.reviewId : localReview.id;
|
||||
const reviewId = typeof context.reviewId === 'string' ? context.reviewId : localReview.id;
|
||||
void handleSubmit(reviewId);
|
||||
return;
|
||||
}
|
||||
@@ -648,33 +561,27 @@ export const DynamicReview: React.FC<DynamicReviewProps> = ({
|
||||
const type = context.type as AiReviewSectionType | undefined;
|
||||
if (type && SECTION_ORDER.includes(type)) {
|
||||
setActiveType(type);
|
||||
setActiveKey(
|
||||
localReview.sections.find((section) => sectionType(section) === type)?.key,
|
||||
);
|
||||
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,
|
||||
);
|
||||
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;
|
||||
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 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);
|
||||
@@ -684,16 +591,10 @@ export const DynamicReview: React.FC<DynamicReviewProps> = ({
|
||||
|
||||
return (
|
||||
<div className="ai-chat-review">
|
||||
<XCard.Box
|
||||
components={{ ReviewPreview }}
|
||||
commands={commands}
|
||||
onAction={handleAction}
|
||||
>
|
||||
<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" />}
|
||||
{error && <Alert type="error" showIcon title={error} className="ai-chat-review__error" />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DynamicReview;
|
||||
|
||||
Reference in New Issue
Block a user