From 4b60e0c01825e397a2b5ed46757997de03878711 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 8 Aug 2026 09:43:48 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20code-review=20=E5=AE=A1=E6=9F=A5?= =?UTF-8?q?=E9=97=AE=E9=A2=98=E4=BF=AE=E5=A4=8D=20+=20A2UI=20=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E8=A1=A5=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standards 轴: - 移除 uiArtifacts.ts 的 payloadOf 死代码残留 - AttendanceDevices 残留 any 类型化(补 ClassroomOption.status 字段) - 批量考勤纠错区分业务失败(已结算/无权限)与系统错误, 前端提示精确到两类数量 - Dashboard queryFn 六段重复校验块收敛为 safeValidate 助手 Spec 轴: - 补齐阶段 3.4 A2UI 测试:图表空数据占位、ArtifactErrorBoundary 降级隔离、useSubmissionState/useXCardSurface 单测(7 用例) - 阶段 2.2 补两处引导:教师工作台区分「今日无课」与「未分配班级」、 班级花名册空态带「添加学员」动作 - 契约文档修正 DynamicReview 状态管理描述(多提交点如实说明) aislop 剩余 16 警告均为必要豁免(类型边界/声明式 SQL 配置/既有文件规模) --- .../AiChat/bubble.integration.test.tsx | 46 +++++ .../src/components/AiChat/uiArtifacts.ts | 5 - .../AiChat/useSubmissionState.test.tsx | 161 ++++++++++++++++++ .../Attendance/LessonAttendanceDetail.tsx | 18 +- apps/admin/src/pages/Attendance/admin.tsx | 18 +- apps/admin/src/pages/AttendanceDevices.tsx | 3 +- .../src/pages/Classes/ClassDetailTabs.tsx | 14 ++ apps/admin/src/pages/Dashboard/index.tsx | 91 ++++------ .../src/pages/TeacherWorkspace/index.tsx | 10 +- .../agent-context/pending-tasks.service.ts | 23 ++- .../attendance-records.controller.ts | 18 +- scripts/a2ui-contract.md | 7 +- 12 files changed, 323 insertions(+), 91 deletions(-) create mode 100644 apps/admin/src/components/AiChat/useSubmissionState.test.tsx diff --git a/apps/admin/src/components/AiChat/bubble.integration.test.tsx b/apps/admin/src/components/AiChat/bubble.integration.test.tsx index 761bc73..7123e64 100644 --- a/apps/admin/src/components/AiChat/bubble.integration.test.tsx +++ b/apps/admin/src/components/AiChat/bubble.integration.test.tsx @@ -4,6 +4,7 @@ import { Bubble } from '@ant-design/x'; import { afterEach, describe, expect, it } from 'vitest'; import { aiBubbleRoles, conversationStatusMeta } from './AiChatDrawer'; import { AiMessageContent } from './AiMessageContent'; +import { ArtifactErrorBoundary } from './ArtifactErrorBoundary'; import { DynamicChart } from './DynamicChart'; import { DynamicForm } from './DynamicForm'; import { DynamicReview } from './DynamicReview'; @@ -504,4 +505,49 @@ describe('AI chat bubble rendering', () => { expect(container.textContent).toContain(label); expect(container.querySelector('.ai-chat-chart-card canvas')).not.toBeNull(); }); + + it('renders an empty-data placeholder instead of a blank chart', async () => { + const chart: AiChartSchema = { + id: 'chart-empty', + title: '空图表', + chartType: 'bar', + columns: [{ key: 'name', title: '名称' }], + rows: [], + }; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + + expect(container.textContent).toContain('空图表'); + expect(container.textContent).toContain('暂无数据'); + expect(container.querySelector('canvas')).toBeNull(); + }); + + it('degrades a single failing artifact to an error card without crashing the bubble', async () => { + const Bomb: React.FC = () => { + throw new Error('boom'); + }; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + // 用无错误边界的兄弟节点 + 错误边界内的炸弹组件验证隔离 + await act(async () => { + root?.render( +
+
正常内容
+ + + +
, + ); + }); + + expect(container.querySelector('.neighbor')?.textContent).toContain('正常内容'); + expect(container.textContent).toContain('表单渲染失败'); + }); }); diff --git a/apps/admin/src/components/AiChat/uiArtifacts.ts b/apps/admin/src/components/AiChat/uiArtifacts.ts index 6b90bc5..1e9b302 100644 --- a/apps/admin/src/components/AiChat/uiArtifacts.ts +++ b/apps/admin/src/components/AiChat/uiArtifacts.ts @@ -25,10 +25,6 @@ export function mergeById( return next; } -function payloadOf(artifact: AiArtifactSchema): unknown { - return artifact.payload && typeof artifact.payload === 'object' ? artifact.payload : {}; -} - /** * 将统一 artifact 归入 uiArtifacts。 * @@ -41,7 +37,6 @@ export function mergeArtifactIntoMessage( artifact: AiArtifactSchema, ): AiChatMessage { message.uiArtifacts = mergeById(message.uiArtifacts, artifact); - void payloadOf(artifact); return message; } diff --git a/apps/admin/src/components/AiChat/useSubmissionState.test.tsx b/apps/admin/src/components/AiChat/useSubmissionState.test.tsx new file mode 100644 index 0000000..4fc8a42 --- /dev/null +++ b/apps/admin/src/components/AiChat/useSubmissionState.test.tsx @@ -0,0 +1,161 @@ +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it } from 'vitest'; +import { useSubmissionState, useXCardSurface } from './useSubmissionState'; + +// 项目未安装 @testing-library/react,用 createRoot + harness 组件暴露 hook API +let container: HTMLDivElement | null = null; +let root: ReturnType | null = null; +let api: ReturnType | null = null; +let surface: ReturnType | null = null; +let surfaceId = 'surface-test'; + +function Harness() { + api = useSubmissionState(); + surface = useXCardSurface(surfaceId); + return null; +} + +function renderHarness(): void { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root?.render(); + }); +} + +afterEach(async () => { + if (root) await act(async () => root?.unmount()); + container?.remove(); + root = null; + container = null; + api = null; + surface = null; + surfaceId = 'surface-test'; +}); + +describe('useSubmissionState', () => { + it('tracks submitting during the task and succeeds afterwards', async () => { + renderHarness(); + let resolveTask: () => void = () => undefined; + const task = () => + new Promise((resolve) => { + resolveTask = resolve; + }); + + let promise: Promise | undefined; + act(() => { + promise = api?.run(task); + }); + expect(api?.submitting).toBe(true); + expect(api?.error).toBeNull(); + + await act(async () => { + resolveTask(); + await promise; + }); + expect(api?.submitting).toBe(false); + expect(api?.submitted).toBe(true); + expect(api?.error).toBeNull(); + }); + + it('captures the error message and keeps submitted false on failure', async () => { + renderHarness(); + const failing = () => { + throw new Error('接口 500'); + }; + + await act(async () => { + await api?.run(failing); + }); + expect(api?.submitting).toBe(false); + expect(api?.submitted).toBe(false); + expect(api?.error).toBe('接口 500'); + }); + + it('normalizes non-Error rejections to a generic message', async () => { + renderHarness(); + const failing = () => Promise.reject('raw string'); + + await act(async () => { + await api?.run(failing); + }); + expect(api?.error).toBe('提交失败,请稍后重试'); + }); + + it('ignores re-entrant calls while a task is in flight', async () => { + renderHarness(); + let resolveTask: () => void = () => undefined; + const task = () => + new Promise((resolve) => { + resolveTask = resolve; + }); + let secondRan = false; + + act(() => { + void api?.run(task); + void api?.run(() => { + secondRan = true; + }); + }); + expect(secondRan).toBe(false); + + await act(async () => { + resolveTask(); + }); + expect(api?.submitted).toBe(true); + }); + + it('reset clears submitted and error states', async () => { + renderHarness(); + await act(async () => { + await api?.run(() => undefined); + }); + expect(api?.submitted).toBe(true); + + act(() => { + api?.reset(); + }); + expect(api?.submitted).toBe(false); + expect(api?.error).toBeNull(); + }); +}); + +describe('useXCardSurface', () => { + it('deduplicates createSurface commands for the same surface id', () => { + renderHarness(); + act(() => { + surface?.pushCommands([ + { version: 'v0.9', createSurface: { surfaceId: 'surface-test', catalogId: 'catalog' } }, + { version: 'v0.9', updateDataModel: { surfaceId: 'surface-test', path: '/x', value: 1 } }, + ]); + surface?.pushCommands([ + { version: 'v0.9', createSurface: { surfaceId: 'surface-test', catalogId: 'catalog' } }, + { version: 'v0.9', updateDataModel: { surfaceId: 'surface-test', path: '/x', value: 2 } }, + ]); + }); + const createCommands = surface?.commands.filter((command) => 'createSurface' in command); + expect(createCommands).toHaveLength(1); + expect(surface?.commands).toHaveLength(3); + }); + + it('resets the command stream when the surface id changes', () => { + renderHarness(); + act(() => { + surface?.pushCommands([ + { version: 'v0.9', createSurface: { surfaceId: 'surface-test', catalogId: 'c' } }, + ]); + }); + expect(surface?.commands).toHaveLength(1); + + surfaceId = 'surface-other'; + act(() => { + root?.render(); + surface?.pushCommands([ + { version: 'v0.9', createSurface: { surfaceId: 'surface-other', catalogId: 'c' } }, + ]); + }); + expect(surface?.commands).toHaveLength(1); + }); +}); diff --git a/apps/admin/src/pages/Attendance/LessonAttendanceDetail.tsx b/apps/admin/src/pages/Attendance/LessonAttendanceDetail.tsx index c6e64b8..b11e718 100644 --- a/apps/admin/src/pages/Attendance/LessonAttendanceDetail.tsx +++ b/apps/admin/src/pages/Attendance/LessonAttendanceDetail.tsx @@ -116,10 +116,12 @@ const LessonAttendanceDetail: React.FC = ({ onOk: async () => { setBatchUpdating(status); try { - const res = await api.put<{ updated: number; failed: number; failedIds: number[] }>( - '/attendance-records/batch-status', - { ids: targetIds, status }, - ); + const res = await api.put<{ + updated: number; + failed: number; + failedIds: number[]; + systemFailed: number; + }>('/attendance-records/batch-status', { ids: targetIds, status }); if (cancelledRef.current) return; const failedSet = new Set(res.failedIds); setRecords((items) => @@ -130,7 +132,13 @@ const LessonAttendanceDetail: React.FC = ({ ), ); message.success(`已更新 ${res.updated} 条记录`); - if (res.failed > 0) message.warning(`有 ${res.failed} 条更新失败(可能已结算)`); + if (res.failed > 0) { + const bizFailed = res.failed - (res.systemFailed ?? 0); + const parts: string[] = []; + if (bizFailed > 0) parts.push(`${bizFailed} 条可能已结算`); + if (res.systemFailed > 0) parts.push(`${res.systemFailed} 条系统错误`); + message.warning(`有 ${res.failed} 条更新失败:${parts.join(',')}`); + } } catch (error: unknown) { if (cancelledRef.current) return; message.error(getErrorMessage(error, '批量更新失败')); diff --git a/apps/admin/src/pages/Attendance/admin.tsx b/apps/admin/src/pages/Attendance/admin.tsx index 610062a..1b9522e 100644 --- a/apps/admin/src/pages/Attendance/admin.tsx +++ b/apps/admin/src/pages/Attendance/admin.tsx @@ -422,13 +422,21 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit onOk: async () => { setBatchCorrecting(true); try { - const res = await api.put<{ updated: number; failed: number; failedIds: number[] }>( - '/attendance-records/batch-status', - { ids: selectedRecordIds, status: nextStatus }, - ); + const res = await api.put<{ + updated: number; + failed: number; + failedIds: number[]; + systemFailed: number; + }>('/attendance-records/batch-status', { ids: selectedRecordIds, status: nextStatus }); setSelectedRecordIds([]); message.success(`已更新 ${res.updated} 条记录`); - if (res.failed > 0) message.warning(`有 ${res.failed} 条更新失败(可能已结算)`); + if (res.failed > 0) { + const bizFailed = res.failed - (res.systemFailed ?? 0); + const parts: string[] = []; + if (bizFailed > 0) parts.push(`${bizFailed} 条可能已结算`); + if (res.systemFailed > 0) parts.push(`${res.systemFailed} 条系统错误`); + message.warning(`有 ${res.failed} 条更新失败:${parts.join(',')}`); + } void refetchRecords(); } catch (e: unknown) { message.error(getErrorMessage(e, '批量更新失败')); diff --git a/apps/admin/src/pages/AttendanceDevices.tsx b/apps/admin/src/pages/AttendanceDevices.tsx index f75d369..3618960 100644 --- a/apps/admin/src/pages/AttendanceDevices.tsx +++ b/apps/admin/src/pages/AttendanceDevices.tsx @@ -18,6 +18,7 @@ interface ClassroomOption { id: number; name: string; building?: string | null; + status?: string; } interface AttendanceDeviceRow { @@ -63,7 +64,7 @@ const AttendanceDevicesPage: React.FC = () => { classrooms: validateResponse( classroomOptionsSchema, classroomList, - ).filter((item: any) => item.status !== 'archived'), + ).filter((item: ClassroomOption) => item.status !== 'archived'), }; }, }); diff --git a/apps/admin/src/pages/Classes/ClassDetailTabs.tsx b/apps/admin/src/pages/Classes/ClassDetailTabs.tsx index 155978d..03e9c45 100644 --- a/apps/admin/src/pages/Classes/ClassDetailTabs.tsx +++ b/apps/admin/src/pages/Classes/ClassDetailTabs.tsx @@ -22,6 +22,7 @@ import { DownloadOutlined, PlusOutlined } from '@ant-design/icons'; import dayjs from 'dayjs'; import { useUserStore } from '../../store/user/userStore'; import PermissionButton from '../../components/PermissionButton'; +import { QueryEmpty } from '../../components/QueryState'; import { message } from '../../ui/app-message'; import { buildTeacherCandidateOptions, type TeacherCandidateUser } from './teacher-candidate'; @@ -324,6 +325,19 @@ export const ClassStudentsTab: React.FC<{ columns={studentColumns} dataSource={students} rowKey="id" + locale={{ + emptyText: ( + , + onClick: onOpen, + }} + /> + ), + }} pagination={{ defaultPageSize: 20, showSizeChanger: true, diff --git a/apps/admin/src/pages/Dashboard/index.tsx b/apps/admin/src/pages/Dashboard/index.tsx index 1fb2467..143f206 100644 --- a/apps/admin/src/pages/Dashboard/index.tsx +++ b/apps/admin/src/pages/Dashboard/index.tsx @@ -105,72 +105,41 @@ const DashboardPage: React.FC = () => { console.error('部分看板数据加载失败', rejected); message.warning(`有 ${rejected.length} 项数据加载失败,其余数据已正常显示`); } - let stats: DashboardStats | null = null; - const s = value(settled[0]); - if (s) { + // 校验失败的模块降级为对应空值,不影响其他模块 + type ValidateSchema = Parameters[0]; + const safeValidate = (schema: ValidateSchema, raw: unknown, fallback: T): T => { try { - stats = validateResponse(dashboardStatsSchema, s); + return validateResponse(schema, raw); } catch (e) { console.error(e); + return fallback; } - } - let roomRanking: Array<{ roomNumber: string; total: string }> = []; - const rr = value(settled[1]); - if (rr) { - try { - roomRanking = validateResponse>( - roomRankingSchema, - rr, - ); - } catch (e) { - console.error(e); - } - } - let classRanking: { top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] } = { - top: [], - bottom: [], }; - const cr = value(settled[2]); - if (cr) { - try { - classRanking = validateResponse<{ - top: ClassAttendanceRank[]; - bottom: ClassAttendanceRank[]; - }>(classAttendanceRankingSchema, cr); - } catch (e) { - console.error(e); - } - } - let ganttData: GanttRoom[] = []; - const g = value(settled[3]); - if (g) { - try { - ganttData = validateResponse(ganttRoomsSchema, g); - } catch (e) { - console.error(e); - } - } - let classroomOccupancy: ClassroomOccupancy[] = []; - const co = value(settled[4]); - if (co) { - try { - classroomOccupancy = validateResponse( - classroomOccupanciesSchema, - co, - ); - } catch (e) { - console.error(e); - } - } - let classroomUtil: ClassroomUtilStats | null = null; - const cu = value(settled[5]); - if (cu) { - try { - classroomUtil = validateResponse(classroomUtilStatsSchema, cu); - } catch (e) { - console.error(e); - } - } + const stats = safeValidate( + dashboardStatsSchema, + value(settled[0]), + null, + ); + const roomRanking = safeValidate>( + roomRankingSchema, + value(settled[1]), + [], + ); + const classRanking = safeValidate<{ + top: ClassAttendanceRank[]; + bottom: ClassAttendanceRank[]; + }>(classAttendanceRankingSchema, value(settled[2]), { top: [], bottom: [] }); + const ganttData = safeValidate(ganttRoomsSchema, value(settled[3]), []); + const classroomOccupancy = safeValidate( + classroomOccupanciesSchema, + value(settled[4]), + [], + ); + const classroomUtil = safeValidate( + classroomUtilStatsSchema, + value(settled[5]), + null, + ); return { stats, classRanking, classroomOccupancy, ganttData, roomRanking, classroomUtil }; }, }); diff --git a/apps/admin/src/pages/TeacherWorkspace/index.tsx b/apps/admin/src/pages/TeacherWorkspace/index.tsx index 0995269..abd418c 100644 --- a/apps/admin/src/pages/TeacherWorkspace/index.tsx +++ b/apps/admin/src/pages/TeacherWorkspace/index.tsx @@ -6,7 +6,7 @@ import { teacherWorkspaceSchema } from '../../api/schemas'; import { Card, Tabs, Table, Tag, Empty, Spin } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import api from '../../api'; -import { QueryErrorState } from '../../components/QueryState'; +import { QueryErrorState, QueryEmpty } from '../../components/QueryState'; interface AssignedClass { classId: number; @@ -189,7 +189,13 @@ const TeacherWorkspacePage: React.FC = () => { }} /> ) : ( - + ), }, diff --git a/apps/server/src/agent-context/pending-tasks.service.ts b/apps/server/src/agent-context/pending-tasks.service.ts index 17a78ad..b49f910 100644 --- a/apps/server/src/agent-context/pending-tasks.service.ts +++ b/apps/server/src/agent-context/pending-tasks.service.ts @@ -29,6 +29,19 @@ function can(context: AgentToolContext, permission: string): boolean { return context.isSuperAdmin || context.permissions.includes(permission); } +/** 教师作用域:仅统计该教师任课班级的数据 */ +function teacherScoped(scope: StudentAccessScope): boolean { + return scope.type === 'teacher'; +} + +/** 教师作用域下的班级过滤 SQL 片段(需配合 class_student cs 别名) */ +const TEACHER_CLASS_FILTER_SQL = + 'AND cs.class_id IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = ?)'; + +function teacherParams(scope: StudentAccessScope): unknown[] { + return scope.type === 'teacher' ? [scope.userId] : []; +} + function today(): string { const now = new Date(); const year = now.getFullYear(); @@ -78,10 +91,10 @@ const TASKS: readonly TaskDefinition[] = [ INNER JOIN class_student cs ON cs.student_id = s.id AND cs.status = 'active' LEFT JOIN occupancies o ON o.student_id = s.id AND o.status = 'active' WHERE s.status = 'active' - ${scope.type === 'teacher' ? 'AND cs.class_id IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = ?)' : ''} + ${teacherScoped(scope) ? TEACHER_CLASS_FILTER_SQL : ''} AND o.id IS NULL `, - params: scope.type === 'teacher' ? [scope.userId] : [], + params: teacherParams(scope), }), }, { @@ -93,13 +106,13 @@ const TASKS: readonly TaskDefinition[] = [ sql: (scope) => ({ sql: ` SELECT COUNT(DISTINCT o.id) AS cnt FROM occupancies o - ${scope.type === 'teacher' ? 'INNER JOIN class_student cs ON cs.student_id = o.student_id AND cs.status = \'active\'' : ''} + ${teacherScoped(scope) ? "INNER JOIN class_student cs ON cs.student_id = o.student_id AND cs.status = 'active'" : ''} LEFT JOIN bills b ON b.student_id = o.student_id WHERE o.status = 'active' - ${scope.type === 'teacher' ? 'AND cs.class_id IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = ?)' : ''} + ${teacherScoped(scope) ? TEACHER_CLASS_FILTER_SQL : ''} AND b.id IS NULL `, - params: scope.type === 'teacher' ? [scope.userId] : [], + params: teacherParams(scope), }), }, { diff --git a/apps/server/src/attendance/attendance-records.controller.ts b/apps/server/src/attendance/attendance-records.controller.ts index c8c9d6b..1549327 100644 --- a/apps/server/src/attendance/attendance-records.controller.ts +++ b/apps/server/src/attendance/attendance-records.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Put, Delete, Body, Param, Query, Request, Res, BadRequestException, ForbiddenException, ParseIntPipe } from '@nestjs/common'; +import { Controller, Get, Post, Put, Delete, Body, Param, Query, Request, Res, BadRequestException, ForbiddenException, NotFoundException, ParseIntPipe } from '@nestjs/common'; import type { Response } from 'express'; import { AttendanceControllerBase, RequestUser } from './attendance.controller-base'; import { AttendanceService } from './attendance.service'; @@ -246,6 +246,7 @@ export class AttendanceRecordsController extends AttendanceControllerBase { ) { const failedIds: number[] = []; let updated = 0; + let systemFailed = 0; for (const id of dto.ids) { try { const existing = await this.service.findAttendanceRecord(id); @@ -255,15 +256,24 @@ export class AttendanceRecordsController extends AttendanceControllerBase { if (existing.classId != null) await this.assertClassAccess(req, existing.classId); await this.service.update(id, { status: dto.status, remark: dto.remark }); updated += 1; - } catch { + } catch (error) { failedIds.push(id); + // 业务失败(已结算/无权限等)与系统错误区分开,便于前端给出准确提示 + if ( + error instanceof BadRequestException || + error instanceof NotFoundException || + error instanceof ForbiddenException + ) { + continue; + } + systemFailed += 1; } } await logAudit(this.logService, req, { module: '考勤管理', action: '批量修改考勤状态', targetId: 0, targetType: 'attendanceRecord', - detail: `批量 ${dto.ids.length} 条 → ${dto.status},成功 ${updated},失败 ${failedIds.length}`, + detail: `批量 ${dto.ids.length} 条 → ${dto.status},成功 ${updated},失败 ${failedIds.length},系统错误 ${systemFailed}`, }); - return { updated, failed: failedIds.length, failedIds }; + return { updated, failed: failedIds.length, failedIds, systemFailed }; } // ── Update a single attendance record ── diff --git a/scripts/a2ui-contract.md b/scripts/a2ui-contract.md index ad0beae..ba2067d 100644 --- a/scripts/a2ui-contract.md +++ b/scripts/a2ui-contract.md @@ -156,7 +156,8 @@ interface AiUiForm { `sseReducer`/`message-mappers` 恢复为 legacy 字段(`message.forms/reviews/charts`, 已在 `types.ts` 标注 deprecated),渲染层在 uiArtifacts 为空时回退使用; 新数据一律只写 `uiArtifacts`。 -- **A2UI 组件实现**:`DynamicForm`/`DynamicReview`/`DynamicChart` 共享 - `useSubmissionState`(提交状态:防重复提交 + 失败可重试)与 `useXCardSurface` - (XCard commands 增量更新 + createSurface 自动去重)。 +- **A2UI 组件实现**:`DynamicForm`/`DynamicChart` 使用 `useSubmissionState` + (单提交点:防重复提交 + 失败可重试)与 `useXCardSurface`(XCard commands + 增量更新 + createSurface 自动去重);`DynamicReview` 因「逐表确认/逐组确认/全部入库」 + 多提交点并存,保留组件内多提交状态,仅共享 `useXCardSurface`。 - **提交与确认接口**:与上文契约一致,未变更。