refactor: 前端应用骨架迁移至 zustand 并统一路由权限壳
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import React, { Suspense, lazy } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router';
|
||||
import { ConfigProvider, App as AntdApp, Spin } from 'antd';
|
||||
import { XProvider } from '@ant-design/x';
|
||||
import XProvider from '@ant-design/x/es/x-provider';
|
||||
import xZhCN from '@ant-design/x/es/locale/zh_CN';
|
||||
import zhCN from 'antd/es/locale/zh_CN';
|
||||
import MainLayout from './layouts/MainLayout';
|
||||
|
||||
29
apps/admin/src/api/schemas/ai.ts
Normal file
29
apps/admin/src/api/schemas/ai.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const aiConfigSchema = z
|
||||
.object({
|
||||
id: z.number(),
|
||||
provider: z.string(),
|
||||
baseUrl: z.string(),
|
||||
hasApiKey: z.boolean(),
|
||||
hasDatabaseKey: z.boolean(),
|
||||
maskedApiKey: z.string().nullable(),
|
||||
keySource: z.enum(['database', 'environment', 'none']),
|
||||
defaultModel: z.string().nullable(),
|
||||
enabled: z.boolean(),
|
||||
supportsVision: z.boolean(),
|
||||
timeoutMs: z.number(),
|
||||
reasoningEffort: z.string().nullable(),
|
||||
verified: z.boolean(),
|
||||
lastTestedAt: z.string().nullable(),
|
||||
lastTestLatencyMs: z.number().nullable(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const aiConfigEnvelopeSchema = z
|
||||
.object({ success: z.boolean(), data: aiConfigSchema })
|
||||
.passthrough();
|
||||
|
||||
/** 导入任务 */
|
||||
80
apps/admin/src/api/schemas/attendance.ts
Normal file
80
apps/admin/src/api/schemas/attendance.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const attendanceRecordSchema = z
|
||||
.object({
|
||||
id: z.number(),
|
||||
studentId: z.number(),
|
||||
classId: z.number().nullable(),
|
||||
attendanceDate: z.string(),
|
||||
session: z.string(),
|
||||
status: z.string(),
|
||||
remark: z.string().nullable(),
|
||||
createdAt: z.string(),
|
||||
student: z
|
||||
.object({ id: z.number(), name: z.string(), studentNo: z.string().nullable().optional() })
|
||||
.passthrough(),
|
||||
class: z.object({ id: z.number(), name: z.string() }).passthrough().nullable(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const attendanceRecordsResponseSchema = z
|
||||
.object({ list: z.array(attendanceRecordSchema), total: z.number() })
|
||||
.passthrough();
|
||||
|
||||
export const attendanceSummarySchema = z
|
||||
.object({
|
||||
total: z.number(),
|
||||
present: z.number(),
|
||||
late: z.number(),
|
||||
absent: z.number(),
|
||||
leave: z.number(),
|
||||
pending: z.number(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const dingTalkSyncStatusSchema = z
|
||||
.object({
|
||||
lastPulledAt: z.string().nullable(),
|
||||
action: z.string().nullable(),
|
||||
username: z.string().nullable(),
|
||||
detail: z.string().nullable(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/** 学生档案聚合 */
|
||||
|
||||
export const attendanceClassOptionSchema = z
|
||||
.object({ classId: z.number(), className: z.string() })
|
||||
.passthrough();
|
||||
|
||||
export const attendanceClassOptionsSchema = z.array(attendanceClassOptionSchema);
|
||||
|
||||
export const attendanceAlertSchema = z
|
||||
.object({ id: z.number(), type: z.string(), message: z.string() })
|
||||
.passthrough();
|
||||
|
||||
export const attendanceAlertsSchema = z.array(attendanceAlertSchema);
|
||||
|
||||
export const attendancePeriodSchema = z
|
||||
.object({
|
||||
periodKey: z.string(),
|
||||
label: z.string(),
|
||||
startTime: z.string(),
|
||||
endTime: z.string(),
|
||||
sortOrder: z.number(),
|
||||
enabled: z.boolean(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const attendancePeriodsSchema = z.array(attendancePeriodSchema);
|
||||
|
||||
export const attendanceScheduleOptionSchema = z
|
||||
.object({
|
||||
id: z.number(),
|
||||
subject: z.string(),
|
||||
startTime: z.string(),
|
||||
endTime: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const attendanceScheduleOptionsSchema = z.array(attendanceScheduleOptionSchema);
|
||||
330
apps/admin/src/api/schemas/core.ts
Normal file
330
apps/admin/src/api/schemas/core.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const studentProfileAggregateSchema = z
|
||||
.object({
|
||||
student: z
|
||||
.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
phone: z.string(),
|
||||
idNumber: z.string(),
|
||||
studentNo: z.string(),
|
||||
status: z.string(),
|
||||
})
|
||||
.passthrough(),
|
||||
profile: z.record(z.string(), z.unknown()).nullable(),
|
||||
enrollments: z.array(z.record(z.string(), z.unknown())),
|
||||
examScores: z.array(z.record(z.string(), z.unknown())),
|
||||
learningRecords: z.array(z.record(z.string(), z.unknown())),
|
||||
result: z.record(z.string(), z.unknown()).nullable(),
|
||||
attachments: z.array(z.record(z.string(), z.unknown())),
|
||||
attendances: z.array(z.record(z.string(), z.unknown())),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/** 权限树 */
|
||||
export const permissionItemSchema = z
|
||||
.object({ id: z.number(), code: z.string(), name: z.string(), group: z.string() })
|
||||
.passthrough();
|
||||
|
||||
export const permissionTreeSchema = z.array(
|
||||
z.object({ group: z.string(), permissions: z.array(permissionItemSchema) }).passthrough(),
|
||||
);
|
||||
|
||||
/** 机构 */
|
||||
export const organizationSchema = z
|
||||
.object({ id: z.number(), name: z.string(), code: z.string(), status: z.string() })
|
||||
.passthrough();
|
||||
|
||||
export const organizationsSchema = z.array(organizationSchema);
|
||||
|
||||
export const organizationOptionSchema = z
|
||||
.object({ id: z.number(), name: z.string() })
|
||||
.passthrough();
|
||||
|
||||
export const organizationOptionsSchema = z.array(organizationOptionSchema);
|
||||
|
||||
/** 账单 */
|
||||
export const billSchema = z
|
||||
.object({
|
||||
id: z.number(),
|
||||
status: z.string(),
|
||||
student: z
|
||||
.object({ id: z.number(), name: z.string() })
|
||||
.passthrough()
|
||||
.nullable()
|
||||
.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const billsSchema = z.array(billSchema);
|
||||
|
||||
/** 班级 */
|
||||
export const classSchema = z
|
||||
.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
code: z.string(),
|
||||
classType: z.string(),
|
||||
isArchived: z.boolean(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const classesSchema = z.array(classSchema);
|
||||
|
||||
/** 教师 */
|
||||
export const teacherSchema = z
|
||||
.object({ id: z.number(), username: z.string(), name: z.string() })
|
||||
.passthrough();
|
||||
|
||||
export const teacherListSchema = z
|
||||
.object({ list: z.array(teacherSchema), total: z.number() })
|
||||
.passthrough();
|
||||
|
||||
/** 角色 / 用户 */
|
||||
export const roleSchema = z
|
||||
.object({ id: z.number(), name: z.string(), status: z.number() })
|
||||
.passthrough();
|
||||
|
||||
export const rolesSchema = z.array(roleSchema);
|
||||
|
||||
export const userSchema = z
|
||||
.object({ id: z.number(), username: z.string(), name: z.string() })
|
||||
.passthrough();
|
||||
|
||||
export const usersSchema = z.array(userSchema);
|
||||
|
||||
/** 操作日志 */
|
||||
export const operationLogSchema = z
|
||||
.object({
|
||||
id: z.number(),
|
||||
module: z.string(),
|
||||
action: z.string(),
|
||||
username: z.string(),
|
||||
createdAt: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const operationLogsSchema = z
|
||||
.object({ data: z.array(operationLogSchema), total: z.number() })
|
||||
.passthrough();
|
||||
|
||||
/** 通知 */
|
||||
export const notificationSchema = z
|
||||
.object({
|
||||
id: z.number(),
|
||||
type: z.string(),
|
||||
title: z.string(),
|
||||
content: z.string(),
|
||||
isRead: z.boolean(),
|
||||
createdAt: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const notificationsSchema = z.array(notificationSchema);
|
||||
|
||||
/** 考勤机 / 教室选项 */
|
||||
export const attendanceDeviceSchema = z
|
||||
.object({ id: z.number(), deviceSn: z.string(), deviceName: z.string(), status: z.string() })
|
||||
.passthrough();
|
||||
|
||||
export const attendanceDevicesSchema = z.array(attendanceDeviceSchema);
|
||||
|
||||
export const classroomOptionSchema = z
|
||||
.object({ id: z.number(), name: z.string() })
|
||||
.passthrough();
|
||||
|
||||
export const classroomOptionsSchema = z.array(classroomOptionSchema);
|
||||
|
||||
/** 宿舍 / 教室 */
|
||||
export const roomSchema = z
|
||||
.object({
|
||||
id: z.number(),
|
||||
roomNumber: z.string(),
|
||||
status: z.string(),
|
||||
currentCount: z.number(),
|
||||
capacity: z.number(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const roomsOverviewSchema = z.array(roomSchema);
|
||||
|
||||
export const classroomSchema = z
|
||||
.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
building: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const classroomsSchema = z.array(classroomSchema);
|
||||
|
||||
/** 学生 */
|
||||
export const studentSchema = z
|
||||
.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
studentNo: z.string().optional(),
|
||||
status: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const studentsSchema = z.array(studentSchema);
|
||||
|
||||
/** 押金 */
|
||||
export const depositSchema = z
|
||||
.object({ id: z.number(), studentId: z.number(), amount: z.number(), status: z.string() })
|
||||
.passthrough();
|
||||
|
||||
export const depositsSchema = z.array(depositSchema);
|
||||
|
||||
export const depositStudentLookupSchema = z
|
||||
.object({ studentId: z.number(), name: z.string().optional() })
|
||||
.passthrough();
|
||||
|
||||
export const depositStudentLookupsSchema = z.array(depositStudentLookupSchema);
|
||||
|
||||
export const eligibleStudentSchema = z
|
||||
.object({ studentId: z.number(), roomId: z.number(), roomNumber: z.string() })
|
||||
.passthrough();
|
||||
|
||||
export const eligibleStudentsSchema = z.array(eligibleStudentSchema);
|
||||
|
||||
/** 钱包 */
|
||||
export const walletSchema = z
|
||||
.object({
|
||||
studentId: z.number(),
|
||||
studentName: z.string(),
|
||||
balance: z.number(),
|
||||
outstandingAmount: z.number(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const walletsSchema = z.array(walletSchema);
|
||||
|
||||
export const roomTypesSchema = z.array(z.string());
|
||||
|
||||
/** 费用 */
|
||||
export const expenseRecordSchema = z
|
||||
.object({
|
||||
id: z.number(),
|
||||
expenseType: z.string(),
|
||||
amount: z.number(),
|
||||
status: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const expenseRecordsSchema = z.array(expenseRecordSchema);
|
||||
|
||||
export const expenseLookupsSchema = z
|
||||
.object({
|
||||
rooms: z.array(z.record(z.string(), z.unknown())),
|
||||
students: z.array(z.record(z.string(), z.unknown())),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const expenseTypesSchema = z.array(
|
||||
z.object({ code: z.string(), name: z.string(), category: z.string() }),
|
||||
);
|
||||
|
||||
/** 入住 */
|
||||
export const occupancySchema = z
|
||||
.object({
|
||||
id: z.number(),
|
||||
studentId: z.number(),
|
||||
roomId: z.number(),
|
||||
checkInDate: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const occupanciesSchema = z.array(occupancySchema);
|
||||
|
||||
/** 排课 */
|
||||
export const scheduleLookupsSchema = z
|
||||
.object({
|
||||
classrooms: z.array(classroomOptionSchema),
|
||||
classes: z.array(z.object({ id: z.number(), name: z.string() }).passthrough()),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const weeklyScheduleSchema = z.record(
|
||||
z.string(),
|
||||
z.record(
|
||||
z.string(),
|
||||
z.array(
|
||||
z
|
||||
.object({
|
||||
id: z.number().nullable(),
|
||||
classId: z.number().nullable(),
|
||||
classroomId: z.number(),
|
||||
weekDay: z.number(),
|
||||
startTime: z.string(),
|
||||
endTime: z.string(),
|
||||
})
|
||||
.passthrough(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/** 租赁订单 */
|
||||
export const rentalSchema = z
|
||||
.object({
|
||||
id: z.number(),
|
||||
classroom: z
|
||||
.object({ id: z.number(), name: z.string() })
|
||||
.passthrough()
|
||||
.nullable()
|
||||
.optional(),
|
||||
lesseeOrganization: z
|
||||
.object({ id: z.number(), name: z.string() })
|
||||
.passthrough()
|
||||
.nullable()
|
||||
.optional(),
|
||||
status: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const rentalsSchema = z.array(rentalSchema);
|
||||
|
||||
/** 考试 */
|
||||
export const examSchema = z
|
||||
.object({
|
||||
id: z.number(),
|
||||
examName: z.string(),
|
||||
examType: z.string(),
|
||||
isArchived: z.boolean(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const examsSchema = z.array(examSchema);
|
||||
|
||||
export const examDetailSchema = z
|
||||
.object({ id: z.number(), examName: z.string() })
|
||||
.passthrough();
|
||||
|
||||
export const classOptionSchema = z
|
||||
.object({ id: z.number(), name: z.string() })
|
||||
.passthrough();
|
||||
|
||||
export const classOptionsSchema = z.array(classOptionSchema);
|
||||
|
||||
export const studentFilterLookupsSchema = z
|
||||
.object({
|
||||
classes: z.array(z.object({ id: z.number(), name: z.string() }).passthrough()),
|
||||
teachers: z.array(z.object({ id: z.number(), name: z.string() }).passthrough()),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/** 教师工作台 */
|
||||
export const teacherWorkspaceSchema = z
|
||||
.object({
|
||||
assignedClasses: z.array(
|
||||
z.object({ classId: z.number(), className: z.string() }).passthrough(),
|
||||
),
|
||||
todaySchedules: z.array(z.record(z.string(), z.unknown())),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/** 集成配置 */
|
||||
79
apps/admin/src/api/schemas/dashboard.ts
Normal file
79
apps/admin/src/api/schemas/dashboard.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const classroomScheduleSchema = z
|
||||
.object({
|
||||
classrooms: z.array(z.record(z.string(), z.unknown())),
|
||||
organizations: z.array(z.record(z.string(), z.unknown())),
|
||||
matrix: z.record(z.string(), z.record(z.string(), z.array(z.record(z.string(), z.unknown())))),
|
||||
summary: z.record(z.string(), z.record(z.string(), z.unknown())),
|
||||
days: z.number().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/** Dashboard 统计 */
|
||||
export const dashboardStatsSchema = z
|
||||
.object({
|
||||
totalRooms: z.number(),
|
||||
totalStudents: z.number(),
|
||||
occupiedBeds: z.number(),
|
||||
totalCapacity: z.number(),
|
||||
occupancyRate: z.string(),
|
||||
classroomCount: z.number(),
|
||||
classroomOccupancyRate: z.string(),
|
||||
todayAttendanceRate: z.string().optional(),
|
||||
monthlyIncome: z.number(),
|
||||
classCount: z.number(),
|
||||
teacherCount: z.number(),
|
||||
pendingDeposits: z.number(),
|
||||
activeRentals: z.number(),
|
||||
todayPresent: z.number(),
|
||||
occupancyByBuilding: z.array(
|
||||
z.object({ building: z.string(), count: z.string() }).passthrough(),
|
||||
),
|
||||
attendanceByStatus: z.record(z.string(), z.number()),
|
||||
expenseByType: z.array(z.object({ type: z.string(), total: z.string() }).passthrough()),
|
||||
attendanceTrend: z.array(z.object({ date: z.string(), rate: z.string() }).passthrough()),
|
||||
incomeTrend: z.array(z.object({ month: z.string(), amount: z.number() }).passthrough()),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const roomRankingSchema = z.array(
|
||||
z.object({ roomNumber: z.string(), total: z.string() }).passthrough(),
|
||||
);
|
||||
|
||||
export const classAttendanceRankingSchema = z
|
||||
.object({
|
||||
top: z.array(
|
||||
z
|
||||
.object({ className: z.string(), present: z.number(), total: z.number(), rate: z.number() })
|
||||
.passthrough(),
|
||||
),
|
||||
bottom: z.array(
|
||||
z
|
||||
.object({ className: z.string(), present: z.number(), total: z.number(), rate: z.number() })
|
||||
.passthrough(),
|
||||
),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const ganttRoomsSchema = z.array(
|
||||
z
|
||||
.object({ roomNumber: z.string(), occupancies: z.array(z.record(z.string(), z.unknown())) })
|
||||
.passthrough(),
|
||||
);
|
||||
|
||||
export const classroomOccupanciesSchema = z.array(
|
||||
z
|
||||
.object({ name: z.string(), building: z.string(), capacity: z.number(), occupancy: z.number() })
|
||||
.passthrough(),
|
||||
);
|
||||
|
||||
export const classroomUtilStatsSchema = z
|
||||
.object({
|
||||
totalClassrooms: z.number(),
|
||||
inUseCount: z.number(),
|
||||
utilizationRate: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/** 考勤元数据 */
|
||||
58
apps/admin/src/api/schemas/import-run.ts
Normal file
58
apps/admin/src/api/schemas/import-run.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const importSheetMetaSchema = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
headers: z.array(z.string()),
|
||||
rowCount: z.number(),
|
||||
suggestedStepKey: z.string().nullable(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const importStepSummarySchema = z
|
||||
.object({
|
||||
total: z.number(),
|
||||
valid: z.number(),
|
||||
error: z.number(),
|
||||
create: z.number(),
|
||||
update: z.number(),
|
||||
skip: z.number(),
|
||||
})
|
||||
.passthrough()
|
||||
.nullable();
|
||||
|
||||
export const importStepDetailSchema = z
|
||||
.object({
|
||||
id: z.number(),
|
||||
stepKey: z.string(),
|
||||
label: z.string(),
|
||||
sheets: z.array(z.string()),
|
||||
status: z.string(),
|
||||
mapping: z.record(z.string(), z.string()),
|
||||
summary: importStepSummarySchema,
|
||||
committedAt: z.string().nullable(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const importRunDetailSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
fileName: z.string(),
|
||||
source: z.enum(['ai', 'manual']),
|
||||
status: z.string(),
|
||||
currentStepKey: z.string().nullable(),
|
||||
createdAt: z.string(),
|
||||
sheets: z.array(importSheetMetaSchema),
|
||||
steps: z.array(importStepDetailSchema),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const importRunEnvelopeSchema = z
|
||||
.object({
|
||||
success: z.boolean(),
|
||||
data: importRunDetailSchema,
|
||||
message: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/** 考勤记录 */
|
||||
6
apps/admin/src/api/schemas/index.ts
Normal file
6
apps/admin/src/api/schemas/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './core';
|
||||
export * from './attendance';
|
||||
export * from './dashboard';
|
||||
export * from './import-run';
|
||||
export * from './ai';
|
||||
export * from './integration';
|
||||
23
apps/admin/src/api/schemas/integration.ts
Normal file
23
apps/admin/src/api/schemas/integration.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const integrationConfigSchema = z
|
||||
.object({
|
||||
success: z.boolean(),
|
||||
data: z.array(
|
||||
z
|
||||
.object({
|
||||
type: z.string(),
|
||||
verify: z.boolean(),
|
||||
config: z.record(z.string(), z.unknown()),
|
||||
})
|
||||
.passthrough(),
|
||||
),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/** 金数据规则 */
|
||||
export const jinshujuRulesSchema = z.array(
|
||||
z.object({ id: z.number(), name: z.string(), formToken: z.string() }).passthrough(),
|
||||
);
|
||||
|
||||
/** 教室排课总览 */
|
||||
@@ -1,3 +1,4 @@
|
||||
// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化
|
||||
export interface AppMenuItem {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -36,100 +37,112 @@ const ROLE_ALIASES: Record<string, string> = {
|
||||
super_admin: 'super',
|
||||
};
|
||||
|
||||
function entry(key: string, label: string, icon: string, permission: string): MenuEntry {
|
||||
return { key, label, icon, permission };
|
||||
}
|
||||
|
||||
function section(
|
||||
key: string,
|
||||
label: string,
|
||||
icon: string,
|
||||
roles: string[],
|
||||
children: MenuEntry[],
|
||||
): MenuSection {
|
||||
return { key, label, icon, roles, children };
|
||||
}
|
||||
|
||||
const SECTIONS: MenuSection[] = [
|
||||
{
|
||||
key: 'teaching-group',
|
||||
label: '教学工作',
|
||||
icon: 'calendar',
|
||||
roles: ['teacher'],
|
||||
children: [
|
||||
{
|
||||
key: '/teacher-workspace',
|
||||
label: '今日教学',
|
||||
icon: 'workspace',
|
||||
permission: 'teacher-workspace:view',
|
||||
},
|
||||
{ key: '/schedules', label: '我的排课', icon: 'calendar', permission: 'schedule:view' },
|
||||
{ key: '/attendance', label: '课程考勤', icon: 'attendance', permission: 'attendance:view' },
|
||||
section(
|
||||
'teaching-group',
|
||||
'教学工作',
|
||||
'calendar',
|
||||
['teacher'],
|
||||
[
|
||||
entry('/teacher-workspace', '今日教学', 'workspace', 'teacher-workspace:view'),
|
||||
|
||||
entry('/schedules', '我的排课', 'calendar', 'schedule:view'),
|
||||
|
||||
entry('/attendance', '课程考勤', 'attendance', 'attendance:view'),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'academic-group',
|
||||
label: '教务管理',
|
||||
icon: 'academic',
|
||||
roles: ['academic', 'super'],
|
||||
children: [
|
||||
{ key: '/students', label: '学生管理', icon: 'students', permission: 'student:view' },
|
||||
{ key: '/classes', label: '班级管理', icon: 'classes', permission: 'class:view' },
|
||||
{ key: '/exams', label: '考试管理', icon: 'exam', permission: 'exam:view' },
|
||||
{ key: '/teachers', label: '教师管理', icon: 'teachers', permission: 'teacher:view' },
|
||||
{ key: '/schedules', label: '排课管理', icon: 'calendar', permission: 'schedule:view' },
|
||||
{ key: '/attendance', label: '历史考勤', icon: 'attendance', permission: 'attendance:view' },
|
||||
{ key: '/classrooms', label: '教室查看', icon: 'classroom', permission: 'classroom:view' },
|
||||
),
|
||||
section(
|
||||
'academic-group',
|
||||
'教务管理',
|
||||
'academic',
|
||||
['academic', 'super'],
|
||||
[
|
||||
entry('/students', '学生管理', 'students', 'student:view'),
|
||||
|
||||
entry('/classes', '班级管理', 'classes', 'class:view'),
|
||||
|
||||
entry('/exams', '考试管理', 'exam', 'exam:view'),
|
||||
|
||||
entry('/teachers', '教师管理', 'teachers', 'teacher:view'),
|
||||
|
||||
entry('/schedules', '排课管理', 'calendar', 'schedule:view'),
|
||||
|
||||
entry('/attendance', '历史考勤', 'attendance', 'attendance:view'),
|
||||
|
||||
entry('/classrooms', '教室查看', 'classroom', 'classroom:view'),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'accommodation-group',
|
||||
label: '住宿运营',
|
||||
icon: 'home',
|
||||
roles: ['accommodation', 'super'],
|
||||
children: [
|
||||
{ key: '/room-visual', label: '住宿总览', icon: 'overview', permission: 'room:view' },
|
||||
{ key: '/rooms', label: '房间管理', icon: 'home', permission: 'room:view' },
|
||||
{ key: '/occupancies', label: '入住管理', icon: 'occupancy', permission: 'occupancy:view' },
|
||||
{ key: '/expenses', label: '费用管理', icon: 'expense', permission: 'expense:view' },
|
||||
{ key: '/bills', label: '账单管理', icon: 'bill', permission: 'bill:view' },
|
||||
{ key: '/wallets', label: '学生余额', icon: 'wallet', permission: 'wallet:view' },
|
||||
{ key: '/deposits', label: '押金管理', icon: 'deposit', permission: 'deposit:view' },
|
||||
),
|
||||
section(
|
||||
'accommodation-group',
|
||||
'住宿运营',
|
||||
'home',
|
||||
['accommodation', 'super'],
|
||||
[
|
||||
entry('/room-visual', '住宿总览', 'overview', 'room:view'),
|
||||
|
||||
entry('/rooms', '房间管理', 'home', 'room:view'),
|
||||
|
||||
entry('/occupancies', '入住管理', 'occupancy', 'occupancy:view'),
|
||||
|
||||
entry('/expenses', '费用管理', 'expense', 'expense:view'),
|
||||
|
||||
entry('/bills', '账单管理', 'bill', 'bill:view'),
|
||||
|
||||
entry('/wallets', '学生余额', 'wallet', 'wallet:view'),
|
||||
|
||||
entry('/deposits', '押金管理', 'deposit', 'deposit:view'),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'classroom-group',
|
||||
label: '教室运营',
|
||||
icon: 'classroom',
|
||||
roles: ['classroom', 'super'],
|
||||
children: [
|
||||
{
|
||||
key: '/classroom-schedule',
|
||||
label: '教室排期',
|
||||
icon: 'calendar',
|
||||
permission: 'rental:view',
|
||||
},
|
||||
{ key: '/classrooms', label: '教室管理', icon: 'classroom', permission: 'classroom:view' },
|
||||
{
|
||||
key: '/attendance-devices',
|
||||
label: '考勤机绑定',
|
||||
icon: 'attendance',
|
||||
permission: 'classroom:view',
|
||||
},
|
||||
{ key: '/classroom-rentals', label: '租赁订单', icon: 'rental', permission: 'rental:view' },
|
||||
{
|
||||
key: '/organizations',
|
||||
label: '机构管理',
|
||||
icon: 'organization',
|
||||
permission: 'organization:view',
|
||||
},
|
||||
),
|
||||
section(
|
||||
'classroom-group',
|
||||
'教室运营',
|
||||
'classroom',
|
||||
['classroom', 'super'],
|
||||
[
|
||||
entry('/classroom-schedule', '教室排期', 'calendar', 'rental:view'),
|
||||
|
||||
entry('/classrooms', '教室管理', 'classroom', 'classroom:view'),
|
||||
|
||||
entry('/attendance-devices', '考勤机绑定', 'attendance', 'classroom:view'),
|
||||
|
||||
entry('/classroom-rentals', '租赁订单', 'rental', 'rental:view'),
|
||||
|
||||
entry('/organizations', '机构管理', 'organization', 'organization:view'),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'system-group',
|
||||
label: '系统管理',
|
||||
icon: 'settings',
|
||||
roles: ['system', 'super'],
|
||||
children: [
|
||||
{ key: '/users', label: '账号管理', icon: 'users', permission: 'user:view' },
|
||||
{ key: '/roles', label: '角色管理', icon: 'role', permission: 'role:view' },
|
||||
{ key: '/permissions', label: '权限一览', icon: 'permission', permission: 'role:view' },
|
||||
{ key: '/operation-logs', label: '操作日志', icon: 'log', permission: 'log:view' },
|
||||
{
|
||||
key: '/integration-config',
|
||||
label: '钉钉集成',
|
||||
icon: 'integration',
|
||||
permission: 'integration:read',
|
||||
},
|
||||
{ key: '/ai-config', label: 'AI 配置', icon: 'ai', permission: 'ai:config:read' },
|
||||
),
|
||||
section(
|
||||
'system-group',
|
||||
'系统管理',
|
||||
'settings',
|
||||
['system', 'super'],
|
||||
[
|
||||
entry('/users', '账号管理', 'users', 'user:view'),
|
||||
|
||||
entry('/roles', '角色管理', 'role', 'role:view'),
|
||||
|
||||
entry('/permissions', '权限一览', 'permission', 'role:view'),
|
||||
|
||||
entry('/operation-logs', '操作日志', 'log', 'log:view'),
|
||||
|
||||
entry('/integration-config', '钉钉集成', 'integration', 'integration:read'),
|
||||
|
||||
entry('/ai-config', 'AI 配置', 'ai', 'ai:config:read'),
|
||||
],
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
export function getRoleDomains(
|
||||
|
||||
24
apps/admin/src/components/BrandLogo.tsx
Normal file
24
apps/admin/src/components/BrandLogo.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { ReadOutlined } from '@ant-design/icons';
|
||||
|
||||
const BRAND_COLOR = '#7e14ff';
|
||||
|
||||
/** 全局品牌标识:登录页 / 侧边栏 / 页头统一使用 */
|
||||
export function BrandLogo({ size = 32 }: { size?: number }) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: 8,
|
||||
background: BRAND_COLOR,
|
||||
color: '#fff',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<ReadOutlined style={{ fontSize: size * 0.55 }} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { Navigate } from 'react-router';
|
||||
import { Result, Spin } from 'antd';
|
||||
import { usePermission } from '../hooks/usePermission';
|
||||
import { findRoleAwareLandingPath } from '../auth/menu-policy';
|
||||
@@ -7,10 +7,10 @@ import { useUserStore } from '../store/user/userStore';
|
||||
|
||||
const DefaultRoute: React.FC = () => {
|
||||
const { permissions, permissionsReady } = usePermission();
|
||||
const roles = useUserStore((state) => state.user?.roles ?? []);
|
||||
if (!permissionsReady) {
|
||||
return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
|
||||
}
|
||||
const roles = useUserStore((state) => state.user?.roles ?? []);
|
||||
const firstPath = findRoleAwareLandingPath(roles, permissions);
|
||||
if (firstPath) return <Navigate to={firstPath} replace />;
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Badge, Popover, Button, List, Typography, Empty } from 'antd';
|
||||
import { BellOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router';
|
||||
import dayjs from 'dayjs';
|
||||
import { useInterval } from 'usehooks-ts';
|
||||
import api from '../api';
|
||||
import { formatNotificationText, notificationTypeLabels } from '../utils/notification-display';
|
||||
import { useUserStore } from '../store/user/userStore';
|
||||
@@ -17,25 +19,19 @@ interface NotificationItem {
|
||||
}
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return '刚刚';
|
||||
if (mins < 60) return `${mins}分钟前`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}小时前`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}天前`;
|
||||
return dayjs(dateStr).fromNow();
|
||||
}
|
||||
|
||||
const NotificationBell: React.FC = () => {
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [sseDown, setSseDown] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
try {
|
||||
const data = (await api.get('/notifications?limit=20')) as unknown as NotificationItem[];
|
||||
const data = await api.get<NotificationItem[]>('/notifications?limit=20');
|
||||
setNotifications(data);
|
||||
} catch {
|
||||
/* ignore */
|
||||
@@ -44,7 +40,7 @@ const NotificationBell: React.FC = () => {
|
||||
|
||||
const fetchUnread = async () => {
|
||||
try {
|
||||
const data = (await api.get('/notifications/unread-count')) as unknown as { count: number };
|
||||
const data = await api.get<{ count: number }>('/notifications/unread-count');
|
||||
setUnreadCount(data.count);
|
||||
} catch {
|
||||
/* ignore */
|
||||
@@ -52,7 +48,9 @@ const NotificationBell: React.FC = () => {
|
||||
};
|
||||
const openRef = useRef(open);
|
||||
openRef.current = open;
|
||||
const retryRef = useRef<number | null>(null);
|
||||
useInterval(() => {
|
||||
void fetchUnread();
|
||||
}, sseDown ? 60_000 : null);
|
||||
|
||||
// SSE connection — decoupled from popover open state
|
||||
useEffect(() => {
|
||||
@@ -71,13 +69,11 @@ const NotificationBell: React.FC = () => {
|
||||
};
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
if (retryRef.current !== null) clearInterval(retryRef.current);
|
||||
retryRef.current = window.setInterval(fetchUnread, 60_000);
|
||||
setSseDown(true);
|
||||
};
|
||||
return () => {
|
||||
es.close();
|
||||
clearInterval(retryRef.current ?? undefined);
|
||||
retryRef.current = null;
|
||||
setSseDown(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Button } from 'antd';
|
||||
import type { ButtonProps } from 'antd';
|
||||
import { Button, type ButtonProps } from 'antd';
|
||||
import { usePermission } from '../hooks/usePermission';
|
||||
|
||||
interface PermissionButtonProps extends ButtonProps {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Result, Button, Spin } from 'antd';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { findRoleAwareLandingPath } from '../auth/menu-policy';
|
||||
import { usePermission } from '../hooks/usePermission';
|
||||
import { useUserStore } from '../store/user/userStore';
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import type { DragEndEvent } from '@dnd-kit/core';
|
||||
import { closestCenter, DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
horizontalListSortingStrategy,
|
||||
@@ -8,9 +14,8 @@ import {
|
||||
useSortable,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Tabs } from 'antd';
|
||||
import type { TabsProps } from 'antd';
|
||||
import type { Location } from 'react-router-dom';
|
||||
import { Tabs, type TabsProps } from 'antd';
|
||||
import type { Location } from 'react-router';
|
||||
import type { AppMenuItem } from '../../auth/menu-policy';
|
||||
import { useAppStore } from '../../store';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { MemoryRouter, Route, Routes, useNavigate } from 'react-router-dom';
|
||||
import { MemoryRouter, Route, Routes, useNavigate } from 'react-router';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { RouteKeeper } from './RouteKeeper';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { useLocation, useOutlet } from 'react-router-dom';
|
||||
import { useLocation, useOutlet } from 'react-router';
|
||||
|
||||
const MAX_CACHED_PAGES = 30;
|
||||
|
||||
|
||||
39
apps/admin/src/hooks/useApiMutation.ts
Normal file
39
apps/admin/src/hooks/useApiMutation.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useMutation, useQueryClient, type QueryKey } from '@tanstack/react-query';
|
||||
import { message } from '../ui/app-message';
|
||||
import { getErrorMessage } from '../utils/error';
|
||||
|
||||
interface UseApiMutationOptions<TData, TVars> {
|
||||
/** 成功后自动失效的查询 key(触发列表/详情刷新) */
|
||||
invalidate?: QueryKey[];
|
||||
/** 成功后回调(例如关闭弹窗) */
|
||||
onSuccess?: (data: TData, vars: TVars) => void;
|
||||
/** 失败回调;默认统一用 getErrorMessage 弹错误提示 */
|
||||
onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* useMutation 的轻量封装:统一错误提示 + 成功后 invalidateQueries,
|
||||
* 消除手写 `await api.xxx(); await fetchData();` 样板。
|
||||
*/
|
||||
export function useApiMutation<TData = unknown, TVars = void>(
|
||||
mutationFn: (vars: TVars) => Promise<TData>,
|
||||
options: UseApiMutationOptions<TData, TVars> = {},
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<TData, Error, TVars>({
|
||||
mutationFn,
|
||||
onSuccess: (data, vars) => {
|
||||
for (const key of options.invalidate ?? []) {
|
||||
void queryClient.invalidateQueries({ queryKey: key });
|
||||
}
|
||||
options.onSuccess?.(data, vars);
|
||||
},
|
||||
onError: (error) => {
|
||||
if (options.onError) {
|
||||
options.onError(error);
|
||||
} else {
|
||||
message.error(getErrorMessage(error));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { Modal } from 'antd';
|
||||
import { App } from 'antd';
|
||||
import api from '../api';
|
||||
import { message } from '../ui/app-message';
|
||||
|
||||
@@ -13,8 +13,9 @@ import { message } from '../ui/app-message';
|
||||
* already-open confirm modal is destroyed.
|
||||
*/
|
||||
export function useViewSensitive(studentId: number, module: string, canLog: boolean) {
|
||||
const { modal } = App.useApp();
|
||||
const canLogRef = useRef(canLog);
|
||||
const modalRef = useRef<ReturnType<typeof Modal.confirm> | null>(null);
|
||||
const modalRef = useRef<ReturnType<typeof modal.confirm> | null>(null);
|
||||
canLogRef.current = canLog;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -31,7 +32,7 @@ export function useViewSensitive(studentId: number, module: string, canLog: bool
|
||||
return useCallback(
|
||||
(field: string, value: string) => {
|
||||
if (!canLogRef.current) return;
|
||||
modalRef.current = Modal.confirm({
|
||||
modalRef.current = modal.confirm({
|
||||
title: '查看敏感信息',
|
||||
content: `您即将查看 "${field}" 的完整信息。此操作将被记录。`,
|
||||
okText: '确认查看',
|
||||
@@ -50,7 +51,7 @@ export function useViewSensitive(studentId: number, module: string, canLog: bool
|
||||
message.error('操作日志记录失败,请稍后重试');
|
||||
return;
|
||||
}
|
||||
Modal.info({
|
||||
modal.info({
|
||||
title: field,
|
||||
content: value,
|
||||
okText: '关闭',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useNavigate, useLocation } from 'react-router';
|
||||
import { Layout, Menu, Button, Avatar, Badge, Dropdown, Drawer, Grid, Tooltip } from 'antd';
|
||||
import { BrandLogo } from '../components/BrandLogo';
|
||||
import {
|
||||
DashboardOutlined,
|
||||
TeamOutlined,
|
||||
@@ -262,7 +263,17 @@ const MainLayout: React.FC = () => {
|
||||
borderBottom: '1px solid #e5e5e7',
|
||||
}}
|
||||
>
|
||||
{collapsed ? '学' : '学生管理系统'}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<BrandLogo size={collapsed ? 26 : 30} />
|
||||
{!collapsed && <span>学生管理系统</span>}
|
||||
</div>
|
||||
</div>
|
||||
{menuContent}
|
||||
</Sider>
|
||||
@@ -275,7 +286,12 @@ const MainLayout: React.FC = () => {
|
||||
size={240}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
className="app-navigation-drawer"
|
||||
title="学生管理系统"
|
||||
title={
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||
<BrandLogo size={24} />
|
||||
学生管理系统
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{menuContent}
|
||||
</Drawer>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import customParseFormat from 'dayjs/plugin/customParseFormat';
|
||||
import advancedFormat from 'dayjs/plugin/advancedFormat';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import weekday from 'dayjs/plugin/weekday';
|
||||
import localeData from 'dayjs/plugin/localeData';
|
||||
import weekOfYear from 'dayjs/plugin/weekOfYear';
|
||||
@@ -15,6 +18,7 @@ import updateLocale from 'dayjs/plugin/updateLocale';
|
||||
// 扩展 antd DatePicker/RangePicker 面板所需的 dayjs 插件,否则中文 locale 无法生效
|
||||
dayjs.extend(customParseFormat);
|
||||
dayjs.extend(advancedFormat);
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.extend(weekday);
|
||||
dayjs.extend(localeData);
|
||||
dayjs.extend(weekOfYear);
|
||||
@@ -24,8 +28,20 @@ dayjs.extend(updateLocale);
|
||||
// 必须在所有插件加载后设置 locale
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
staleTime: 30_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Form, Input, Button, Card, Typography } from 'antd';
|
||||
import { UserOutlined, LockOutlined } from '@ant-design/icons';
|
||||
import api from '../../api';
|
||||
import { BrandLogo } from '../../components/BrandLogo';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { findRoleAwareLandingPath } from '../../auth/menu-policy';
|
||||
import { usePermissionStore } from '../../store/permission/permissionStore';
|
||||
@@ -59,7 +60,11 @@ const LoginPage: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||
<Title level={3} style={{ margin: 0, fontWeight: 600, color: '#1d1d1f' }}>
|
||||
<BrandLogo size={48} />
|
||||
<Title
|
||||
level={3}
|
||||
style={{ margin: '14px 0 0', fontWeight: 600, color: '#1d1d1f' }}
|
||||
>
|
||||
学生管理系统
|
||||
</Title>
|
||||
<p style={{ color: '#86868b', marginTop: 8 }}>学生综合管理平台</p>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import { appUiPersistStorage, APP_UI_STORAGE_NAME } from '../middleware/persist';
|
||||
import { APP_UI_STORAGE_NAME, appUiPersistStorage, migrateAppUiState } from '../middleware/persist';
|
||||
import type { AppPersistedState, AppStore } from './appTypes';
|
||||
|
||||
/**
|
||||
@@ -59,7 +59,8 @@ export const useAppStore = create<AppStore>()(
|
||||
sidebarCollapsed: state.sidebarCollapsed,
|
||||
routeDockTabs: state.routeDockTabs,
|
||||
}),
|
||||
version: 1,
|
||||
version: 2,
|
||||
migrate: migrateAppUiState,
|
||||
},
|
||||
),
|
||||
{ name: 'app-store', enabled: import.meta.env.DEV },
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* 持久化中间件基础设施。
|
||||
*
|
||||
* 为了平滑迁移,这里把旧实现直接读写 localStorage 的 key
|
||||
* (token / user / permissions / gongxue-route-dock)包装成 zustand
|
||||
* persist 的 StateStorage,保证迁移前后数据格式兼容。
|
||||
* 所有 Store 统一使用 zustand 官方 persist + createJSONStorage(localStorage)。
|
||||
* zustand 只有在「新 key 下已存在数据」时才会执行 migrate,因此旧 key/旧格式
|
||||
* 不能只靠 migrate 迁移:这里通过 StateStorage.getItem 的旧值回退(把旧数据
|
||||
* 包装成 version:1 的 persist envelope),保证升级后首次加载就能触发 migrate,
|
||||
* 并在首次写入新格式时清理旧 key。
|
||||
*/
|
||||
import { createJSONStorage, type StateStorage } from 'zustand/middleware';
|
||||
import type { AppPersistedState, DockTab } from '../app/appTypes';
|
||||
@@ -38,111 +40,94 @@ function isDockTab(value: unknown): value is DockTab {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户会话持久化:继续使用旧的 `token` / `user` 两个 key,
|
||||
* 保持与后端、既有代码及浏览器缓存格式一致。
|
||||
*/
|
||||
const legacyAuthStorage: StateStorage = {
|
||||
getItem: () => {
|
||||
const token = localStorage.getItem(LEGACY_TOKEN_KEY);
|
||||
const rawUser = localStorage.getItem(LEGACY_USER_KEY);
|
||||
if (token === null && rawUser === null) return null;
|
||||
let user: UserInfo | null = null;
|
||||
if (rawUser !== null) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(rawUser);
|
||||
user = isRecord(parsed) ? (parsed as UserInfo) : null;
|
||||
} catch {
|
||||
user = null;
|
||||
}
|
||||
}
|
||||
return JSON.stringify({ state: { token, user }, version: 1 });
|
||||
},
|
||||
setItem: (_name, value) => {
|
||||
function readLegacyAuth(): { token: string | null; user: UserInfo | null } {
|
||||
const token = localStorage.getItem(LEGACY_TOKEN_KEY);
|
||||
const rawUser = localStorage.getItem(LEGACY_USER_KEY);
|
||||
let user: UserInfo | null = null;
|
||||
if (rawUser !== null) {
|
||||
try {
|
||||
const persisted = JSON.parse(value) as { state?: UserPersistedState };
|
||||
const { token, user } = persisted.state ?? {};
|
||||
if (token) {
|
||||
localStorage.setItem(LEGACY_TOKEN_KEY, token);
|
||||
} else {
|
||||
localStorage.removeItem(LEGACY_TOKEN_KEY);
|
||||
}
|
||||
if (user) {
|
||||
localStorage.setItem(LEGACY_USER_KEY, JSON.stringify(user));
|
||||
} else {
|
||||
localStorage.removeItem(LEGACY_USER_KEY);
|
||||
}
|
||||
const parsed: unknown = JSON.parse(rawUser);
|
||||
user = isRecord(parsed) ? (parsed as UserInfo) : null;
|
||||
} catch {
|
||||
// 持久化写入失败不应影响应用运行
|
||||
user = null;
|
||||
}
|
||||
},
|
||||
removeItem: () => {
|
||||
}
|
||||
return { token, user };
|
||||
}
|
||||
|
||||
interface LegacyAdapter {
|
||||
legacyValue: () => string | null;
|
||||
clearLegacy: () => void;
|
||||
}
|
||||
|
||||
/** 新 key 无数据时回退到旧 key,首次写入新格式后清理旧 key */
|
||||
function legacyFallbackStorage(adapter: LegacyAdapter): StateStorage {
|
||||
return {
|
||||
getItem: (name) => {
|
||||
const current = localStorage.getItem(name);
|
||||
if (current !== null) return current;
|
||||
return adapter.legacyValue();
|
||||
},
|
||||
setItem: (name, value) => {
|
||||
try {
|
||||
localStorage.setItem(name, value);
|
||||
} catch {
|
||||
// 持久化失败不应影响应用运行
|
||||
}
|
||||
try {
|
||||
adapter.clearLegacy();
|
||||
} catch {
|
||||
// 清理失败不影响应用运行
|
||||
}
|
||||
},
|
||||
removeItem: (name) => {
|
||||
try {
|
||||
localStorage.removeItem(name);
|
||||
} catch {
|
||||
// 持久化失败不应影响应用运行
|
||||
}
|
||||
try {
|
||||
adapter.clearLegacy();
|
||||
} catch {
|
||||
// 清理失败不影响应用运行
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 会话:旧 token/user 两个 key 包装成 version:1 envelope */
|
||||
function legacyAuthValue(): string | null {
|
||||
const { token, user } = readLegacyAuth();
|
||||
if (token === null && user === null) return null;
|
||||
return JSON.stringify({ state: { token, user }, version: 1 });
|
||||
}
|
||||
|
||||
const authAdapter: LegacyAdapter = {
|
||||
legacyValue: legacyAuthValue,
|
||||
clearLegacy: () => {
|
||||
localStorage.removeItem(LEGACY_TOKEN_KEY);
|
||||
localStorage.removeItem(LEGACY_USER_KEY);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 权限持久化:兼容旧格式(原始 JSON 数组)与 zustand persist 格式。
|
||||
* 无论磁盘上是什么状态,恢复后一律为 `unknown`,保持 fail-closed,
|
||||
* 直到 `/auth/profile` 校验成功。
|
||||
*/
|
||||
const legacyPermissionStorage: StateStorage = {
|
||||
getItem: () => {
|
||||
const raw = localStorage.getItem(PERMISSION_STORAGE_NAME);
|
||||
export const authPersistStorage = createJSONStorage(() =>
|
||||
legacyFallbackStorage(authAdapter),
|
||||
);
|
||||
|
||||
/** 权限:旧格式是 permissions 下的裸数组,读取时统一包装成 envelope 以触发 migrate */
|
||||
const permissionStorage: StateStorage = {
|
||||
getItem: (name) => {
|
||||
const raw = localStorage.getItem(name);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
return JSON.stringify({
|
||||
state: { permissions: parsed.filter(isString), status: 'unknown' },
|
||||
state: { permissions: parsed.filter(isString) },
|
||||
version: 1,
|
||||
});
|
||||
}
|
||||
if (isRecord(parsed) && isRecord(parsed.state)) {
|
||||
const permissions = Array.isArray(parsed.state.permissions)
|
||||
? parsed.state.permissions.filter(isString)
|
||||
: [];
|
||||
return JSON.stringify({
|
||||
state: { permissions, status: 'unknown' },
|
||||
version: 1,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// 损坏的缓存按无权限处理
|
||||
}
|
||||
return null;
|
||||
},
|
||||
setItem: (_name, value) => {
|
||||
try {
|
||||
const persisted = JSON.parse(value) as { state?: PermissionPersistedState };
|
||||
const permissions = Array.isArray(persisted.state?.permissions)
|
||||
? persisted.state.permissions.filter(isString)
|
||||
: [];
|
||||
localStorage.setItem(PERMISSION_STORAGE_NAME, JSON.stringify(permissions));
|
||||
} catch {
|
||||
// 忽略损坏数据
|
||||
}
|
||||
},
|
||||
removeItem: () => {
|
||||
localStorage.removeItem(PERMISSION_STORAGE_NAME);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 应用 UI 状态持久化:新 key `gongxue-app-ui`,
|
||||
* 首次读取时自动迁移旧 key `gongxue-route-dock` 中已打开的页签。
|
||||
*/
|
||||
const appUiStorage: StateStorage = {
|
||||
getItem: (name) => {
|
||||
const current = localStorage.getItem(name);
|
||||
if (current) return current;
|
||||
const legacy = localStorage.getItem(LEGACY_DOCK_STORAGE_KEY);
|
||||
if (!legacy) return null;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(legacy);
|
||||
const routeDockTabs = Array.isArray(parsed) ? parsed.filter(isDockTab) : [];
|
||||
return JSON.stringify({ state: { routeDockTabs, sidebarCollapsed: false }, version: 1 });
|
||||
return raw;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -163,13 +148,84 @@ const appUiStorage: StateStorage = {
|
||||
},
|
||||
};
|
||||
|
||||
/** 会话 Store 使用的 persist storage(兼容旧 token/user key) */
|
||||
export const authPersistStorage = createJSONStorage(() => legacyAuthStorage);
|
||||
export const permissionPersistStorage = createJSONStorage(() => permissionStorage);
|
||||
|
||||
/** 权限 Store 使用的 persist storage(兼容旧 permissions key) */
|
||||
export const permissionPersistStorage = createJSONStorage(() => legacyPermissionStorage);
|
||||
/** 应用 UI:旧 gongxue-route-dock key 包装成 version:1 envelope */
|
||||
function legacyDockValue(): string | null {
|
||||
const legacy = localStorage.getItem(LEGACY_DOCK_STORAGE_KEY);
|
||||
if (!legacy) return null;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(legacy);
|
||||
const routeDockTabs = Array.isArray(parsed) ? parsed.filter(isDockTab) : [];
|
||||
return JSON.stringify({ state: { routeDockTabs, sidebarCollapsed: false }, version: 1 });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 应用 UI Store 使用的 persist storage(含旧 RouteDock key 迁移) */
|
||||
export const appUiPersistStorage = createJSONStorage(() => appUiStorage);
|
||||
const appUiAdapter: LegacyAdapter = {
|
||||
legacyValue: legacyDockValue,
|
||||
clearLegacy: () => {
|
||||
localStorage.removeItem(LEGACY_DOCK_STORAGE_KEY);
|
||||
},
|
||||
};
|
||||
|
||||
export const appUiPersistStorage = createJSONStorage(() =>
|
||||
legacyFallbackStorage(appUiAdapter),
|
||||
);
|
||||
|
||||
/** 会话状态迁移:读取 v1 envelope 或直接 partial state */
|
||||
export function migrateAuthState(persisted: unknown, _version: number): UserPersistedState {
|
||||
if (isRecord(persisted) && isRecord(persisted.state)) {
|
||||
const state = persisted.state as Record<string, unknown>;
|
||||
return {
|
||||
token: typeof state.token === 'string' ? state.token : null,
|
||||
user: isRecord(state.user) ? (state.user as UserInfo) : null,
|
||||
};
|
||||
}
|
||||
const existing = (isRecord(persisted) ? persisted : {}) as Partial<UserPersistedState>;
|
||||
return {
|
||||
token: existing.token ?? null,
|
||||
user: existing.user ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 权限状态迁移:兼容 v1 envelope 与裸数组,恢复后一律 fail-closed */
|
||||
export function migratePermissionState(
|
||||
persisted: unknown,
|
||||
_version: number,
|
||||
): PermissionPersistedState {
|
||||
if (isRecord(persisted) && isRecord(persisted.state)) {
|
||||
const permissions = Array.isArray(persisted.state.permissions)
|
||||
? persisted.state.permissions.filter(isString)
|
||||
: [];
|
||||
return { permissions };
|
||||
}
|
||||
if (Array.isArray(persisted)) {
|
||||
return { permissions: persisted.filter(isString) };
|
||||
}
|
||||
const existing = (isRecord(persisted) ? persisted : {}) as Partial<PermissionPersistedState>;
|
||||
return {
|
||||
permissions: Array.isArray(existing.permissions) ? existing.permissions.filter(isString) : [],
|
||||
};
|
||||
}
|
||||
|
||||
/** 应用 UI 状态迁移:读取 v1 envelope 或直接 partial state */
|
||||
export function migrateAppUiState(persisted: unknown, _version: number): AppPersistedState {
|
||||
if (isRecord(persisted) && isRecord(persisted.state)) {
|
||||
const state = persisted.state as Record<string, unknown>;
|
||||
return {
|
||||
routeDockTabs: Array.isArray(state.routeDockTabs)
|
||||
? state.routeDockTabs.filter(isDockTab)
|
||||
: [],
|
||||
sidebarCollapsed: state.sidebarCollapsed === true,
|
||||
};
|
||||
}
|
||||
const existing = (isRecord(persisted) ? persisted : {}) as Partial<AppPersistedState>;
|
||||
return {
|
||||
routeDockTabs: Array.isArray(existing.routeDockTabs) ? existing.routeDockTabs : [],
|
||||
sidebarCollapsed: existing.sidebarCollapsed ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
export type { AppPersistedState, PermissionPersistedState, UserPersistedState };
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import {
|
||||
permissionPersistStorage,
|
||||
PERMISSION_STORAGE_NAME,
|
||||
migratePermissionState,
|
||||
permissionPersistStorage,
|
||||
} from '../middleware/persist';
|
||||
import type { PermissionPersistedState, PermissionStore } from './permissionTypes';
|
||||
|
||||
@@ -40,7 +41,8 @@ export const usePermissionStore = create<PermissionStore>()(
|
||||
name: PERMISSION_STORAGE_NAME,
|
||||
storage: permissionPersistStorage,
|
||||
partialize: (state): PermissionPersistedState => ({ permissions: state.permissions }),
|
||||
version: 1,
|
||||
version: 2,
|
||||
migrate: migratePermissionState,
|
||||
},
|
||||
),
|
||||
{ name: 'permission-store', enabled: import.meta.env.DEV },
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import { createJSONStorage } from 'zustand/middleware';
|
||||
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { SETTINGS_STORAGE_NAME } from '../middleware/persist';
|
||||
import type { SettingsState, SettingsStore } from './settingsTypes';
|
||||
|
||||
|
||||
@@ -9,6 +9,3 @@
|
||||
|
||||
/** 权限校验状态:未知(fail-closed)→ 校验中 → 已就绪 */
|
||||
export type StoreStatus = 'unknown' | 'loading' | 'ready';
|
||||
|
||||
/** 持久化时从 Store 中挑选出的字段 */
|
||||
export type Partialize<T> = (state: T) => Partial<T>;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import { authPersistStorage, AUTH_STORAGE_NAME } from '../middleware/persist';
|
||||
import { AUTH_STORAGE_NAME, authPersistStorage, migrateAuthState } from '../middleware/persist';
|
||||
import { createUserActions } from './userActions';
|
||||
import type { UserPersistedState, UserStore } from './userTypes';
|
||||
|
||||
/**
|
||||
* 用户会话 Store(token + 用户资料)。
|
||||
* 使用 zustand 官方推荐写法:create()(devtools(persist(...)))。
|
||||
* 持久化沿用旧 `token` / `user` localStorage key。
|
||||
* 持久化使用官方 persist + localStorage,旧 `token` / `user` key 由 migrate 一次性迁移。
|
||||
*/
|
||||
export const useUserStore = create<UserStore>()(
|
||||
devtools(
|
||||
@@ -21,7 +21,8 @@ export const useUserStore = create<UserStore>()(
|
||||
name: AUTH_STORAGE_NAME,
|
||||
storage: authPersistStorage,
|
||||
partialize: (state): UserPersistedState => ({ token: state.token, user: state.user }),
|
||||
version: 1,
|
||||
version: 2,
|
||||
migrate: migrateAuthState,
|
||||
},
|
||||
),
|
||||
{ name: 'user-store', enabled: import.meta.env.DEV },
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
/**
|
||||
* Test fixtures — consistent test data used across integration tests.
|
||||
*
|
||||
* These mirror the PRD data models and are used to seed/verify API responses.
|
||||
* All IDs are prefixed "test-" to distinguish from real data in a shared dev DB.
|
||||
*/
|
||||
|
||||
// ── Auth ────────────────────────────────────────────────────────────
|
||||
|
||||
export const CREDENTIALS = {
|
||||
superAdmin: { username: 'admin', password: 'admin123' },
|
||||
staff: { username: 'staff1', password: 'staff123' },
|
||||
classTeacher: { username: 'teacher1', password: 'teacher123' },
|
||||
student: { username: 'student1', password: 'student123' },
|
||||
} as const;
|
||||
|
||||
// ── Student (PRD §3) ────────────────────────────────────────────────
|
||||
|
||||
export const SAMPLE_STUDENT = {
|
||||
name: '测试学员A',
|
||||
phone: '13800000001',
|
||||
idCard: '110101200001011234',
|
||||
gender: '男',
|
||||
ethnicity: '汉族',
|
||||
status: 'active',
|
||||
emergencyContact: '张三',
|
||||
emergencyPhone: '13900000001',
|
||||
studentNo: 'TEST-2026-001',
|
||||
};
|
||||
|
||||
export const SAMPLE_STUDENT_B = {
|
||||
name: '测试学员B',
|
||||
phone: '13800000002',
|
||||
idCard: '110101200001011235',
|
||||
gender: '女',
|
||||
ethnicity: '汉族',
|
||||
status: 'active',
|
||||
emergencyContact: '李四',
|
||||
emergencyPhone: '13900000002',
|
||||
studentNo: 'TEST-2026-002',
|
||||
};
|
||||
|
||||
// ── Class (PRD §5) ──────────────────────────────────────────────────
|
||||
|
||||
export const SAMPLE_CLASS = {
|
||||
name: '2026届文化课冲刺1班',
|
||||
code: 'TEST-WHK-2026-001',
|
||||
classType: '文化课',
|
||||
startDate: '2026-03-01',
|
||||
endDate: '2026-06-30',
|
||||
status: '在读',
|
||||
maxStudents: 40,
|
||||
};
|
||||
|
||||
// ── Schedule (PRD §6) ───────────────────────────────────────────────
|
||||
|
||||
export const SAMPLE_SCHEDULE = {
|
||||
weekDay: 1, // 周一
|
||||
startTime: '09:00',
|
||||
endTime: '10:30',
|
||||
subject: '语文',
|
||||
scheduleType: 'INTERNAL',
|
||||
status: 'active',
|
||||
};
|
||||
|
||||
// Conflicting schedule: same classroom, same weekday, overlapping time
|
||||
export const CONFLICT_SCHEDULE = {
|
||||
weekDay: 1,
|
||||
startTime: '09:30', // overlaps with 09:00-10:30
|
||||
endTime: '11:00',
|
||||
subject: '数学',
|
||||
scheduleType: 'INTERNAL',
|
||||
status: 'active',
|
||||
};
|
||||
|
||||
// Non-conflicting: same classroom, same weekday, non-overlapping
|
||||
export const NON_CONFLICT_SCHEDULE = {
|
||||
weekDay: 1,
|
||||
startTime: '10:30', // exactly at boundary — no overlap
|
||||
endTime: '12:00',
|
||||
subject: '英语',
|
||||
scheduleType: 'INTERNAL',
|
||||
status: 'active',
|
||||
};
|
||||
|
||||
// ── Room / Dormitory (PRD §7) ───────────────────────────────────────
|
||||
|
||||
export const SAMPLE_ROOM = {
|
||||
roomNumber: 'TEST-401',
|
||||
building: '1号楼',
|
||||
floor: 4,
|
||||
capacity: 6,
|
||||
status: 'available',
|
||||
gender: '男',
|
||||
rentalCategory: 'short',
|
||||
roomType: '标准间',
|
||||
};
|
||||
|
||||
export const SAMPLE_LONG_RENT_ROOM = {
|
||||
roomNumber: 'TEST-501',
|
||||
building: '1号楼',
|
||||
floor: 5,
|
||||
capacity: 4,
|
||||
status: 'available',
|
||||
gender: '女',
|
||||
rentalCategory: 'long',
|
||||
monthlyRate: 800,
|
||||
roomType: '标准间',
|
||||
};
|
||||
|
||||
// ── Occupancy (PRD §8) ──────────────────────────────────────────────
|
||||
|
||||
export const SAMPLE_OCCUPANCY = {
|
||||
checkInDate: '2026-03-01',
|
||||
billingStartDate: '2026-03-01',
|
||||
billingEndDate: '2026-06-30',
|
||||
stayType: 'short',
|
||||
};
|
||||
|
||||
// ── Bill / Expense (PRD §9-10) ──────────────────────────────────────
|
||||
|
||||
export const SAMPLE_EXPENSE = {
|
||||
type: 'water',
|
||||
amount: 150.0,
|
||||
billingMonth: '2026-03',
|
||||
description: '3月水费公摊',
|
||||
};
|
||||
|
||||
export const SAMPLE_PERSONAL_EXPENSE = {
|
||||
type: 'damage',
|
||||
amount: 50.0,
|
||||
description: '损坏赔偿-台灯',
|
||||
};
|
||||
|
||||
// ── Deposit (PRD §11) ───────────────────────────────────────────────
|
||||
|
||||
export const SAMPLE_DEPOSIT = {
|
||||
amount: 500.0,
|
||||
type: 'collect' as const,
|
||||
notes: '入学押金',
|
||||
};
|
||||
|
||||
// ── Attendance (PRD §13) ────────────────────────────────────────────
|
||||
|
||||
export const SAMPLE_ATTENDANCE = {
|
||||
attendanceDate: '2026-03-15',
|
||||
session: '上午',
|
||||
status: '出勤',
|
||||
source: '人工点名',
|
||||
courseName: '语文',
|
||||
};
|
||||
|
||||
export const SAMPLE_ATTENDANCE_ABSENT = {
|
||||
attendanceDate: '2026-03-16',
|
||||
session: '上午',
|
||||
status: '缺勤',
|
||||
source: '人工点名',
|
||||
courseName: '语文',
|
||||
};
|
||||
|
||||
// ── Classroom (PRD §6) ──────────────────────────────────────────────
|
||||
|
||||
export const SAMPLE_CLASSROOM = {
|
||||
name: 'TEST-301教室',
|
||||
building: '教学楼A',
|
||||
floor: 3,
|
||||
capacity: 50,
|
||||
roomType: '大',
|
||||
status: 'available',
|
||||
};
|
||||
|
||||
// ── Organization (PRD §12) ────────────────────────────────────────────────
|
||||
|
||||
export const SAMPLE_TENANT = {
|
||||
name: '测试合作机构A',
|
||||
contact: '王经理',
|
||||
phone: '13700000001',
|
||||
color: '#1890ff',
|
||||
status: 'active',
|
||||
};
|
||||
|
||||
// ── Operation Log expectation (PRD §18) ─────────────────────────────
|
||||
|
||||
export const LOG_ACTIONS = {
|
||||
STUDENT_CREATE: { module: 'students', action: 'create' },
|
||||
STUDENT_UPDATE: { module: 'students', action: 'update' },
|
||||
STUDENT_DELETE: { module: 'students', action: 'delete' },
|
||||
BILL_GENERATE: { module: 'bills', action: 'generate' },
|
||||
BILL_CONFIRM: { module: 'bills', action: 'confirm' },
|
||||
DEPOSIT_COLLECT: { module: 'deposits', action: 'collect' },
|
||||
DEPOSIT_REFUND: { module: 'deposits', action: 'refund' },
|
||||
OCCUPANCY_CHECKIN: { module: 'occupancies', action: 'create' },
|
||||
OCCUPANCY_CHECKOUT: { module: 'occupancies', action: 'checkout' },
|
||||
EXPENSE_CREATE: { module: 'expenses', action: 'create' },
|
||||
CLASS_CREATE: { module: 'classes', action: 'create' },
|
||||
CLASS_DELETE: { module: 'classes', action: 'delete' },
|
||||
SCHEDULE_CREATE: { module: 'schedules', action: 'create' },
|
||||
ATTENDANCE_BATCH: { module: 'attendance', action: 'batch' },
|
||||
SENSITIVE_VIEW: { module: 'students', action: 'view_sensitive' },
|
||||
} as const;
|
||||
|
||||
// ── Permission nodes (PRD §17) ──────────────────────────────────────
|
||||
|
||||
export const PERMISSION_NODES = [
|
||||
'student:view',
|
||||
'student:add',
|
||||
'student:update',
|
||||
'student:delete',
|
||||
'student:import',
|
||||
'student:export',
|
||||
'room:view',
|
||||
'room:add',
|
||||
'room:update',
|
||||
'room:delete',
|
||||
'occupancy:view',
|
||||
'occupancy:add',
|
||||
'occupancy:update',
|
||||
'bill:view',
|
||||
'bill:generate',
|
||||
'bill:confirm',
|
||||
'bill:markPaid',
|
||||
'bill:export',
|
||||
'expense:view',
|
||||
'expense:add',
|
||||
'expense:update',
|
||||
'expense:delete',
|
||||
'deposit:view',
|
||||
'deposit:collect',
|
||||
'deposit:refund',
|
||||
'class:view',
|
||||
'class:add',
|
||||
'class:update',
|
||||
'class:delete',
|
||||
'schedule:view',
|
||||
'schedule:add',
|
||||
'schedule:update',
|
||||
'schedule:delete',
|
||||
'attendance:view',
|
||||
'attendance:add',
|
||||
'attendance:update',
|
||||
'attendance:delete',
|
||||
'attendance:batch',
|
||||
'classroom:view',
|
||||
'classroom:add',
|
||||
'classroom:update',
|
||||
'classroom:delete',
|
||||
'organization:view',
|
||||
'organization:create',
|
||||
'organization:edit',
|
||||
'organization:delete',
|
||||
'rental:view',
|
||||
'rental:add',
|
||||
'rental:update',
|
||||
'rental:delete',
|
||||
'archive:view',
|
||||
'archive:import',
|
||||
'archive:export',
|
||||
'report:generate',
|
||||
'log:view',
|
||||
'role:view',
|
||||
'role:add',
|
||||
'role:update',
|
||||
'role:delete',
|
||||
'user:view',
|
||||
'user:add',
|
||||
'user:update',
|
||||
'dashboard:view',
|
||||
] as const;
|
||||
@@ -1,173 +0,0 @@
|
||||
/**
|
||||
* Shared browser-test helpers.
|
||||
*
|
||||
* Import this in every `*.integration.test.ts` file.
|
||||
* Provides login, API calling, and page-navigation utilities
|
||||
* that work inside the Vitest browser environment.
|
||||
*/
|
||||
import { expect } from 'vitest';
|
||||
import { CREDENTIALS } from './fixtures';
|
||||
import { BASE } from './setup';
|
||||
import { usePermissionStore } from '../store/permission/permissionStore';
|
||||
import { useUserStore } from '../store/user/userStore';
|
||||
import type { UserInfo } from '../store/user/userTypes';
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────
|
||||
|
||||
interface ApiResponse<T = unknown> {
|
||||
code: number;
|
||||
data: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
type Role = keyof typeof CREDENTIALS;
|
||||
|
||||
// ── Auth helpers ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Login as a specific role and store the token in localStorage.
|
||||
* Returns the parsed response data.
|
||||
*/
|
||||
export async function loginAs(
|
||||
role: Role,
|
||||
): Promise<{ token: string; user: Record<string, unknown> }> {
|
||||
const creds = CREDENTIALS[role];
|
||||
const res = await fetch(`${BASE}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(creds),
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
const json = (await res.json()) as ApiResponse<{ token: string; user: Record<string, unknown> }>;
|
||||
expect(json.code).toBe(0);
|
||||
useUserStore.getState().setSession(json.data.token, json.data.user as unknown as UserInfo);
|
||||
usePermissionStore
|
||||
.getState()
|
||||
.writePermissions((json.data.user.permissions ?? []) as string[]);
|
||||
return json.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout: clear localStorage.
|
||||
*/
|
||||
export function logout(): void {
|
||||
useUserStore.getState().logout();
|
||||
usePermissionStore.getState().clearPermissions();
|
||||
}
|
||||
|
||||
// ── API helpers (authenticated) ─────────────────────────────────────
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const token = useUserStore.getState().token;
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function apiGet<T = unknown>(url: string): Promise<ApiResponse<T>> {
|
||||
const res = await fetch(`${BASE}${url}`, { headers: authHeaders() });
|
||||
return (await res.json()) as ApiResponse<T>;
|
||||
}
|
||||
|
||||
export async function apiPost<T = unknown>(url: string, body?: unknown): Promise<ApiResponse<T>> {
|
||||
const res = await fetch(`${BASE}${url}`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return (await res.json()) as ApiResponse<T>;
|
||||
}
|
||||
|
||||
export async function apiPut<T = unknown>(url: string, body?: unknown): Promise<ApiResponse<T>> {
|
||||
const res = await fetch(`${BASE}${url}`, {
|
||||
method: 'PUT',
|
||||
headers: authHeaders(),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return (await res.json()) as ApiResponse<T>;
|
||||
}
|
||||
|
||||
export async function apiDelete<T = unknown>(url: string): Promise<ApiResponse<T>> {
|
||||
const res = await fetch(`${BASE}${url}`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders(),
|
||||
});
|
||||
return (await res.json()) as ApiResponse<T>;
|
||||
}
|
||||
|
||||
// ── Page helpers ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Navigate to a page and wait for it to load.
|
||||
*/
|
||||
export async function goTo(path: string): Promise<void> {
|
||||
document.location.href = `${BASE}${path}`;
|
||||
// Wait for React to render
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert the current page URL contains the given path.
|
||||
*/
|
||||
export async function assertOnPage(path: string): Promise<void> {
|
||||
// Wait a tick for SPA routing
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
expect(window.location.pathname).toContain(path);
|
||||
}
|
||||
|
||||
// ── Wait helpers ────────────────────────────────────────────────────
|
||||
|
||||
/** Poll until a condition is true or timeout. */
|
||||
export async function waitFor(
|
||||
condition: () => boolean | Promise<boolean>,
|
||||
timeout = 5000,
|
||||
interval = 200,
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
if (await condition()) return;
|
||||
await new Promise((r) => setTimeout(r, interval));
|
||||
}
|
||||
throw new Error(`waitFor timed out after ${timeout}ms`);
|
||||
}
|
||||
|
||||
// ── Assertion helpers ───────────────────────────────────────────────
|
||||
|
||||
/** Assert an API response is successful (code === 0). */
|
||||
export function assertOk<T>(res: ApiResponse<T>, msg?: string): T {
|
||||
expect(res.code, msg ?? 'API should return code 0').toBe(0);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/** Assert an API response is an error (code !== 0). */
|
||||
export function assertError(res: ApiResponse, expectedCode?: number): void {
|
||||
expect(res.code).not.toBe(0);
|
||||
if (expectedCode !== undefined) {
|
||||
expect(res.code).toBe(expectedCode);
|
||||
}
|
||||
}
|
||||
|
||||
/** Assert a 403 is returned (permission denied). */
|
||||
export async function assertForbidden(promise: Promise<Response>): Promise<void> {
|
||||
const res = await promise;
|
||||
expect(res.status).toBe(403);
|
||||
}
|
||||
|
||||
/** Assert a 401 is returned (unauthenticated). */
|
||||
export async function assertUnauthenticated(promise: Promise<Response>): Promise<void> {
|
||||
const res = await promise;
|
||||
expect(res.status).toBe(401);
|
||||
}
|
||||
|
||||
// ── Sensitive data helpers (PRD §3.3) ───────────────────────────────
|
||||
|
||||
/** Assert phone number is masked: 138****0001 */
|
||||
export function assertPhoneMasked(displayed: string): void {
|
||||
expect(displayed).toMatch(/^\d{3}\*{4}\d{4}$/);
|
||||
}
|
||||
|
||||
/** Assert ID card is masked: 110101********1234 */
|
||||
export function assertIdCardMasked(displayed: string): void {
|
||||
expect(displayed).toMatch(/^\d{6}\*{8}\d{4}$/);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useUserStore } from '../store/user/userStore';
|
||||
import { saveAs } from 'file-saver';
|
||||
|
||||
/**
|
||||
* Download a file from the API as a blob and trigger a browser download.
|
||||
@@ -22,12 +23,5 @@ export async function downloadBlob(endpoint: string, filename: string): Promise<
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
saveAs(blob, filename);
|
||||
}
|
||||
|
||||
24
apps/admin/src/utils/error.ts
Normal file
24
apps/admin/src/utils/error.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import axios from 'axios';
|
||||
|
||||
/**
|
||||
* 统一从任意错误对象中提取可展示的 message。
|
||||
* 后端 4xx/5xx 经 axios 拦截器解包后通常是 { message } 普通对象;
|
||||
* 网络/超时/取消则是 axios 原生错误。
|
||||
*/
|
||||
export function getErrorMessage(error: unknown, fallback = '操作失败'): string {
|
||||
let message = '';
|
||||
if (axios.isAxiosError(error)) {
|
||||
const data = error.response?.data as { message?: unknown } | undefined;
|
||||
if (typeof data?.message === 'string' && data.message) message = data.message;
|
||||
else if (error.message) message = error.message;
|
||||
} else if (error && typeof error === 'object' && 'message' in error) {
|
||||
const value = (error as { message?: unknown }).message;
|
||||
if (typeof value === 'string' && value) message = value;
|
||||
} else if (typeof error === 'string' && error) {
|
||||
message = error;
|
||||
}
|
||||
const trimmed = message.trim();
|
||||
if (!trimmed) return fallback;
|
||||
const singleLine = trimmed.replace(/[\n\r]+/g, ' ').replace(/ {2,}/g, ' ');
|
||||
return singleLine.length > 120 ? singleLine.slice(0, 120) + '\u2026' : singleLine;
|
||||
}
|
||||
16
apps/admin/src/utils/validate.ts
Normal file
16
apps/admin/src/utils/validate.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* 用 zod schema 校验接口响应;失败时抛出带字段路径的错误,
|
||||
* 由调用方的统一错误处理(getErrorMessage / useApiMutation)展示。
|
||||
*/
|
||||
export function validateResponse<T>(schema: z.ZodType<unknown>, data: unknown): T {
|
||||
const result = schema.safeParse(data);
|
||||
if (!result.success) {
|
||||
const first = result.error.issues[0];
|
||||
const path = first?.path?.join('.');
|
||||
console.error('[response-validation]', result.error.issues);
|
||||
throw new Error(path ? `接口字段 ${path} 格式异常` : '接口数据格式异常');
|
||||
}
|
||||
return result.data as T;
|
||||
}
|
||||
Reference in New Issue
Block a user