UX 缺陷修复: - 校验失败不再卡死弹窗按钮(Users/Roles/Bills) - 押金收取/批量收取/添加分期防重复提交;切换房型重置勾选 - AI 表单/批量确认不再出现"假成功" - Dashboard 各数据模块独立加载,单接口失败不再整页清零 - 房间可视化加载失败显示错误态而非永久转圈 - 学生编辑表单回填前重置,避免字段残留污染 - 覆盖式导入增加二次确认;恢复默认考勤时段确认并同步表单 - 金数据匹配关闭前确认,同步中禁止误关 体验提升: - 新增统一 QueryErrorState/QueryEmpty,20+ 页面加载失败显示错误态与重试 - 全局 ErrorBoundary + RouteKeeper 逐页兜底 - 新增 usePageVisible/useVisibleRefetch,保活页面切回自动刷新数据 - 新增首次登录角色引导 RoleTour 与业务闭环 NextStepHint 引导卡 - 重构 A2UI:useSubmissionState/useXCardSurface 收敛状态与命令生命周期, ArtifactErrorBoundary 渲染降级,图表空数据占位 - AI 助手欢迎语与建议话术按角色定制,会话列表空态引导 - 更新 a2ui-contract.md 契约文档说明实现现状
597 lines
20 KiB
TypeScript
597 lines
20 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
||
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 { useXCardSurface } from './useSubmissionState';
|
||
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';
|
||
|
||
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}`;
|
||
}
|
||
|
||
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 & {
|
||
submitting?: boolean;
|
||
activeKey?: string;
|
||
activeType?: AiReviewSectionType;
|
||
submittingKey?: string | null;
|
||
submittingGroup?: boolean;
|
||
error?: string | null;
|
||
};
|
||
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 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(review.activeType as AiReviewSectionType)
|
||
? (review.activeType as AiReviewSectionType)
|
||
: presentTypes[0];
|
||
if (!activeType) return null;
|
||
const activeSection =
|
||
sections.find((section) => section.key === review.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
|
||
title="此导入预览已被新的预览替代,已失效"
|
||
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
|
||
title={
|
||
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
|
||
title={`${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
|
||
title={
|
||
dependency.step === -1
|
||
? `「${dependency.title}」分表尚未生成或导入,请先确认前置步骤`
|
||
: `请先确认第 ${dependency.step + 1} 步「${dependency.title}」`
|
||
}
|
||
/>
|
||
)}
|
||
{activeStatus === 'failed' && (
|
||
<Alert type="error" showIcon title="本步导入失败,可重试" />
|
||
)}
|
||
{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 title="已确认导入,数据已入库" />}
|
||
{review.error && (
|
||
<Alert
|
||
type="error"
|
||
showIcon
|
||
title={review.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 activeTypeRef = useRef<AiReviewSectionType | undefined>(activeType);
|
||
activeTypeRef.current = activeType;
|
||
const [localReview, setLocalReview] = useState<AiReviewSchema>(review);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const sid = surfaceId(localReview.id);
|
||
const { commands, pushCommands } = useXCardSurface(sid);
|
||
|
||
useEffect(() => {
|
||
setLocalReview(review);
|
||
const types = SECTION_ORDER.filter((type) =>
|
||
review.sections.some((section) => sectionType(section) === type),
|
||
);
|
||
const preferredType =
|
||
activeTypeRef.current && types.includes(activeTypeRef.current)
|
||
? activeTypeRef.current
|
||
: 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,
|
||
);
|
||
}, [review]);
|
||
|
||
useEffect(() => {
|
||
const cmds: XAgentCommand_v0_9[] = [
|
||
{
|
||
version: 'v0.9',
|
||
createSurface: { surfaceId: sid, catalogId: REVIEW_CATALOG_ID },
|
||
},
|
||
{
|
||
version: 'v0.9',
|
||
updateDataModel: {
|
||
surfaceId: sid,
|
||
path: '/review',
|
||
value: {
|
||
...localReview,
|
||
submitting,
|
||
activeKey,
|
||
activeType,
|
||
submittingKey,
|
||
submittingGroup,
|
||
error,
|
||
},
|
||
},
|
||
},
|
||
{
|
||
version: 'v0.9',
|
||
updateComponents: {
|
||
surfaceId: sid,
|
||
components: [
|
||
{
|
||
id: 'root',
|
||
component: 'ReviewPreview',
|
||
review: { path: '/review' },
|
||
disabled: Boolean(disabled),
|
||
},
|
||
],
|
||
},
|
||
},
|
||
];
|
||
pushCommands(cmds);
|
||
}, [
|
||
activeKey,
|
||
activeType,
|
||
disabled,
|
||
error,
|
||
localReview,
|
||
pushCommands,
|
||
sid,
|
||
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 title={error} className="ai-chat-review__error" />}
|
||
</div>
|
||
);
|
||
};
|