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`。
- **提交与确认接口**:与上文契约一致,未变更。