diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 2176015..f17a264 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -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'; diff --git a/apps/admin/src/api/schemas/ai.ts b/apps/admin/src/api/schemas/ai.ts new file mode 100644 index 0000000..4a81d4c --- /dev/null +++ b/apps/admin/src/api/schemas/ai.ts @@ -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(); + +/** 导入任务 */ diff --git a/apps/admin/src/api/schemas/attendance.ts b/apps/admin/src/api/schemas/attendance.ts new file mode 100644 index 0000000..66f4231 --- /dev/null +++ b/apps/admin/src/api/schemas/attendance.ts @@ -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); diff --git a/apps/admin/src/api/schemas/core.ts b/apps/admin/src/api/schemas/core.ts new file mode 100644 index 0000000..38295e9 --- /dev/null +++ b/apps/admin/src/api/schemas/core.ts @@ -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(); + +/** 集成配置 */ diff --git a/apps/admin/src/api/schemas/dashboard.ts b/apps/admin/src/api/schemas/dashboard.ts new file mode 100644 index 0000000..99a3ea0 --- /dev/null +++ b/apps/admin/src/api/schemas/dashboard.ts @@ -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(); + +/** 考勤元数据 */ diff --git a/apps/admin/src/api/schemas/import-run.ts b/apps/admin/src/api/schemas/import-run.ts new file mode 100644 index 0000000..4ada227 --- /dev/null +++ b/apps/admin/src/api/schemas/import-run.ts @@ -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(); + +/** 考勤记录 */ diff --git a/apps/admin/src/api/schemas/index.ts b/apps/admin/src/api/schemas/index.ts new file mode 100644 index 0000000..a3a7173 --- /dev/null +++ b/apps/admin/src/api/schemas/index.ts @@ -0,0 +1,6 @@ +export * from './core'; +export * from './attendance'; +export * from './dashboard'; +export * from './import-run'; +export * from './ai'; +export * from './integration'; diff --git a/apps/admin/src/api/schemas/integration.ts b/apps/admin/src/api/schemas/integration.ts new file mode 100644 index 0000000..d6639a3 --- /dev/null +++ b/apps/admin/src/api/schemas/integration.ts @@ -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(), +); + +/** 教室排课总览 */ diff --git a/apps/admin/src/auth/menu-policy.ts b/apps/admin/src/auth/menu-policy.ts index ceff6c5..7f427c4 100644 --- a/apps/admin/src/auth/menu-policy.ts +++ b/apps/admin/src/auth/menu-policy.ts @@ -1,3 +1,4 @@ +// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化 export interface AppMenuItem { key: string; label: string; @@ -36,100 +37,112 @@ const ROLE_ALIASES: Record = { 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( diff --git a/apps/admin/src/components/BrandLogo.tsx b/apps/admin/src/components/BrandLogo.tsx new file mode 100644 index 0000000..e0988b3 --- /dev/null +++ b/apps/admin/src/components/BrandLogo.tsx @@ -0,0 +1,24 @@ +import { ReadOutlined } from '@ant-design/icons'; + +const BRAND_COLOR = '#7e14ff'; + +/** 全局品牌标识:登录页 / 侧边栏 / 页头统一使用 */ +export function BrandLogo({ size = 32 }: { size?: number }) { + return ( + + + + ); +} diff --git a/apps/admin/src/components/DefaultRoute.tsx b/apps/admin/src/components/DefaultRoute.tsx index c9566b5..af76a1b 100644 --- a/apps/admin/src/components/DefaultRoute.tsx +++ b/apps/admin/src/components/DefaultRoute.tsx @@ -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 ; } - const roles = useUserStore((state) => state.user?.roles ?? []); const firstPath = findRoleAwareLandingPath(roles, permissions); if (firstPath) return ; return ( diff --git a/apps/admin/src/components/NotificationBell.tsx b/apps/admin/src/components/NotificationBell.tsx index e8fe643..3f194bc 100644 --- a/apps/admin/src/components/NotificationBell.tsx +++ b/apps/admin/src/components/NotificationBell.tsx @@ -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([]); 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('/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(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); }; }, []); diff --git a/apps/admin/src/components/PermissionButton.tsx b/apps/admin/src/components/PermissionButton.tsx index 26c2c0a..c2127fa 100644 --- a/apps/admin/src/components/PermissionButton.tsx +++ b/apps/admin/src/components/PermissionButton.tsx @@ -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 { diff --git a/apps/admin/src/components/PermissionRoute.tsx b/apps/admin/src/components/PermissionRoute.tsx index ff3f805..0f37570 100644 --- a/apps/admin/src/components/PermissionRoute.tsx +++ b/apps/admin/src/components/PermissionRoute.tsx @@ -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'; diff --git a/apps/admin/src/components/RouteDock/index.tsx b/apps/admin/src/components/RouteDock/index.tsx index 79359ab..14bcacc 100644 --- a/apps/admin/src/components/RouteDock/index.tsx +++ b/apps/admin/src/components/RouteDock/index.tsx @@ -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'; diff --git a/apps/admin/src/components/RouteKeeper.integration.test.tsx b/apps/admin/src/components/RouteKeeper.integration.test.tsx index 8c23e72..1aa0c4d 100644 --- a/apps/admin/src/components/RouteKeeper.integration.test.tsx +++ b/apps/admin/src/components/RouteKeeper.integration.test.tsx @@ -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'; diff --git a/apps/admin/src/components/RouteKeeper.tsx b/apps/admin/src/components/RouteKeeper.tsx index 4ae4111..a07a8b7 100644 --- a/apps/admin/src/components/RouteKeeper.tsx +++ b/apps/admin/src/components/RouteKeeper.tsx @@ -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; diff --git a/apps/admin/src/hooks/useApiMutation.ts b/apps/admin/src/hooks/useApiMutation.ts new file mode 100644 index 0000000..3aefd86 --- /dev/null +++ b/apps/admin/src/hooks/useApiMutation.ts @@ -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 { + /** 成功后自动失效的查询 key(触发列表/详情刷新) */ + invalidate?: QueryKey[]; + /** 成功后回调(例如关闭弹窗) */ + onSuccess?: (data: TData, vars: TVars) => void; + /** 失败回调;默认统一用 getErrorMessage 弹错误提示 */ + onError?: (error: unknown) => void; +} + +/** + * useMutation 的轻量封装:统一错误提示 + 成功后 invalidateQueries, + * 消除手写 `await api.xxx(); await fetchData();` 样板。 + */ +export function useApiMutation( + mutationFn: (vars: TVars) => Promise, + options: UseApiMutationOptions = {}, +) { + const queryClient = useQueryClient(); + return useMutation({ + 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)); + } + }, + }); +} diff --git a/apps/admin/src/hooks/useViewSensitive.ts b/apps/admin/src/hooks/useViewSensitive.ts index 6a99021..856f9e2 100644 --- a/apps/admin/src/hooks/useViewSensitive.ts +++ b/apps/admin/src/hooks/useViewSensitive.ts @@ -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 | null>(null); + const modalRef = useRef | 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: '关闭', diff --git a/apps/admin/src/layouts/MainLayout.tsx b/apps/admin/src/layouts/MainLayout.tsx index 64ffeed..1d27a1d 100644 --- a/apps/admin/src/layouts/MainLayout.tsx +++ b/apps/admin/src/layouts/MainLayout.tsx @@ -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 ? '学' : '学生管理系统'} +
+ + {!collapsed && 学生管理系统} +
{menuContent} @@ -275,7 +286,12 @@ const MainLayout: React.FC = () => { size={240} styles={{ body: { padding: 0 } }} className="app-navigation-drawer" - title="学生管理系统" + title={ + + + 学生管理系统 + + } > {menuContent} diff --git a/apps/admin/src/main.tsx b/apps/admin/src/main.tsx index 55bbb28..7d05a2d 100644 --- a/apps/admin/src/main.tsx +++ b/apps/admin/src/main.tsx @@ -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( - + + + {import.meta.env.DEV && } + , ); diff --git a/apps/admin/src/pages/Login/index.tsx b/apps/admin/src/pages/Login/index.tsx index cd9f195..e97b375 100644 --- a/apps/admin/src/pages/Login/index.tsx +++ b/apps/admin/src/pages/Login/index.tsx @@ -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 = () => { }} >
- + <BrandLogo size={48} /> + <Title + level={3} + style={{ margin: '14px 0 0', fontWeight: 600, color: '#1d1d1f' }} + > 学生管理系统

学生综合管理平台

diff --git a/apps/admin/src/store/app/appStore.ts b/apps/admin/src/store/app/appStore.ts index e517d95..aaed467 100644 --- a/apps/admin/src/store/app/appStore.ts +++ b/apps/admin/src/store/app/appStore.ts @@ -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()( sidebarCollapsed: state.sidebarCollapsed, routeDockTabs: state.routeDockTabs, }), - version: 1, + version: 2, + migrate: migrateAppUiState, }, ), { name: 'app-store', enabled: import.meta.env.DEV }, diff --git a/apps/admin/src/store/middleware/persist.ts b/apps/admin/src/store/middleware/persist.ts index 3bc98eb..3fe7c13 100644 --- a/apps/admin/src/store/middleware/persist.ts +++ b/apps/admin/src/store/middleware/persist.ts @@ -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; + 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; + 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; + 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; + return { + routeDockTabs: Array.isArray(state.routeDockTabs) + ? state.routeDockTabs.filter(isDockTab) + : [], + sidebarCollapsed: state.sidebarCollapsed === true, + }; + } + const existing = (isRecord(persisted) ? persisted : {}) as Partial; + return { + routeDockTabs: Array.isArray(existing.routeDockTabs) ? existing.routeDockTabs : [], + sidebarCollapsed: existing.sidebarCollapsed ?? false, + }; +} export type { AppPersistedState, PermissionPersistedState, UserPersistedState }; diff --git a/apps/admin/src/store/permission/permissionStore.ts b/apps/admin/src/store/permission/permissionStore.ts index d288f24..0999c7b 100644 --- a/apps/admin/src/store/permission/permissionStore.ts +++ b/apps/admin/src/store/permission/permissionStore.ts @@ -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()( 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 }, diff --git a/apps/admin/src/store/settings/settingsStore.ts b/apps/admin/src/store/settings/settingsStore.ts index ee18275..e349879 100644 --- a/apps/admin/src/store/settings/settingsStore.ts +++ b/apps/admin/src/store/settings/settingsStore.ts @@ -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'; diff --git a/apps/admin/src/store/types.ts b/apps/admin/src/store/types.ts index d967265..b3c34b7 100644 --- a/apps/admin/src/store/types.ts +++ b/apps/admin/src/store/types.ts @@ -9,6 +9,3 @@ /** 权限校验状态:未知(fail-closed)→ 校验中 → 已就绪 */ export type StoreStatus = 'unknown' | 'loading' | 'ready'; - -/** 持久化时从 Store 中挑选出的字段 */ -export type Partialize = (state: T) => Partial; diff --git a/apps/admin/src/store/user/userStore.ts b/apps/admin/src/store/user/userStore.ts index 8bf8ef0..ccb0ecc 100644 --- a/apps/admin/src/store/user/userStore.ts +++ b/apps/admin/src/store/user/userStore.ts @@ -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()( devtools( @@ -21,7 +21,8 @@ export const useUserStore = create()( 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 }, diff --git a/apps/admin/src/test/fixtures.ts b/apps/admin/src/test/fixtures.ts deleted file mode 100644 index a8bc120..0000000 --- a/apps/admin/src/test/fixtures.ts +++ /dev/null @@ -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; diff --git a/apps/admin/src/test/helpers.ts b/apps/admin/src/test/helpers.ts deleted file mode 100644 index 2797155..0000000 --- a/apps/admin/src/test/helpers.ts +++ /dev/null @@ -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 { - 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 }> { - 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 }>; - 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 { - const token = useUserStore.getState().token; - return { - 'Content-Type': 'application/json', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }; -} - -export async function apiGet(url: string): Promise> { - const res = await fetch(`${BASE}${url}`, { headers: authHeaders() }); - return (await res.json()) as ApiResponse; -} - -export async function apiPost(url: string, body?: unknown): Promise> { - const res = await fetch(`${BASE}${url}`, { - method: 'POST', - headers: authHeaders(), - body: body ? JSON.stringify(body) : undefined, - }); - return (await res.json()) as ApiResponse; -} - -export async function apiPut(url: string, body?: unknown): Promise> { - const res = await fetch(`${BASE}${url}`, { - method: 'PUT', - headers: authHeaders(), - body: body ? JSON.stringify(body) : undefined, - }); - return (await res.json()) as ApiResponse; -} - -export async function apiDelete(url: string): Promise> { - const res = await fetch(`${BASE}${url}`, { - method: 'DELETE', - headers: authHeaders(), - }); - return (await res.json()) as ApiResponse; -} - -// ── Page helpers ──────────────────────────────────────────────────── - -/** - * Navigate to a page and wait for it to load. - */ -export async function goTo(path: string): Promise { - 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 { - // 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, - timeout = 5000, - interval = 200, -): Promise { - 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(res: ApiResponse, 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): Promise { - const res = await promise; - expect(res.status).toBe(403); -} - -/** Assert a 401 is returned (unauthenticated). */ -export async function assertUnauthenticated(promise: Promise): Promise { - 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}$/); -} diff --git a/apps/admin/src/utils/download.ts b/apps/admin/src/utils/download.ts index d95d0db..3c87aea 100644 --- a/apps/admin/src/utils/download.ts +++ b/apps/admin/src/utils/download.ts @@ -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); } diff --git a/apps/admin/src/utils/error.ts b/apps/admin/src/utils/error.ts new file mode 100644 index 0000000..e7da39d --- /dev/null +++ b/apps/admin/src/utils/error.ts @@ -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; +} diff --git a/apps/admin/src/utils/validate.ts b/apps/admin/src/utils/validate.ts new file mode 100644 index 0000000..69623e9 --- /dev/null +++ b/apps/admin/src/utils/validate.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +/** + * 用 zod schema 校验接口响应;失败时抛出带字段路径的错误, + * 由调用方的统一错误处理(getErrorMessage / useApiMutation)展示。 + */ +export function validateResponse(schema: z.ZodType, 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; +}