fix(admin,server): 修复 Dashboard 甘特图时间线与数据校验
Some checks failed
CI / check (pull_request) Failing after 1m54s
Some checks failed
CI / check (pull_request) Failing after 1m54s
This commit is contained in:
44
apps/admin/src/api/schemas/dashboard.integration.test.ts
Normal file
44
apps/admin/src/api/schemas/dashboard.integration.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -55,7 +55,7 @@ export interface ExpenseByTypeRow {
|
||||
}
|
||||
export interface GanttOccupancy {
|
||||
studentName: string;
|
||||
studentId?: string;
|
||||
studentId?: string | number;
|
||||
checkInDate: string;
|
||||
checkOutDate: string | null;
|
||||
billingStartDate?: string;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
};
|
||||
}),
|
||||
),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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 (
|
||||
<LazySection
|
||||
@@ -74,7 +75,7 @@ export const GanttCard: React.FC<{ data: GanttRoom[]; isMobile: boolean }> = ({
|
||||
>
|
||||
{data.length > 0 ? (
|
||||
<ReactECharts
|
||||
option={buildGanttOption(data)}
|
||||
option={buildGanttOption(data, { periodEnd })}
|
||||
style={{ width: '100%', height: isMobile ? 300 : 450 }}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -495,7 +495,7 @@ const DashboardPage: React.FC = () => {
|
||||
<ClassroomHeatmapCard data={classroomOccupancy} isMobile={isMobile} />
|
||||
|
||||
{/* ═══════════ 图表:入住时间线甘特图(懒加载) ═══════════ */}
|
||||
<GanttCard data={ganttData} isMobile={isMobile} />
|
||||
<GanttCard data={ganttData} isMobile={isMobile} periodEnd={period[1]} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user