refactor: 前端应用骨架迁移至 zustand 并统一路由权限壳
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import React, { Suspense, lazy } from 'react';
|
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 { 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 xZhCN from '@ant-design/x/es/locale/zh_CN';
|
||||||
import zhCN from 'antd/es/locale/zh_CN';
|
import zhCN from 'antd/es/locale/zh_CN';
|
||||||
import MainLayout from './layouts/MainLayout';
|
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 {
|
export interface AppMenuItem {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -36,100 +37,112 @@ const ROLE_ALIASES: Record<string, string> = {
|
|||||||
super_admin: 'super',
|
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[] = [
|
const SECTIONS: MenuSection[] = [
|
||||||
{
|
section(
|
||||||
key: 'teaching-group',
|
'teaching-group',
|
||||||
label: '教学工作',
|
'教学工作',
|
||||||
icon: 'calendar',
|
'calendar',
|
||||||
roles: ['teacher'],
|
['teacher'],
|
||||||
children: [
|
[
|
||||||
{
|
entry('/teacher-workspace', '今日教学', 'workspace', 'teacher-workspace:view'),
|
||||||
key: '/teacher-workspace',
|
|
||||||
label: '今日教学',
|
entry('/schedules', '我的排课', 'calendar', 'schedule:view'),
|
||||||
icon: 'workspace',
|
|
||||||
permission: 'teacher-workspace:view',
|
entry('/attendance', '课程考勤', 'attendance', 'attendance:view'),
|
||||||
},
|
|
||||||
{ key: '/schedules', label: '我的排课', icon: 'calendar', permission: 'schedule:view' },
|
|
||||||
{ key: '/attendance', label: '课程考勤', icon: 'attendance', permission: 'attendance:view' },
|
|
||||||
],
|
],
|
||||||
},
|
),
|
||||||
{
|
section(
|
||||||
key: 'academic-group',
|
'academic-group',
|
||||||
label: '教务管理',
|
'教务管理',
|
||||||
icon: 'academic',
|
'academic',
|
||||||
roles: ['academic', 'super'],
|
['academic', 'super'],
|
||||||
children: [
|
[
|
||||||
{ key: '/students', label: '学生管理', icon: 'students', permission: 'student:view' },
|
entry('/students', '学生管理', 'students', 'student:view'),
|
||||||
{ key: '/classes', label: '班级管理', icon: 'classes', permission: 'class:view' },
|
|
||||||
{ key: '/exams', label: '考试管理', icon: 'exam', permission: 'exam:view' },
|
entry('/classes', '班级管理', 'classes', 'class:view'),
|
||||||
{ key: '/teachers', label: '教师管理', icon: 'teachers', permission: 'teacher:view' },
|
|
||||||
{ key: '/schedules', label: '排课管理', icon: 'calendar', permission: 'schedule:view' },
|
entry('/exams', '考试管理', 'exam', 'exam:view'),
|
||||||
{ key: '/attendance', label: '历史考勤', icon: 'attendance', permission: 'attendance:view' },
|
|
||||||
{ key: '/classrooms', label: '教室查看', icon: 'classroom', permission: 'classroom:view' },
|
entry('/teachers', '教师管理', 'teachers', 'teacher:view'),
|
||||||
|
|
||||||
|
entry('/schedules', '排课管理', 'calendar', 'schedule:view'),
|
||||||
|
|
||||||
|
entry('/attendance', '历史考勤', 'attendance', 'attendance:view'),
|
||||||
|
|
||||||
|
entry('/classrooms', '教室查看', 'classroom', 'classroom:view'),
|
||||||
],
|
],
|
||||||
},
|
),
|
||||||
{
|
section(
|
||||||
key: 'accommodation-group',
|
'accommodation-group',
|
||||||
label: '住宿运营',
|
'住宿运营',
|
||||||
icon: 'home',
|
'home',
|
||||||
roles: ['accommodation', 'super'],
|
['accommodation', 'super'],
|
||||||
children: [
|
[
|
||||||
{ key: '/room-visual', label: '住宿总览', icon: 'overview', permission: 'room:view' },
|
entry('/room-visual', '住宿总览', 'overview', 'room:view'),
|
||||||
{ key: '/rooms', label: '房间管理', icon: 'home', permission: 'room:view' },
|
|
||||||
{ key: '/occupancies', label: '入住管理', icon: 'occupancy', permission: 'occupancy:view' },
|
entry('/rooms', '房间管理', 'home', 'room:view'),
|
||||||
{ key: '/expenses', label: '费用管理', icon: 'expense', permission: 'expense:view' },
|
|
||||||
{ key: '/bills', label: '账单管理', icon: 'bill', permission: 'bill:view' },
|
entry('/occupancies', '入住管理', 'occupancy', 'occupancy:view'),
|
||||||
{ key: '/wallets', label: '学生余额', icon: 'wallet', permission: 'wallet:view' },
|
|
||||||
{ key: '/deposits', label: '押金管理', icon: 'deposit', permission: 'deposit:view' },
|
entry('/expenses', '费用管理', 'expense', 'expense:view'),
|
||||||
|
|
||||||
|
entry('/bills', '账单管理', 'bill', 'bill:view'),
|
||||||
|
|
||||||
|
entry('/wallets', '学生余额', 'wallet', 'wallet:view'),
|
||||||
|
|
||||||
|
entry('/deposits', '押金管理', 'deposit', 'deposit:view'),
|
||||||
],
|
],
|
||||||
},
|
),
|
||||||
{
|
section(
|
||||||
key: 'classroom-group',
|
'classroom-group',
|
||||||
label: '教室运营',
|
'教室运营',
|
||||||
icon: 'classroom',
|
'classroom',
|
||||||
roles: ['classroom', 'super'],
|
['classroom', 'super'],
|
||||||
children: [
|
[
|
||||||
{
|
entry('/classroom-schedule', '教室排期', 'calendar', 'rental:view'),
|
||||||
key: '/classroom-schedule',
|
|
||||||
label: '教室排期',
|
entry('/classrooms', '教室管理', 'classroom', 'classroom:view'),
|
||||||
icon: 'calendar',
|
|
||||||
permission: 'rental:view',
|
entry('/attendance-devices', '考勤机绑定', 'attendance', 'classroom:view'),
|
||||||
},
|
|
||||||
{ key: '/classrooms', label: '教室管理', icon: 'classroom', permission: 'classroom:view' },
|
entry('/classroom-rentals', '租赁订单', 'rental', 'rental:view'),
|
||||||
{
|
|
||||||
key: '/attendance-devices',
|
entry('/organizations', '机构管理', 'organization', 'organization:view'),
|
||||||
label: '考勤机绑定',
|
|
||||||
icon: 'attendance',
|
|
||||||
permission: 'classroom:view',
|
|
||||||
},
|
|
||||||
{ key: '/classroom-rentals', label: '租赁订单', icon: 'rental', permission: 'rental:view' },
|
|
||||||
{
|
|
||||||
key: '/organizations',
|
|
||||||
label: '机构管理',
|
|
||||||
icon: 'organization',
|
|
||||||
permission: 'organization:view',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
),
|
||||||
{
|
section(
|
||||||
key: 'system-group',
|
'system-group',
|
||||||
label: '系统管理',
|
'系统管理',
|
||||||
icon: 'settings',
|
'settings',
|
||||||
roles: ['system', 'super'],
|
['system', 'super'],
|
||||||
children: [
|
[
|
||||||
{ key: '/users', label: '账号管理', icon: 'users', permission: 'user:view' },
|
entry('/users', '账号管理', 'users', 'user:view'),
|
||||||
{ key: '/roles', label: '角色管理', icon: 'role', permission: 'role:view' },
|
|
||||||
{ key: '/permissions', label: '权限一览', icon: 'permission', permission: 'role:view' },
|
entry('/roles', '角色管理', 'role', 'role:view'),
|
||||||
{ key: '/operation-logs', label: '操作日志', icon: 'log', permission: 'log:view' },
|
|
||||||
{
|
entry('/permissions', '权限一览', 'permission', 'role:view'),
|
||||||
key: '/integration-config',
|
|
||||||
label: '钉钉集成',
|
entry('/operation-logs', '操作日志', 'log', 'log:view'),
|
||||||
icon: 'integration',
|
|
||||||
permission: 'integration:read',
|
entry('/integration-config', '钉钉集成', 'integration', 'integration:read'),
|
||||||
},
|
|
||||||
{ key: '/ai-config', label: 'AI 配置', icon: 'ai', permission: 'ai:config:read' },
|
entry('/ai-config', 'AI 配置', 'ai', 'ai:config:read'),
|
||||||
],
|
],
|
||||||
},
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
export function getRoleDomains(
|
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 React from 'react';
|
||||||
import { Navigate } from 'react-router-dom';
|
import { Navigate } from 'react-router';
|
||||||
import { Result, Spin } from 'antd';
|
import { Result, Spin } from 'antd';
|
||||||
import { usePermission } from '../hooks/usePermission';
|
import { usePermission } from '../hooks/usePermission';
|
||||||
import { findRoleAwareLandingPath } from '../auth/menu-policy';
|
import { findRoleAwareLandingPath } from '../auth/menu-policy';
|
||||||
@@ -7,10 +7,10 @@ import { useUserStore } from '../store/user/userStore';
|
|||||||
|
|
||||||
const DefaultRoute: React.FC = () => {
|
const DefaultRoute: React.FC = () => {
|
||||||
const { permissions, permissionsReady } = usePermission();
|
const { permissions, permissionsReady } = usePermission();
|
||||||
|
const roles = useUserStore((state) => state.user?.roles ?? []);
|
||||||
if (!permissionsReady) {
|
if (!permissionsReady) {
|
||||||
return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
|
return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
|
||||||
}
|
}
|
||||||
const roles = useUserStore((state) => state.user?.roles ?? []);
|
|
||||||
const firstPath = findRoleAwareLandingPath(roles, permissions);
|
const firstPath = findRoleAwareLandingPath(roles, permissions);
|
||||||
if (firstPath) return <Navigate to={firstPath} replace />;
|
if (firstPath) return <Navigate to={firstPath} replace />;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { Badge, Popover, Button, List, Typography, Empty } from 'antd';
|
import { Badge, Popover, Button, List, Typography, Empty } from 'antd';
|
||||||
import { BellOutlined } from '@ant-design/icons';
|
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 api from '../api';
|
||||||
import { formatNotificationText, notificationTypeLabels } from '../utils/notification-display';
|
import { formatNotificationText, notificationTypeLabels } from '../utils/notification-display';
|
||||||
import { useUserStore } from '../store/user/userStore';
|
import { useUserStore } from '../store/user/userStore';
|
||||||
@@ -17,25 +19,19 @@ interface NotificationItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function timeAgo(dateStr: string): string {
|
function timeAgo(dateStr: string): string {
|
||||||
const diff = Date.now() - new Date(dateStr).getTime();
|
return dayjs(dateStr).fromNow();
|
||||||
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}天前`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const NotificationBell: React.FC = () => {
|
const NotificationBell: React.FC = () => {
|
||||||
const [unreadCount, setUnreadCount] = useState(0);
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [sseDown, setSseDown] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const fetchNotifications = async () => {
|
const fetchNotifications = async () => {
|
||||||
try {
|
try {
|
||||||
const data = (await api.get('/notifications?limit=20')) as unknown as NotificationItem[];
|
const data = await api.get<NotificationItem[]>('/notifications?limit=20');
|
||||||
setNotifications(data);
|
setNotifications(data);
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
@@ -44,7 +40,7 @@ const NotificationBell: React.FC = () => {
|
|||||||
|
|
||||||
const fetchUnread = async () => {
|
const fetchUnread = async () => {
|
||||||
try {
|
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);
|
setUnreadCount(data.count);
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
@@ -52,7 +48,9 @@ const NotificationBell: React.FC = () => {
|
|||||||
};
|
};
|
||||||
const openRef = useRef(open);
|
const openRef = useRef(open);
|
||||||
openRef.current = open;
|
openRef.current = open;
|
||||||
const retryRef = useRef<number | null>(null);
|
useInterval(() => {
|
||||||
|
void fetchUnread();
|
||||||
|
}, sseDown ? 60_000 : null);
|
||||||
|
|
||||||
// SSE connection — decoupled from popover open state
|
// SSE connection — decoupled from popover open state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -71,13 +69,11 @@ const NotificationBell: React.FC = () => {
|
|||||||
};
|
};
|
||||||
es.onerror = () => {
|
es.onerror = () => {
|
||||||
es.close();
|
es.close();
|
||||||
if (retryRef.current !== null) clearInterval(retryRef.current);
|
setSseDown(true);
|
||||||
retryRef.current = window.setInterval(fetchUnread, 60_000);
|
|
||||||
};
|
};
|
||||||
return () => {
|
return () => {
|
||||||
es.close();
|
es.close();
|
||||||
clearInterval(retryRef.current ?? undefined);
|
setSseDown(false);
|
||||||
retryRef.current = null;
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Button } from 'antd';
|
import { Button, type ButtonProps } from 'antd';
|
||||||
import type { ButtonProps } from 'antd';
|
|
||||||
import { usePermission } from '../hooks/usePermission';
|
import { usePermission } from '../hooks/usePermission';
|
||||||
|
|
||||||
interface PermissionButtonProps extends ButtonProps {
|
interface PermissionButtonProps extends ButtonProps {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Result, Button, Spin } from 'antd';
|
import { Result, Button, Spin } from 'antd';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router';
|
||||||
import { findRoleAwareLandingPath } from '../auth/menu-policy';
|
import { findRoleAwareLandingPath } from '../auth/menu-policy';
|
||||||
import { usePermission } from '../hooks/usePermission';
|
import { usePermission } from '../hooks/usePermission';
|
||||||
import { useUserStore } from '../store/user/userStore';
|
import { useUserStore } from '../store/user/userStore';
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import React, { useEffect, useMemo } from 'react';
|
import React, { useEffect, useMemo } from 'react';
|
||||||
import type { DragEndEvent } from '@dnd-kit/core';
|
import {
|
||||||
import { closestCenter, DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
closestCenter,
|
||||||
|
DndContext,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
type DragEndEvent,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
import {
|
import {
|
||||||
arrayMove,
|
arrayMove,
|
||||||
horizontalListSortingStrategy,
|
horizontalListSortingStrategy,
|
||||||
@@ -8,9 +14,8 @@ import {
|
|||||||
useSortable,
|
useSortable,
|
||||||
} from '@dnd-kit/sortable';
|
} from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { Tabs } from 'antd';
|
import { Tabs, type TabsProps } from 'antd';
|
||||||
import type { TabsProps } from 'antd';
|
import type { Location } from 'react-router';
|
||||||
import type { Location } from 'react-router-dom';
|
|
||||||
import type { AppMenuItem } from '../../auth/menu-policy';
|
import type { AppMenuItem } from '../../auth/menu-policy';
|
||||||
import { useAppStore } from '../../store';
|
import { useAppStore } from '../../store';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { act } from 'react';
|
import { act } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
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 { afterEach, describe, expect, it } from 'vitest';
|
||||||
import { RouteKeeper } from './RouteKeeper';
|
import { RouteKeeper } from './RouteKeeper';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useRef } from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { useLocation, useOutlet } from 'react-router-dom';
|
import { useLocation, useOutlet } from 'react-router';
|
||||||
|
|
||||||
const MAX_CACHED_PAGES = 30;
|
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 { useCallback, useEffect, useRef } from 'react';
|
||||||
import { Modal } from 'antd';
|
import { App } from 'antd';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
import { message } from '../ui/app-message';
|
import { message } from '../ui/app-message';
|
||||||
|
|
||||||
@@ -13,8 +13,9 @@ import { message } from '../ui/app-message';
|
|||||||
* already-open confirm modal is destroyed.
|
* already-open confirm modal is destroyed.
|
||||||
*/
|
*/
|
||||||
export function useViewSensitive(studentId: number, module: string, canLog: boolean) {
|
export function useViewSensitive(studentId: number, module: string, canLog: boolean) {
|
||||||
|
const { modal } = App.useApp();
|
||||||
const canLogRef = useRef(canLog);
|
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;
|
canLogRef.current = canLog;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -31,7 +32,7 @@ export function useViewSensitive(studentId: number, module: string, canLog: bool
|
|||||||
return useCallback(
|
return useCallback(
|
||||||
(field: string, value: string) => {
|
(field: string, value: string) => {
|
||||||
if (!canLogRef.current) return;
|
if (!canLogRef.current) return;
|
||||||
modalRef.current = Modal.confirm({
|
modalRef.current = modal.confirm({
|
||||||
title: '查看敏感信息',
|
title: '查看敏感信息',
|
||||||
content: `您即将查看 "${field}" 的完整信息。此操作将被记录。`,
|
content: `您即将查看 "${field}" 的完整信息。此操作将被记录。`,
|
||||||
okText: '确认查看',
|
okText: '确认查看',
|
||||||
@@ -50,7 +51,7 @@ export function useViewSensitive(studentId: number, module: string, canLog: bool
|
|||||||
message.error('操作日志记录失败,请稍后重试');
|
message.error('操作日志记录失败,请稍后重试');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Modal.info({
|
modal.info({
|
||||||
title: field,
|
title: field,
|
||||||
content: value,
|
content: value,
|
||||||
okText: '关闭',
|
okText: '关闭',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
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 { Layout, Menu, Button, Avatar, Badge, Dropdown, Drawer, Grid, Tooltip } from 'antd';
|
||||||
|
import { BrandLogo } from '../components/BrandLogo';
|
||||||
import {
|
import {
|
||||||
DashboardOutlined,
|
DashboardOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
@@ -262,7 +263,17 @@ const MainLayout: React.FC = () => {
|
|||||||
borderBottom: '1px solid #e5e5e7',
|
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>
|
</div>
|
||||||
{menuContent}
|
{menuContent}
|
||||||
</Sider>
|
</Sider>
|
||||||
@@ -275,7 +286,12 @@ const MainLayout: React.FC = () => {
|
|||||||
size={240}
|
size={240}
|
||||||
styles={{ body: { padding: 0 } }}
|
styles={{ body: { padding: 0 } }}
|
||||||
className="app-navigation-drawer"
|
className="app-navigation-drawer"
|
||||||
title="学生管理系统"
|
title={
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<BrandLogo size={24} />
|
||||||
|
学生管理系统
|
||||||
|
</span>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{menuContent}
|
{menuContent}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
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 App from './App';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import 'dayjs/locale/zh-cn';
|
import 'dayjs/locale/zh-cn';
|
||||||
import customParseFormat from 'dayjs/plugin/customParseFormat';
|
import customParseFormat from 'dayjs/plugin/customParseFormat';
|
||||||
import advancedFormat from 'dayjs/plugin/advancedFormat';
|
import advancedFormat from 'dayjs/plugin/advancedFormat';
|
||||||
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
import weekday from 'dayjs/plugin/weekday';
|
import weekday from 'dayjs/plugin/weekday';
|
||||||
import localeData from 'dayjs/plugin/localeData';
|
import localeData from 'dayjs/plugin/localeData';
|
||||||
import weekOfYear from 'dayjs/plugin/weekOfYear';
|
import weekOfYear from 'dayjs/plugin/weekOfYear';
|
||||||
@@ -15,6 +18,7 @@ import updateLocale from 'dayjs/plugin/updateLocale';
|
|||||||
// 扩展 antd DatePicker/RangePicker 面板所需的 dayjs 插件,否则中文 locale 无法生效
|
// 扩展 antd DatePicker/RangePicker 面板所需的 dayjs 插件,否则中文 locale 无法生效
|
||||||
dayjs.extend(customParseFormat);
|
dayjs.extend(customParseFormat);
|
||||||
dayjs.extend(advancedFormat);
|
dayjs.extend(advancedFormat);
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
dayjs.extend(weekday);
|
dayjs.extend(weekday);
|
||||||
dayjs.extend(localeData);
|
dayjs.extend(localeData);
|
||||||
dayjs.extend(weekOfYear);
|
dayjs.extend(weekOfYear);
|
||||||
@@ -24,8 +28,20 @@ dayjs.extend(updateLocale);
|
|||||||
// 必须在所有插件加载后设置 locale
|
// 必须在所有插件加载后设置 locale
|
||||||
dayjs.locale('zh-cn');
|
dayjs.locale('zh-cn');
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: 1,
|
||||||
|
staleTime: 30_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<App />
|
||||||
|
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
|
||||||
|
</QueryClientProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useCallback, useState } from 'react';
|
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 { Form, Input, Button, Card, Typography } from 'antd';
|
||||||
import { UserOutlined, LockOutlined } from '@ant-design/icons';
|
import { UserOutlined, LockOutlined } from '@ant-design/icons';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
|
import { BrandLogo } from '../../components/BrandLogo';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
import { findRoleAwareLandingPath } from '../../auth/menu-policy';
|
import { findRoleAwareLandingPath } from '../../auth/menu-policy';
|
||||||
import { usePermissionStore } from '../../store/permission/permissionStore';
|
import { usePermissionStore } from '../../store/permission/permissionStore';
|
||||||
@@ -59,7 +60,11 @@ const LoginPage: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
<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>
|
</Title>
|
||||||
<p style={{ color: '#86868b', marginTop: 8 }}>学生综合管理平台</p>
|
<p style={{ color: '#86868b', marginTop: 8 }}>学生综合管理平台</p>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { devtools, persist } from 'zustand/middleware';
|
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';
|
import type { AppPersistedState, AppStore } from './appTypes';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,7 +59,8 @@ export const useAppStore = create<AppStore>()(
|
|||||||
sidebarCollapsed: state.sidebarCollapsed,
|
sidebarCollapsed: state.sidebarCollapsed,
|
||||||
routeDockTabs: state.routeDockTabs,
|
routeDockTabs: state.routeDockTabs,
|
||||||
}),
|
}),
|
||||||
version: 1,
|
version: 2,
|
||||||
|
migrate: migrateAppUiState,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
{ name: 'app-store', enabled: import.meta.env.DEV },
|
{ name: 'app-store', enabled: import.meta.env.DEV },
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* 持久化中间件基础设施。
|
* 持久化中间件基础设施。
|
||||||
*
|
*
|
||||||
* 为了平滑迁移,这里把旧实现直接读写 localStorage 的 key
|
* 所有 Store 统一使用 zustand 官方 persist + createJSONStorage(localStorage)。
|
||||||
* (token / user / permissions / gongxue-route-dock)包装成 zustand
|
* zustand 只有在「新 key 下已存在数据」时才会执行 migrate,因此旧 key/旧格式
|
||||||
* persist 的 StateStorage,保证迁移前后数据格式兼容。
|
* 不能只靠 migrate 迁移:这里通过 StateStorage.getItem 的旧值回退(把旧数据
|
||||||
|
* 包装成 version:1 的 persist envelope),保证升级后首次加载就能触发 migrate,
|
||||||
|
* 并在首次写入新格式时清理旧 key。
|
||||||
*/
|
*/
|
||||||
import { createJSONStorage, type StateStorage } from 'zustand/middleware';
|
import { createJSONStorage, type StateStorage } from 'zustand/middleware';
|
||||||
import type { AppPersistedState, DockTab } from '../app/appTypes';
|
import type { AppPersistedState, DockTab } from '../app/appTypes';
|
||||||
@@ -38,111 +40,94 @@ function isDockTab(value: unknown): value is DockTab {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function readLegacyAuth(): { token: string | null; user: UserInfo | null } {
|
||||||
* 用户会话持久化:继续使用旧的 `token` / `user` 两个 key,
|
const token = localStorage.getItem(LEGACY_TOKEN_KEY);
|
||||||
* 保持与后端、既有代码及浏览器缓存格式一致。
|
const rawUser = localStorage.getItem(LEGACY_USER_KEY);
|
||||||
*/
|
let user: UserInfo | null = null;
|
||||||
const legacyAuthStorage: StateStorage = {
|
if (rawUser !== null) {
|
||||||
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) => {
|
|
||||||
try {
|
try {
|
||||||
const persisted = JSON.parse(value) as { state?: UserPersistedState };
|
const parsed: unknown = JSON.parse(rawUser);
|
||||||
const { token, user } = persisted.state ?? {};
|
user = isRecord(parsed) ? (parsed as UserInfo) : null;
|
||||||
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);
|
|
||||||
}
|
|
||||||
} catch {
|
} 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_TOKEN_KEY);
|
||||||
localStorage.removeItem(LEGACY_USER_KEY);
|
localStorage.removeItem(LEGACY_USER_KEY);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
export const authPersistStorage = createJSONStorage(() =>
|
||||||
* 权限持久化:兼容旧格式(原始 JSON 数组)与 zustand persist 格式。
|
legacyFallbackStorage(authAdapter),
|
||||||
* 无论磁盘上是什么状态,恢复后一律为 `unknown`,保持 fail-closed,
|
);
|
||||||
* 直到 `/auth/profile` 校验成功。
|
|
||||||
*/
|
/** 权限:旧格式是 permissions 下的裸数组,读取时统一包装成 envelope 以触发 migrate */
|
||||||
const legacyPermissionStorage: StateStorage = {
|
const permissionStorage: StateStorage = {
|
||||||
getItem: () => {
|
getItem: (name) => {
|
||||||
const raw = localStorage.getItem(PERMISSION_STORAGE_NAME);
|
const raw = localStorage.getItem(name);
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
try {
|
try {
|
||||||
const parsed: unknown = JSON.parse(raw);
|
const parsed: unknown = JSON.parse(raw);
|
||||||
if (Array.isArray(parsed)) {
|
if (Array.isArray(parsed)) {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
state: { permissions: parsed.filter(isString), status: 'unknown' },
|
state: { permissions: parsed.filter(isString) },
|
||||||
version: 1,
|
version: 1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (isRecord(parsed) && isRecord(parsed.state)) {
|
return raw;
|
||||||
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 });
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -163,13 +148,84 @@ const appUiStorage: StateStorage = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 会话 Store 使用的 persist storage(兼容旧 token/user key) */
|
export const permissionPersistStorage = createJSONStorage(() => permissionStorage);
|
||||||
export const authPersistStorage = createJSONStorage(() => legacyAuthStorage);
|
|
||||||
|
|
||||||
/** 权限 Store 使用的 persist storage(兼容旧 permissions key) */
|
/** 应用 UI:旧 gongxue-route-dock key 包装成 version:1 envelope */
|
||||||
export const permissionPersistStorage = createJSONStorage(() => legacyPermissionStorage);
|
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 迁移) */
|
const appUiAdapter: LegacyAdapter = {
|
||||||
export const appUiPersistStorage = createJSONStorage(() => appUiStorage);
|
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 };
|
export type { AppPersistedState, PermissionPersistedState, UserPersistedState };
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { devtools, persist } from 'zustand/middleware';
|
import { devtools, persist } from 'zustand/middleware';
|
||||||
import {
|
import {
|
||||||
permissionPersistStorage,
|
|
||||||
PERMISSION_STORAGE_NAME,
|
PERMISSION_STORAGE_NAME,
|
||||||
|
migratePermissionState,
|
||||||
|
permissionPersistStorage,
|
||||||
} from '../middleware/persist';
|
} from '../middleware/persist';
|
||||||
import type { PermissionPersistedState, PermissionStore } from './permissionTypes';
|
import type { PermissionPersistedState, PermissionStore } from './permissionTypes';
|
||||||
|
|
||||||
@@ -40,7 +41,8 @@ export const usePermissionStore = create<PermissionStore>()(
|
|||||||
name: PERMISSION_STORAGE_NAME,
|
name: PERMISSION_STORAGE_NAME,
|
||||||
storage: permissionPersistStorage,
|
storage: permissionPersistStorage,
|
||||||
partialize: (state): PermissionPersistedState => ({ permissions: state.permissions }),
|
partialize: (state): PermissionPersistedState => ({ permissions: state.permissions }),
|
||||||
version: 1,
|
version: 2,
|
||||||
|
migrate: migratePermissionState,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
{ name: 'permission-store', enabled: import.meta.env.DEV },
|
{ name: 'permission-store', enabled: import.meta.env.DEV },
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { devtools, persist } from 'zustand/middleware';
|
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
|
||||||
import { createJSONStorage } from 'zustand/middleware';
|
|
||||||
import { SETTINGS_STORAGE_NAME } from '../middleware/persist';
|
import { SETTINGS_STORAGE_NAME } from '../middleware/persist';
|
||||||
import type { SettingsState, SettingsStore } from './settingsTypes';
|
import type { SettingsState, SettingsStore } from './settingsTypes';
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,3 @@
|
|||||||
|
|
||||||
/** 权限校验状态:未知(fail-closed)→ 校验中 → 已就绪 */
|
/** 权限校验状态:未知(fail-closed)→ 校验中 → 已就绪 */
|
||||||
export type StoreStatus = 'unknown' | 'loading' | 'ready';
|
export type StoreStatus = 'unknown' | 'loading' | 'ready';
|
||||||
|
|
||||||
/** 持久化时从 Store 中挑选出的字段 */
|
|
||||||
export type Partialize<T> = (state: T) => Partial<T>;
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { devtools, persist } from 'zustand/middleware';
|
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 { createUserActions } from './userActions';
|
||||||
import type { UserPersistedState, UserStore } from './userTypes';
|
import type { UserPersistedState, UserStore } from './userTypes';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户会话 Store(token + 用户资料)。
|
* 用户会话 Store(token + 用户资料)。
|
||||||
* 使用 zustand 官方推荐写法:create()(devtools(persist(...)))。
|
* 使用 zustand 官方推荐写法:create()(devtools(persist(...)))。
|
||||||
* 持久化沿用旧 `token` / `user` localStorage key。
|
* 持久化使用官方 persist + localStorage,旧 `token` / `user` key 由 migrate 一次性迁移。
|
||||||
*/
|
*/
|
||||||
export const useUserStore = create<UserStore>()(
|
export const useUserStore = create<UserStore>()(
|
||||||
devtools(
|
devtools(
|
||||||
@@ -21,7 +21,8 @@ export const useUserStore = create<UserStore>()(
|
|||||||
name: AUTH_STORAGE_NAME,
|
name: AUTH_STORAGE_NAME,
|
||||||
storage: authPersistStorage,
|
storage: authPersistStorage,
|
||||||
partialize: (state): UserPersistedState => ({ token: state.token, user: state.user }),
|
partialize: (state): UserPersistedState => ({ token: state.token, user: state.user }),
|
||||||
version: 1,
|
version: 2,
|
||||||
|
migrate: migrateAuthState,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
{ name: 'user-store', enabled: import.meta.env.DEV },
|
{ 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 { useUserStore } from '../store/user/userStore';
|
||||||
|
import { saveAs } from 'file-saver';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download a file from the API as a blob and trigger a browser download.
|
* 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 blob = await res.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
saveAs(blob, filename);
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = filename;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
}
|
||||||
|
|||||||
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