fix: code-review 审查问题修复 + A2UI 测试补齐
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 配置/既有文件规模)
This commit is contained in:
@@ -4,6 +4,7 @@ import { Bubble } from '@ant-design/x';
|
|||||||
import { afterEach, describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
import { aiBubbleRoles, conversationStatusMeta } from './AiChatDrawer';
|
import { aiBubbleRoles, conversationStatusMeta } from './AiChatDrawer';
|
||||||
import { AiMessageContent } from './AiMessageContent';
|
import { AiMessageContent } from './AiMessageContent';
|
||||||
|
import { ArtifactErrorBoundary } from './ArtifactErrorBoundary';
|
||||||
import { DynamicChart } from './DynamicChart';
|
import { DynamicChart } from './DynamicChart';
|
||||||
import { DynamicForm } from './DynamicForm';
|
import { DynamicForm } from './DynamicForm';
|
||||||
import { DynamicReview } from './DynamicReview';
|
import { DynamicReview } from './DynamicReview';
|
||||||
@@ -504,4 +505,49 @@ describe('AI chat bubble rendering', () => {
|
|||||||
expect(container.textContent).toContain(label);
|
expect(container.textContent).toContain(label);
|
||||||
expect(container.querySelector('.ai-chat-chart-card canvas')).not.toBeNull();
|
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(<DynamicChart chart={chart} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
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(
|
||||||
|
<div>
|
||||||
|
<div className="neighbor">正常内容</div>
|
||||||
|
<ArtifactErrorBoundary title="表单">
|
||||||
|
<Bomb />
|
||||||
|
</ArtifactErrorBoundary>
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.querySelector('.neighbor')?.textContent).toContain('正常内容');
|
||||||
|
expect(container.textContent).toContain('表单渲染失败');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,10 +25,6 @@ export function mergeById<T extends { id: string }>(
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
function payloadOf(artifact: AiArtifactSchema): unknown {
|
|
||||||
return artifact.payload && typeof artifact.payload === 'object' ? artifact.payload : {};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将统一 artifact 归入 uiArtifacts。
|
* 将统一 artifact 归入 uiArtifacts。
|
||||||
*
|
*
|
||||||
@@ -41,7 +37,6 @@ export function mergeArtifactIntoMessage(
|
|||||||
artifact: AiArtifactSchema,
|
artifact: AiArtifactSchema,
|
||||||
): AiChatMessage {
|
): AiChatMessage {
|
||||||
message.uiArtifacts = mergeById<AiArtifactSchema>(message.uiArtifacts, artifact);
|
message.uiArtifacts = mergeById<AiArtifactSchema>(message.uiArtifacts, artifact);
|
||||||
void payloadOf(artifact);
|
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
161
apps/admin/src/components/AiChat/useSubmissionState.test.tsx
Normal file
161
apps/admin/src/components/AiChat/useSubmissionState.test.tsx
Normal file
@@ -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<typeof createRoot> | null = null;
|
||||||
|
let api: ReturnType<typeof useSubmissionState> | null = null;
|
||||||
|
let surface: ReturnType<typeof useXCardSurface> | 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(<Harness />);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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<void>((resolve) => {
|
||||||
|
resolveTask = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
let promise: Promise<void> | 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<void>((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(<Harness />);
|
||||||
|
surface?.pushCommands([
|
||||||
|
{ version: 'v0.9', createSurface: { surfaceId: 'surface-other', catalogId: 'c' } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
expect(surface?.commands).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -116,10 +116,12 @@ const LessonAttendanceDetail: React.FC<LessonAttendanceDetailProps> = ({
|
|||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
setBatchUpdating(status);
|
setBatchUpdating(status);
|
||||||
try {
|
try {
|
||||||
const res = await api.put<{ updated: number; failed: number; failedIds: number[] }>(
|
const res = await api.put<{
|
||||||
'/attendance-records/batch-status',
|
updated: number;
|
||||||
{ ids: targetIds, status },
|
failed: number;
|
||||||
);
|
failedIds: number[];
|
||||||
|
systemFailed: number;
|
||||||
|
}>('/attendance-records/batch-status', { ids: targetIds, status });
|
||||||
if (cancelledRef.current) return;
|
if (cancelledRef.current) return;
|
||||||
const failedSet = new Set(res.failedIds);
|
const failedSet = new Set(res.failedIds);
|
||||||
setRecords((items) =>
|
setRecords((items) =>
|
||||||
@@ -130,7 +132,13 @@ const LessonAttendanceDetail: React.FC<LessonAttendanceDetailProps> = ({
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
message.success(`已更新 ${res.updated} 条记录`);
|
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) {
|
} catch (error: unknown) {
|
||||||
if (cancelledRef.current) return;
|
if (cancelledRef.current) return;
|
||||||
message.error(getErrorMessage(error, '批量更新失败'));
|
message.error(getErrorMessage(error, '批量更新失败'));
|
||||||
|
|||||||
@@ -422,13 +422,21 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
|
|||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
setBatchCorrecting(true);
|
setBatchCorrecting(true);
|
||||||
try {
|
try {
|
||||||
const res = await api.put<{ updated: number; failed: number; failedIds: number[] }>(
|
const res = await api.put<{
|
||||||
'/attendance-records/batch-status',
|
updated: number;
|
||||||
{ ids: selectedRecordIds, status: nextStatus },
|
failed: number;
|
||||||
);
|
failedIds: number[];
|
||||||
|
systemFailed: number;
|
||||||
|
}>('/attendance-records/batch-status', { ids: selectedRecordIds, status: nextStatus });
|
||||||
setSelectedRecordIds([]);
|
setSelectedRecordIds([]);
|
||||||
message.success(`已更新 ${res.updated} 条记录`);
|
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();
|
void refetchRecords();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
message.error(getErrorMessage(e, '批量更新失败'));
|
message.error(getErrorMessage(e, '批量更新失败'));
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface ClassroomOption {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
building?: string | null;
|
building?: string | null;
|
||||||
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AttendanceDeviceRow {
|
interface AttendanceDeviceRow {
|
||||||
@@ -63,7 +64,7 @@ const AttendanceDevicesPage: React.FC = () => {
|
|||||||
classrooms: validateResponse<ClassroomOption[]>(
|
classrooms: validateResponse<ClassroomOption[]>(
|
||||||
classroomOptionsSchema,
|
classroomOptionsSchema,
|
||||||
classroomList,
|
classroomList,
|
||||||
).filter((item: any) => item.status !== 'archived'),
|
).filter((item: ClassroomOption) => item.status !== 'archived'),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { DownloadOutlined, PlusOutlined } from '@ant-design/icons';
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { useUserStore } from '../../store/user/userStore';
|
import { useUserStore } from '../../store/user/userStore';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
|
import { QueryEmpty } from '../../components/QueryState';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
import { buildTeacherCandidateOptions, type TeacherCandidateUser } from './teacher-candidate';
|
import { buildTeacherCandidateOptions, type TeacherCandidateUser } from './teacher-candidate';
|
||||||
|
|
||||||
@@ -324,6 +325,19 @@ export const ClassStudentsTab: React.FC<{
|
|||||||
columns={studentColumns}
|
columns={studentColumns}
|
||||||
dataSource={students}
|
dataSource={students}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
|
locale={{
|
||||||
|
emptyText: (
|
||||||
|
<QueryEmpty
|
||||||
|
description="班级还没有学员"
|
||||||
|
action={{
|
||||||
|
label: '添加学员',
|
||||||
|
type: 'primary',
|
||||||
|
icon: <PlusOutlined />,
|
||||||
|
onClick: onOpen,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
pagination={{
|
pagination={{
|
||||||
defaultPageSize: 20,
|
defaultPageSize: 20,
|
||||||
showSizeChanger: true,
|
showSizeChanger: true,
|
||||||
|
|||||||
@@ -105,72 +105,41 @@ const DashboardPage: React.FC = () => {
|
|||||||
console.error('部分看板数据加载失败', rejected);
|
console.error('部分看板数据加载失败', rejected);
|
||||||
message.warning(`有 ${rejected.length} 项数据加载失败,其余数据已正常显示`);
|
message.warning(`有 ${rejected.length} 项数据加载失败,其余数据已正常显示`);
|
||||||
}
|
}
|
||||||
let stats: DashboardStats | null = null;
|
// 校验失败的模块降级为对应空值,不影响其他模块
|
||||||
const s = value(settled[0]);
|
type ValidateSchema = Parameters<typeof validateResponse>[0];
|
||||||
if (s) {
|
const safeValidate = <T,>(schema: ValidateSchema, raw: unknown, fallback: T): T => {
|
||||||
try {
|
try {
|
||||||
stats = validateResponse<DashboardStats>(dashboardStatsSchema, s);
|
return validateResponse<T>(schema, raw);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
return fallback;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
let roomRanking: Array<{ roomNumber: string; total: string }> = [];
|
|
||||||
const rr = value(settled[1]);
|
|
||||||
if (rr) {
|
|
||||||
try {
|
|
||||||
roomRanking = validateResponse<Array<{ roomNumber: string; total: string }>>(
|
|
||||||
roomRankingSchema,
|
|
||||||
rr,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let classRanking: { top: ClassAttendanceRank[]; bottom: ClassAttendanceRank[] } = {
|
|
||||||
top: [],
|
|
||||||
bottom: [],
|
|
||||||
};
|
};
|
||||||
const cr = value(settled[2]);
|
const stats = safeValidate<DashboardStats | null>(
|
||||||
if (cr) {
|
dashboardStatsSchema,
|
||||||
try {
|
value(settled[0]),
|
||||||
classRanking = validateResponse<{
|
null,
|
||||||
top: ClassAttendanceRank[];
|
);
|
||||||
bottom: ClassAttendanceRank[];
|
const roomRanking = safeValidate<Array<{ roomNumber: string; total: string }>>(
|
||||||
}>(classAttendanceRankingSchema, cr);
|
roomRankingSchema,
|
||||||
} catch (e) {
|
value(settled[1]),
|
||||||
console.error(e);
|
[],
|
||||||
}
|
);
|
||||||
}
|
const classRanking = safeValidate<{
|
||||||
let ganttData: GanttRoom[] = [];
|
top: ClassAttendanceRank[];
|
||||||
const g = value(settled[3]);
|
bottom: ClassAttendanceRank[];
|
||||||
if (g) {
|
}>(classAttendanceRankingSchema, value(settled[2]), { top: [], bottom: [] });
|
||||||
try {
|
const ganttData = safeValidate<GanttRoom[]>(ganttRoomsSchema, value(settled[3]), []);
|
||||||
ganttData = validateResponse<GanttRoom[]>(ganttRoomsSchema, g);
|
const classroomOccupancy = safeValidate<ClassroomOccupancy[]>(
|
||||||
} catch (e) {
|
classroomOccupanciesSchema,
|
||||||
console.error(e);
|
value(settled[4]),
|
||||||
}
|
[],
|
||||||
}
|
);
|
||||||
let classroomOccupancy: ClassroomOccupancy[] = [];
|
const classroomUtil = safeValidate<ClassroomUtilStats | null>(
|
||||||
const co = value(settled[4]);
|
classroomUtilStatsSchema,
|
||||||
if (co) {
|
value(settled[5]),
|
||||||
try {
|
null,
|
||||||
classroomOccupancy = validateResponse<ClassroomOccupancy[]>(
|
);
|
||||||
classroomOccupanciesSchema,
|
|
||||||
co,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let classroomUtil: ClassroomUtilStats | null = null;
|
|
||||||
const cu = value(settled[5]);
|
|
||||||
if (cu) {
|
|
||||||
try {
|
|
||||||
classroomUtil = validateResponse<ClassroomUtilStats>(classroomUtilStatsSchema, cu);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { stats, classRanking, classroomOccupancy, ganttData, roomRanking, classroomUtil };
|
return { stats, classRanking, classroomOccupancy, ganttData, roomRanking, classroomUtil };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { teacherWorkspaceSchema } from '../../api/schemas';
|
|||||||
import { Card, Tabs, Table, Tag, Empty, Spin } from 'antd';
|
import { Card, Tabs, Table, Tag, Empty, Spin } from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { QueryErrorState } from '../../components/QueryState';
|
import { QueryErrorState, QueryEmpty } from '../../components/QueryState';
|
||||||
|
|
||||||
interface AssignedClass {
|
interface AssignedClass {
|
||||||
classId: number;
|
classId: number;
|
||||||
@@ -189,7 +189,13 @@ const TeacherWorkspacePage: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Empty description="今日无排课" />
|
<QueryEmpty
|
||||||
|
description={
|
||||||
|
data?.assignedClasses.length
|
||||||
|
? '今天没有课程安排,可在「排课管理」查看完整课表;如应有课程请联系教务管理员'
|
||||||
|
: '暂无分配的班级与课程,请联系教务管理员安排'
|
||||||
|
}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,19 @@ function can(context: AgentToolContext, permission: string): boolean {
|
|||||||
return context.isSuperAdmin || context.permissions.includes(permission);
|
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 {
|
function today(): string {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const year = now.getFullYear();
|
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'
|
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'
|
LEFT JOIN occupancies o ON o.student_id = s.id AND o.status = 'active'
|
||||||
WHERE s.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
|
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: (scope) => ({
|
||||||
sql: `
|
sql: `
|
||||||
SELECT COUNT(DISTINCT o.id) AS cnt FROM occupancies o
|
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
|
LEFT JOIN bills b ON b.student_id = o.student_id
|
||||||
WHERE o.status = 'active'
|
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
|
AND b.id IS NULL
|
||||||
`,
|
`,
|
||||||
params: scope.type === 'teacher' ? [scope.userId] : [],
|
params: teacherParams(scope),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 type { Response } from 'express';
|
||||||
import { AttendanceControllerBase, RequestUser } from './attendance.controller-base';
|
import { AttendanceControllerBase, RequestUser } from './attendance.controller-base';
|
||||||
import { AttendanceService } from './attendance.service';
|
import { AttendanceService } from './attendance.service';
|
||||||
@@ -246,6 +246,7 @@ export class AttendanceRecordsController extends AttendanceControllerBase {
|
|||||||
) {
|
) {
|
||||||
const failedIds: number[] = [];
|
const failedIds: number[] = [];
|
||||||
let updated = 0;
|
let updated = 0;
|
||||||
|
let systemFailed = 0;
|
||||||
for (const id of dto.ids) {
|
for (const id of dto.ids) {
|
||||||
try {
|
try {
|
||||||
const existing = await this.service.findAttendanceRecord(id);
|
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);
|
if (existing.classId != null) await this.assertClassAccess(req, existing.classId);
|
||||||
await this.service.update(id, { status: dto.status, remark: dto.remark });
|
await this.service.update(id, { status: dto.status, remark: dto.remark });
|
||||||
updated += 1;
|
updated += 1;
|
||||||
} catch {
|
} catch (error) {
|
||||||
failedIds.push(id);
|
failedIds.push(id);
|
||||||
|
// 业务失败(已结算/无权限等)与系统错误区分开,便于前端给出准确提示
|
||||||
|
if (
|
||||||
|
error instanceof BadRequestException ||
|
||||||
|
error instanceof NotFoundException ||
|
||||||
|
error instanceof ForbiddenException
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
systemFailed += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '考勤管理', action: '批量修改考勤状态', targetId: 0, targetType: 'attendanceRecord',
|
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 ──
|
// ── Update a single attendance record ──
|
||||||
|
|||||||
@@ -156,7 +156,8 @@ interface AiUiForm {
|
|||||||
`sseReducer`/`message-mappers` 恢复为 legacy 字段(`message.forms/reviews/charts`,
|
`sseReducer`/`message-mappers` 恢复为 legacy 字段(`message.forms/reviews/charts`,
|
||||||
已在 `types.ts` 标注 deprecated),渲染层在 uiArtifacts 为空时回退使用;
|
已在 `types.ts` 标注 deprecated),渲染层在 uiArtifacts 为空时回退使用;
|
||||||
新数据一律只写 `uiArtifacts`。
|
新数据一律只写 `uiArtifacts`。
|
||||||
- **A2UI 组件实现**:`DynamicForm`/`DynamicReview`/`DynamicChart` 共享
|
- **A2UI 组件实现**:`DynamicForm`/`DynamicChart` 使用 `useSubmissionState`
|
||||||
`useSubmissionState`(提交状态:防重复提交 + 失败可重试)与 `useXCardSurface`
|
(单提交点:防重复提交 + 失败可重试)与 `useXCardSurface`(XCard commands
|
||||||
(XCard commands 增量更新 + createSurface 自动去重)。
|
增量更新 + createSurface 自动去重);`DynamicReview` 因「逐表确认/逐组确认/全部入库」
|
||||||
|
多提交点并存,保留组件内多提交状态,仅共享 `useXCardSurface`。
|
||||||
- **提交与确认接口**:与上文契约一致,未变更。
|
- **提交与确认接口**:与上文契约一致,未变更。
|
||||||
|
|||||||
Reference in New Issue
Block a user