Files
gongxue-base/apps/admin/src/components/AiChat/DynamicReview.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

597 lines
20 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, 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>
);
};