From 8d4ebcf9c001ed3ddb1f10d04147341d124a1663 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Fri, 7 Aug 2026 16:38:16 +0800 Subject: [PATCH] =?UTF-8?q?fix(admin,server):=20=E4=BF=AE=E5=A4=8D=20Dashb?= =?UTF-8?q?oard=20=E7=94=98=E7=89=B9=E5=9B=BE=E6=97=B6=E9=97=B4=E7=BA=BF?= =?UTF-8?q?=E4=B8=8E=E6=95=B0=E6=8D=AE=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/schemas/dashboard.integration.test.ts | 44 ++++++++++++++ apps/admin/src/api/schemas/dashboard.ts | 15 ++++- .../src/pages/Dashboard/Dashboard.types.ts | 2 +- .../DashboardCharts.integration.test.ts | 59 +++++++++++++++++++ .../src/pages/Dashboard/DashboardCharts.ts | 30 ++++++---- .../pages/Dashboard/DashboardLazyCards.tsx | 11 ++-- apps/admin/src/pages/Dashboard/index.tsx | 2 +- .../dashboard/dashboard-queries.service.ts | 1 + .../src/dashboard/dashboard.scope.spec.ts | 15 +++++ 9 files changed, 160 insertions(+), 19 deletions(-) create mode 100644 apps/admin/src/api/schemas/dashboard.integration.test.ts create mode 100644 apps/admin/src/pages/Dashboard/DashboardCharts.integration.test.ts diff --git a/apps/admin/src/api/schemas/dashboard.integration.test.ts b/apps/admin/src/api/schemas/dashboard.integration.test.ts new file mode 100644 index 0000000..3204177 --- /dev/null +++ b/apps/admin/src/api/schemas/dashboard.integration.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { ganttRoomsSchema } from './dashboard'; + +describe('ganttRoomsSchema 接口校验', () => { + const validPayload = [ + { + roomNumber: 'A101', + occupancies: [ + { + studentName: '张三', + studentId: 3, + checkInDate: '2026-05-01', + checkOutDate: null, + billingStartDate: '2026-05-01', + billingEndDate: null, + }, + ], + }, + ]; + + it('接受合法的甘特图数据(studentId 为数字)', () => { + expect(ganttRoomsSchema.safeParse(validPayload).success).toBe(true); + }); + + it('拒绝缺少 checkInDate 的入住记录', () => { + const payload = [ + { + roomNumber: 'A101', + occupancies: [{ studentName: '张三', checkOutDate: null }], + }, + ]; + expect(ganttRoomsSchema.safeParse(payload).success).toBe(false); + }); + + it('拒绝缺少 studentName 的入住记录', () => { + const payload = [ + { + roomNumber: 'A101', + occupancies: [{ checkInDate: '2026-05-01', checkOutDate: null }], + }, + ]; + expect(ganttRoomsSchema.safeParse(payload).success).toBe(false); + }); +}); diff --git a/apps/admin/src/api/schemas/dashboard.ts b/apps/admin/src/api/schemas/dashboard.ts index d3dbce3..d5a9c4d 100644 --- a/apps/admin/src/api/schemas/dashboard.ts +++ b/apps/admin/src/api/schemas/dashboard.ts @@ -59,8 +59,19 @@ export const classAttendanceRankingSchema = z export const ganttRoomsSchema = z.array( z - .object({ roomNumber: z.string(), occupancies: z.array(z.record(z.string(), z.unknown())) }) - .passthrough(), + .object({ + roomNumber: z.string(), + occupancies: z.array( + z.object({ + studentName: z.string(), + studentId: z.union([z.string(), z.number()]).optional(), + checkInDate: z.string(), + checkOutDate: z.string().nullable(), + billingStartDate: z.string().optional(), + billingEndDate: z.string().nullable().optional(), + }), + ), + }), ); export const classroomOccupanciesSchema = z.array( diff --git a/apps/admin/src/pages/Dashboard/Dashboard.types.ts b/apps/admin/src/pages/Dashboard/Dashboard.types.ts index c5d1a2c..f41ad1c 100644 --- a/apps/admin/src/pages/Dashboard/Dashboard.types.ts +++ b/apps/admin/src/pages/Dashboard/Dashboard.types.ts @@ -55,7 +55,7 @@ export interface ExpenseByTypeRow { } export interface GanttOccupancy { studentName: string; - studentId?: string; + studentId?: string | number; checkInDate: string; checkOutDate: string | null; billingStartDate?: string; diff --git a/apps/admin/src/pages/Dashboard/DashboardCharts.integration.test.ts b/apps/admin/src/pages/Dashboard/DashboardCharts.integration.test.ts new file mode 100644 index 0000000..374ee5c --- /dev/null +++ b/apps/admin/src/pages/Dashboard/DashboardCharts.integration.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { buildGanttOption } from './DashboardCharts'; +import type { GanttOccupancy } from './Dashboard.types'; + +const ganttRoom = (occupancies: GanttOccupancy[]) => [ + { roomNumber: 'A101', occupancies }, +]; + +describe('buildGanttOption 甘特图时间线', () => { + it('未退宿的入住条在查看过去月份时截断到 periodEnd,而不是画到今天', () => { + const option = buildGanttOption( + ganttRoom([ + { + studentName: '张三', + checkInDate: '2026-05-01', + checkOutDate: null, + }, + ]), + { periodEnd: '2026-06-30', today: '2026-08-07' }, + ); + + const series = option.series as Array<{ data: Array<{ value: [string, string, string, boolean] }> }>; + expect(series[0].data[0].value[2]).toBe('2026-06-30'); + expect(series[0].data[0].value[3]).toBe(true); + }); + + it('未退宿的入住条在查看当前月时截断到今天', () => { + const option = buildGanttOption( + ganttRoom([ + { + studentName: '张三', + checkInDate: '2026-07-01', + checkOutDate: null, + }, + ]), + { periodEnd: '2026-08-31', today: '2026-08-07' }, + ); + + const series = option.series as Array<{ data: Array<{ value: [string, string, string, boolean] }> }>; + expect(series[0].data[0].value[2]).toBe('2026-08-07'); + }); + + it('已退宿的入住条保留真实退宿日期', () => { + const option = buildGanttOption( + ganttRoom([ + { + studentName: '李四', + checkInDate: '2026-05-01', + checkOutDate: '2026-06-15', + }, + ]), + { periodEnd: '2026-06-30', today: '2026-08-07' }, + ); + + const series = option.series as Array<{ data: Array<{ value: [string, string, string, boolean] }> }>; + expect(series[0].data[0].value[2]).toBe('2026-06-15'); + expect(series[0].data[0].value[3]).toBe(false); + }); +}); diff --git a/apps/admin/src/pages/Dashboard/DashboardCharts.ts b/apps/admin/src/pages/Dashboard/DashboardCharts.ts index 641e53d..e2bd906 100644 --- a/apps/admin/src/pages/Dashboard/DashboardCharts.ts +++ b/apps/admin/src/pages/Dashboard/DashboardCharts.ts @@ -1,4 +1,5 @@ import type { EChartsOption } from '../../components/ECharts'; +import dayjs from 'dayjs'; import { attendanceLabelMap, COLORS, @@ -201,7 +202,13 @@ export function buildClassroomHeatmapOption( }; } -export function buildGanttOption(ganttData: GanttRoom[]): EChartsOption { +export function buildGanttOption( + ganttData: GanttRoom[], + options?: { periodEnd?: string; today?: string }, +): EChartsOption { + const today = options?.today ?? dayjs().format('YYYY-MM-DD'); + const periodEnd = options?.periodEnd; + return { tooltip: { formatter: (p: { data: { name: string; value: [string, string, string, boolean] } }) => @@ -246,15 +253,18 @@ export function buildGanttOption(ganttData: GanttRoom[]): EChartsOption { }, encode: { x: [1, 2], y: 0 }, data: ganttData.flatMap((r) => - (r.occupancies || []).map((o) => ({ - name: o.studentName, - value: [ - r.roomNumber, - o.checkInDate, - o.checkOutDate || new Date().toISOString().slice(0, 10), - !o.checkOutDate, - ] as [string, string, string, boolean], - })), + (r.occupancies || []).map((o) => { + const activeEnd = periodEnd && periodEnd < today ? periodEnd : today; + return { + name: o.studentName, + value: [ + r.roomNumber, + o.checkInDate, + o.checkOutDate || activeEnd, + !o.checkOutDate, + ] as [string, string, string, boolean], + }; + }), ), }, ], diff --git a/apps/admin/src/pages/Dashboard/DashboardLazyCards.tsx b/apps/admin/src/pages/Dashboard/DashboardLazyCards.tsx index f2f8554..d7751d2 100644 --- a/apps/admin/src/pages/Dashboard/DashboardLazyCards.tsx +++ b/apps/admin/src/pages/Dashboard/DashboardLazyCards.tsx @@ -61,10 +61,11 @@ export const ClassroomHeatmapCard: React.FC<{ ); }; -export const GanttCard: React.FC<{ data: GanttRoom[]; isMobile: boolean }> = ({ - data, - isMobile, -}) => { +export const GanttCard: React.FC<{ + data: GanttRoom[]; + isMobile: boolean; + periodEnd?: string; +}> = ({ data, isMobile, periodEnd }) => { const vp = useInViewport('200px'); return ( = ({ > {data.length > 0 ? ( ) : ( diff --git a/apps/admin/src/pages/Dashboard/index.tsx b/apps/admin/src/pages/Dashboard/index.tsx index db8f3c1..f228f29 100644 --- a/apps/admin/src/pages/Dashboard/index.tsx +++ b/apps/admin/src/pages/Dashboard/index.tsx @@ -495,7 +495,7 @@ const DashboardPage: React.FC = () => { {/* ═══════════ 图表:入住时间线甘特图(懒加载) ═══════════ */} - + ); }; diff --git a/apps/server/src/dashboard/dashboard-queries.service.ts b/apps/server/src/dashboard/dashboard-queries.service.ts index 5ac20f6..be279ec 100644 --- a/apps/server/src/dashboard/dashboard-queries.service.ts +++ b/apps/server/src/dashboard/dashboard-queries.service.ts @@ -117,6 +117,7 @@ async getGanttData( .leftJoinAndSelect('o.student', 'student') .leftJoinAndSelect('o.room', 'room') .where('room.status != :archived', { archived: 'archived' }) + .andWhere('o.status = :status', { status: 'active' }) .orderBy('room.roomNumber', 'ASC') .addOrderBy('o.checkInDate', 'ASC'); diff --git a/apps/server/src/dashboard/dashboard.scope.spec.ts b/apps/server/src/dashboard/dashboard.scope.spec.ts index 302a528..ba94d2b 100644 --- a/apps/server/src/dashboard/dashboard.scope.spec.ts +++ b/apps/server/src/dashboard/dashboard.scope.spec.ts @@ -6,6 +6,7 @@ const queriesService = (attendanceRepo?: unknown) => const createQb = () => ({ leftJoin: jest.fn().mockReturnThis(), + leftJoinAndSelect: jest.fn().mockReturnThis(), select: jest.fn().mockReturnThis(), addSelect: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), @@ -13,9 +14,11 @@ const createQb = () => ({ groupBy: jest.fn().mockReturnThis(), addGroupBy: jest.fn().mockReturnThis(), orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), limit: jest.fn().mockReturnThis(), getRawMany: jest.fn().mockResolvedValue([]), getRawOne: jest.fn().mockResolvedValue({ cnt: '0' }), + getMany: jest.fn().mockResolvedValue([]), }); describe('DashboardService — teacher class scope', () => { @@ -48,6 +51,18 @@ describe('DashboardService — teacher class scope', () => { }); describe('DashboardService — boundary conditions', () => { + it('excludes archived occupancy records from the gantt timeline', async () => { + const qb = createQb(); + const occRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) }; + const queries = queriesService(); + + await queries.getGanttData(occRepo as never, () => undefined); + + expect(qb.andWhere).toHaveBeenCalledWith('o.status = :status', { + status: 'active', + }); + }); + it('uses a deny-all predicate instead of an empty SQL IN list', async () => { const qb = createQb(); const attendanceRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) };