refactor: 前端应用骨架迁移至 zustand 并统一路由权限壳

This commit is contained in:
2026-08-05 17:10:30 +08:00
parent e21f0de427
commit d53bbd8176
33 changed files with 1055 additions and 687 deletions

View File

@@ -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';

View 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();
/** 导入任务 */

View 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);

View 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();
/** 集成配置 */

View 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();
/** 考勤元数据 */

View 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();
/** 考勤记录 */

View File

@@ -0,0 +1,6 @@
export * from './core';
export * from './attendance';
export * from './dashboard';
export * from './import-run';
export * from './ai';
export * from './integration';

View 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(),
);
/** 教室排课总览 */

View File

@@ -1,3 +1,4 @@
// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化
export interface AppMenuItem {
key: string;
label: string;
@@ -36,100 +37,112 @@ const ROLE_ALIASES: Record<string, string> = {
super_admin: 'super',
};
function entry(key: string, label: string, icon: string, permission: string): MenuEntry {
return { key, label, icon, permission };
}
function section(
key: string,
label: string,
icon: string,
roles: string[],
children: MenuEntry[],
): MenuSection {
return { key, label, icon, roles, children };
}
const SECTIONS: MenuSection[] = [
{
key: 'teaching-group',
label: '教学工作',
icon: 'calendar',
roles: ['teacher'],
children: [
{
key: '/teacher-workspace',
label: '今日教学',
icon: 'workspace',
permission: 'teacher-workspace:view',
},
{ key: '/schedules', label: '我的排课', icon: 'calendar', permission: 'schedule:view' },
{ key: '/attendance', label: '课程考勤', icon: 'attendance', permission: 'attendance:view' },
section(
'teaching-group',
'教学工作',
'calendar',
['teacher'],
[
entry('/teacher-workspace', '今日教学', 'workspace', 'teacher-workspace:view'),
entry('/schedules', '我的排课', 'calendar', 'schedule:view'),
entry('/attendance', '课程考勤', 'attendance', 'attendance:view'),
],
},
{
key: 'academic-group',
label: '教务管理',
icon: 'academic',
roles: ['academic', 'super'],
children: [
{ key: '/students', label: '学生管理', icon: 'students', permission: 'student:view' },
{ key: '/classes', label: '班级管理', icon: 'classes', permission: 'class:view' },
{ key: '/exams', label: '考试管理', icon: 'exam', permission: 'exam:view' },
{ key: '/teachers', label: '教师管理', icon: 'teachers', permission: 'teacher:view' },
{ key: '/schedules', label: '排课管理', icon: 'calendar', permission: 'schedule:view' },
{ key: '/attendance', label: '历史考勤', icon: 'attendance', permission: 'attendance:view' },
{ key: '/classrooms', label: '教室查看', icon: 'classroom', permission: 'classroom:view' },
),
section(
'academic-group',
'教务管理',
'academic',
['academic', 'super'],
[
entry('/students', '学生管理', 'students', 'student:view'),
entry('/classes', '班级管理', 'classes', 'class:view'),
entry('/exams', '考试管理', 'exam', 'exam:view'),
entry('/teachers', '教师管理', 'teachers', 'teacher:view'),
entry('/schedules', '排课管理', 'calendar', 'schedule:view'),
entry('/attendance', '历史考勤', 'attendance', 'attendance:view'),
entry('/classrooms', '教室查看', 'classroom', 'classroom:view'),
],
},
{
key: 'accommodation-group',
label: '住宿运营',
icon: 'home',
roles: ['accommodation', 'super'],
children: [
{ key: '/room-visual', label: '住宿总览', icon: 'overview', permission: 'room:view' },
{ key: '/rooms', label: '房间管理', icon: 'home', permission: 'room:view' },
{ key: '/occupancies', label: '入住管理', icon: 'occupancy', permission: 'occupancy:view' },
{ key: '/expenses', label: '费用管理', icon: 'expense', permission: 'expense:view' },
{ key: '/bills', label: '账单管理', icon: 'bill', permission: 'bill:view' },
{ key: '/wallets', label: '学生余额', icon: 'wallet', permission: 'wallet:view' },
{ key: '/deposits', label: '押金管理', icon: 'deposit', permission: 'deposit:view' },
),
section(
'accommodation-group',
'住宿运营',
'home',
['accommodation', 'super'],
[
entry('/room-visual', '住宿总览', 'overview', 'room:view'),
entry('/rooms', '房间管理', 'home', 'room:view'),
entry('/occupancies', '入住管理', 'occupancy', 'occupancy:view'),
entry('/expenses', '费用管理', 'expense', 'expense:view'),
entry('/bills', '账单管理', 'bill', 'bill:view'),
entry('/wallets', '学生余额', 'wallet', 'wallet:view'),
entry('/deposits', '押金管理', 'deposit', 'deposit:view'),
],
},
{
key: 'classroom-group',
label: '教室运营',
icon: 'classroom',
roles: ['classroom', 'super'],
children: [
{
key: '/classroom-schedule',
label: '教室排期',
icon: 'calendar',
permission: 'rental:view',
},
{ key: '/classrooms', label: '教室管理', icon: 'classroom', permission: 'classroom:view' },
{
key: '/attendance-devices',
label: '考勤机绑定',
icon: 'attendance',
permission: 'classroom:view',
},
{ key: '/classroom-rentals', label: '租赁订单', icon: 'rental', permission: 'rental:view' },
{
key: '/organizations',
label: '机构管理',
icon: 'organization',
permission: 'organization:view',
},
),
section(
'classroom-group',
'教室运营',
'classroom',
['classroom', 'super'],
[
entry('/classroom-schedule', '教室排期', 'calendar', 'rental:view'),
entry('/classrooms', '教室管理', 'classroom', 'classroom:view'),
entry('/attendance-devices', '考勤机绑定', 'attendance', 'classroom:view'),
entry('/classroom-rentals', '租赁订单', 'rental', 'rental:view'),
entry('/organizations', '机构管理', 'organization', 'organization:view'),
],
},
{
key: 'system-group',
label: '系统管理',
icon: 'settings',
roles: ['system', 'super'],
children: [
{ key: '/users', label: '账号管理', icon: 'users', permission: 'user:view' },
{ key: '/roles', label: '角色管理', icon: 'role', permission: 'role:view' },
{ key: '/permissions', label: '权限一览', icon: 'permission', permission: 'role:view' },
{ key: '/operation-logs', label: '操作日志', icon: 'log', permission: 'log:view' },
{
key: '/integration-config',
label: '钉钉集成',
icon: 'integration',
permission: 'integration:read',
},
{ key: '/ai-config', label: 'AI 配置', icon: 'ai', permission: 'ai:config:read' },
),
section(
'system-group',
'系统管理',
'settings',
['system', 'super'],
[
entry('/users', '账号管理', 'users', 'user:view'),
entry('/roles', '角色管理', 'role', 'role:view'),
entry('/permissions', '权限一览', 'permission', 'role:view'),
entry('/operation-logs', '操作日志', 'log', 'log:view'),
entry('/integration-config', '钉钉集成', 'integration', 'integration:read'),
entry('/ai-config', 'AI 配置', 'ai', 'ai:config:read'),
],
},
),
];
export function getRoleDomains(

View 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>
);
}

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import { Navigate } from 'react-router';
import { Result, Spin } from 'antd';
import { usePermission } from '../hooks/usePermission';
import { findRoleAwareLandingPath } from '../auth/menu-policy';
@@ -7,10 +7,10 @@ import { useUserStore } from '../store/user/userStore';
const DefaultRoute: React.FC = () => {
const { permissions, permissionsReady } = usePermission();
const roles = useUserStore((state) => state.user?.roles ?? []);
if (!permissionsReady) {
return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
}
const roles = useUserStore((state) => state.user?.roles ?? []);
const firstPath = findRoleAwareLandingPath(roles, permissions);
if (firstPath) return <Navigate to={firstPath} replace />;
return (

View File

@@ -1,7 +1,9 @@
import React, { useState, useEffect, useRef } from 'react';
import { Badge, Popover, Button, List, Typography, Empty } from 'antd';
import { BellOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import dayjs from 'dayjs';
import { useInterval } from 'usehooks-ts';
import api from '../api';
import { formatNotificationText, notificationTypeLabels } from '../utils/notification-display';
import { useUserStore } from '../store/user/userStore';
@@ -17,25 +19,19 @@ interface NotificationItem {
}
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return '刚刚';
if (mins < 60) return `${mins}分钟前`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}小时前`;
const days = Math.floor(hours / 24);
return `${days}天前`;
return dayjs(dateStr).fromNow();
}
const NotificationBell: React.FC = () => {
const [unreadCount, setUnreadCount] = useState(0);
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
const [open, setOpen] = useState(false);
const [sseDown, setSseDown] = useState(false);
const navigate = useNavigate();
const fetchNotifications = async () => {
try {
const data = (await api.get('/notifications?limit=20')) as unknown as NotificationItem[];
const data = await api.get<NotificationItem[]>('/notifications?limit=20');
setNotifications(data);
} catch {
/* ignore */
@@ -44,7 +40,7 @@ const NotificationBell: React.FC = () => {
const fetchUnread = async () => {
try {
const data = (await api.get('/notifications/unread-count')) as unknown as { count: number };
const data = await api.get<{ count: number }>('/notifications/unread-count');
setUnreadCount(data.count);
} catch {
/* ignore */
@@ -52,7 +48,9 @@ const NotificationBell: React.FC = () => {
};
const openRef = useRef(open);
openRef.current = open;
const retryRef = useRef<number | null>(null);
useInterval(() => {
void fetchUnread();
}, sseDown ? 60_000 : null);
// SSE connection — decoupled from popover open state
useEffect(() => {
@@ -71,13 +69,11 @@ const NotificationBell: React.FC = () => {
};
es.onerror = () => {
es.close();
if (retryRef.current !== null) clearInterval(retryRef.current);
retryRef.current = window.setInterval(fetchUnread, 60_000);
setSseDown(true);
};
return () => {
es.close();
clearInterval(retryRef.current ?? undefined);
retryRef.current = null;
setSseDown(false);
};
}, []);

View File

@@ -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 {

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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;

View 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));
}
},
});
}

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef } from 'react';
import { Modal } from 'antd';
import { App } from 'antd';
import api from '../api';
import { message } from '../ui/app-message';
@@ -13,8 +13,9 @@ import { message } from '../ui/app-message';
* already-open confirm modal is destroyed.
*/
export function useViewSensitive(studentId: number, module: string, canLog: boolean) {
const { modal } = App.useApp();
const canLogRef = useRef(canLog);
const modalRef = useRef<ReturnType<typeof Modal.confirm> | null>(null);
const modalRef = useRef<ReturnType<typeof modal.confirm> | null>(null);
canLogRef.current = canLog;
useEffect(() => {
@@ -31,7 +32,7 @@ export function useViewSensitive(studentId: number, module: string, canLog: bool
return useCallback(
(field: string, value: string) => {
if (!canLogRef.current) return;
modalRef.current = Modal.confirm({
modalRef.current = modal.confirm({
title: '查看敏感信息',
content: `您即将查看 "${field}" 的完整信息。此操作将被记录。`,
okText: '确认查看',
@@ -50,7 +51,7 @@ export function useViewSensitive(studentId: number, module: string, canLog: bool
message.error('操作日志记录失败,请稍后重试');
return;
}
Modal.info({
modal.info({
title: field,
content: value,
okText: '关闭',

View File

@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useNavigate, useLocation } from 'react-router';
import { Layout, Menu, Button, Avatar, Badge, Dropdown, Drawer, Grid, Tooltip } from 'antd';
import { BrandLogo } from '../components/BrandLogo';
import {
DashboardOutlined,
TeamOutlined,
@@ -262,7 +263,17 @@ const MainLayout: React.FC = () => {
borderBottom: '1px solid #e5e5e7',
}}
>
{collapsed ? '学' : '学生管理系统'}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
}}
>
<BrandLogo size={collapsed ? 26 : 30} />
{!collapsed && <span></span>}
</div>
</div>
{menuContent}
</Sider>
@@ -275,7 +286,12 @@ const MainLayout: React.FC = () => {
size={240}
styles={{ body: { padding: 0 } }}
className="app-navigation-drawer"
title="学生管理系统"
title={
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<BrandLogo size={24} />
</span>
}
>
{menuContent}
</Drawer>

View File

@@ -1,11 +1,14 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import App from './App';
import './index.css';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import advancedFormat from 'dayjs/plugin/advancedFormat';
import relativeTime from 'dayjs/plugin/relativeTime';
import weekday from 'dayjs/plugin/weekday';
import localeData from 'dayjs/plugin/localeData';
import weekOfYear from 'dayjs/plugin/weekOfYear';
@@ -15,6 +18,7 @@ import updateLocale from 'dayjs/plugin/updateLocale';
// 扩展 antd DatePicker/RangePicker 面板所需的 dayjs 插件,否则中文 locale 无法生效
dayjs.extend(customParseFormat);
dayjs.extend(advancedFormat);
dayjs.extend(relativeTime);
dayjs.extend(weekday);
dayjs.extend(localeData);
dayjs.extend(weekOfYear);
@@ -24,8 +28,20 @@ dayjs.extend(updateLocale);
// 必须在所有插件加载后设置 locale
dayjs.locale('zh-cn');
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 30_000,
},
},
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
<QueryClientProvider client={queryClient}>
<App />
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
</QueryClientProvider>
</React.StrictMode>,
);

View File

@@ -1,8 +1,9 @@
import React, { useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { Form, Input, Button, Card, Typography } from 'antd';
import { UserOutlined, LockOutlined } from '@ant-design/icons';
import api from '../../api';
import { BrandLogo } from '../../components/BrandLogo';
import { message } from '../../ui/app-message';
import { findRoleAwareLandingPath } from '../../auth/menu-policy';
import { usePermissionStore } from '../../store/permission/permissionStore';
@@ -59,7 +60,11 @@ const LoginPage: React.FC = () => {
}}
>
<div style={{ textAlign: 'center', marginBottom: 32 }}>
<Title level={3} style={{ margin: 0, fontWeight: 600, color: '#1d1d1f' }}>
<BrandLogo size={48} />
<Title
level={3}
style={{ margin: '14px 0 0', fontWeight: 600, color: '#1d1d1f' }}
>
</Title>
<p style={{ color: '#86868b', marginTop: 8 }}></p>

View File

@@ -1,6 +1,6 @@
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { appUiPersistStorage, APP_UI_STORAGE_NAME } from '../middleware/persist';
import { APP_UI_STORAGE_NAME, appUiPersistStorage, migrateAppUiState } from '../middleware/persist';
import type { AppPersistedState, AppStore } from './appTypes';
/**
@@ -59,7 +59,8 @@ export const useAppStore = create<AppStore>()(
sidebarCollapsed: state.sidebarCollapsed,
routeDockTabs: state.routeDockTabs,
}),
version: 1,
version: 2,
migrate: migrateAppUiState,
},
),
{ name: 'app-store', enabled: import.meta.env.DEV },

View File

@@ -1,9 +1,11 @@
/**
* 持久化中间件基础设施。
*
* 为了平滑迁移,这里把旧实现直接读写 localStorage 的 key
* token / user / permissions / gongxue-route-dock包装成 zustand
* persist 的 StateStorage保证迁移前后数据格式兼容。
* 所有 Store 统一使用 zustand 官方 persist + createJSONStorage(localStorage)。
* zustand 只有在「新 key 下已存在数据」时才会执行 migrate因此旧 key/旧格式
* 不能只靠 migrate 迁移:这里通过 StateStorage.getItem 的旧值回退(把旧数据
* 包装成 version:1 的 persist envelope保证升级后首次加载就能触发 migrate
* 并在首次写入新格式时清理旧 key。
*/
import { createJSONStorage, type StateStorage } from 'zustand/middleware';
import type { AppPersistedState, DockTab } from '../app/appTypes';
@@ -38,111 +40,94 @@ function isDockTab(value: unknown): value is DockTab {
);
}
/**
* 用户会话持久化:继续使用旧的 `token` / `user` 两个 key
* 保持与后端、既有代码及浏览器缓存格式一致。
*/
const legacyAuthStorage: StateStorage = {
getItem: () => {
const token = localStorage.getItem(LEGACY_TOKEN_KEY);
const rawUser = localStorage.getItem(LEGACY_USER_KEY);
if (token === null && rawUser === null) return null;
let user: UserInfo | null = null;
if (rawUser !== null) {
try {
const parsed: unknown = JSON.parse(rawUser);
user = isRecord(parsed) ? (parsed as UserInfo) : null;
} catch {
user = null;
}
}
return JSON.stringify({ state: { token, user }, version: 1 });
},
setItem: (_name, value) => {
function readLegacyAuth(): { token: string | null; user: UserInfo | null } {
const token = localStorage.getItem(LEGACY_TOKEN_KEY);
const rawUser = localStorage.getItem(LEGACY_USER_KEY);
let user: UserInfo | null = null;
if (rawUser !== null) {
try {
const persisted = JSON.parse(value) as { state?: UserPersistedState };
const { token, user } = persisted.state ?? {};
if (token) {
localStorage.setItem(LEGACY_TOKEN_KEY, token);
} else {
localStorage.removeItem(LEGACY_TOKEN_KEY);
}
if (user) {
localStorage.setItem(LEGACY_USER_KEY, JSON.stringify(user));
} else {
localStorage.removeItem(LEGACY_USER_KEY);
}
const parsed: unknown = JSON.parse(rawUser);
user = isRecord(parsed) ? (parsed as UserInfo) : null;
} catch {
// 持久化写入失败不应影响应用运行
user = null;
}
},
removeItem: () => {
}
return { token, user };
}
interface LegacyAdapter {
legacyValue: () => string | null;
clearLegacy: () => void;
}
/** 新 key 无数据时回退到旧 key首次写入新格式后清理旧 key */
function legacyFallbackStorage(adapter: LegacyAdapter): StateStorage {
return {
getItem: (name) => {
const current = localStorage.getItem(name);
if (current !== null) return current;
return adapter.legacyValue();
},
setItem: (name, value) => {
try {
localStorage.setItem(name, value);
} catch {
// 持久化失败不应影响应用运行
}
try {
adapter.clearLegacy();
} catch {
// 清理失败不影响应用运行
}
},
removeItem: (name) => {
try {
localStorage.removeItem(name);
} catch {
// 持久化失败不应影响应用运行
}
try {
adapter.clearLegacy();
} catch {
// 清理失败不影响应用运行
}
},
};
}
/** 会话:旧 token/user 两个 key 包装成 version:1 envelope */
function legacyAuthValue(): string | null {
const { token, user } = readLegacyAuth();
if (token === null && user === null) return null;
return JSON.stringify({ state: { token, user }, version: 1 });
}
const authAdapter: LegacyAdapter = {
legacyValue: legacyAuthValue,
clearLegacy: () => {
localStorage.removeItem(LEGACY_TOKEN_KEY);
localStorage.removeItem(LEGACY_USER_KEY);
},
};
/**
* 权限持久化:兼容旧格式(原始 JSON 数组)与 zustand persist 格式。
* 无论磁盘上是什么状态,恢复后一律为 `unknown`,保持 fail-closed
* 直到 `/auth/profile` 校验成功。
*/
const legacyPermissionStorage: StateStorage = {
getItem: () => {
const raw = localStorage.getItem(PERMISSION_STORAGE_NAME);
export const authPersistStorage = createJSONStorage(() =>
legacyFallbackStorage(authAdapter),
);
/** 权限:旧格式是 permissions 下的裸数组,读取时统一包装成 envelope 以触发 migrate */
const permissionStorage: StateStorage = {
getItem: (name) => {
const raw = localStorage.getItem(name);
if (!raw) return null;
try {
const parsed: unknown = JSON.parse(raw);
if (Array.isArray(parsed)) {
return JSON.stringify({
state: { permissions: parsed.filter(isString), status: 'unknown' },
state: { permissions: parsed.filter(isString) },
version: 1,
});
}
if (isRecord(parsed) && isRecord(parsed.state)) {
const permissions = Array.isArray(parsed.state.permissions)
? parsed.state.permissions.filter(isString)
: [];
return JSON.stringify({
state: { permissions, status: 'unknown' },
version: 1,
});
}
} catch {
// 损坏的缓存按无权限处理
}
return null;
},
setItem: (_name, value) => {
try {
const persisted = JSON.parse(value) as { state?: PermissionPersistedState };
const permissions = Array.isArray(persisted.state?.permissions)
? persisted.state.permissions.filter(isString)
: [];
localStorage.setItem(PERMISSION_STORAGE_NAME, JSON.stringify(permissions));
} catch {
// 忽略损坏数据
}
},
removeItem: () => {
localStorage.removeItem(PERMISSION_STORAGE_NAME);
},
};
/**
* 应用 UI 状态持久化:新 key `gongxue-app-ui`
* 首次读取时自动迁移旧 key `gongxue-route-dock` 中已打开的页签。
*/
const appUiStorage: StateStorage = {
getItem: (name) => {
const current = localStorage.getItem(name);
if (current) return current;
const legacy = localStorage.getItem(LEGACY_DOCK_STORAGE_KEY);
if (!legacy) return null;
try {
const parsed: unknown = JSON.parse(legacy);
const routeDockTabs = Array.isArray(parsed) ? parsed.filter(isDockTab) : [];
return JSON.stringify({ state: { routeDockTabs, sidebarCollapsed: false }, version: 1 });
return raw;
} catch {
return null;
}
@@ -163,13 +148,84 @@ const appUiStorage: StateStorage = {
},
};
/** 会话 Store 使用的 persist storage兼容旧 token/user key */
export const authPersistStorage = createJSONStorage(() => legacyAuthStorage);
export const permissionPersistStorage = createJSONStorage(() => permissionStorage);
/** 权限 Store 使用的 persist storage兼容旧 permissions key */
export const permissionPersistStorage = createJSONStorage(() => legacyPermissionStorage);
/** 应用 UI旧 gongxue-route-dock key 包装成 version:1 envelope */
function legacyDockValue(): string | null {
const legacy = localStorage.getItem(LEGACY_DOCK_STORAGE_KEY);
if (!legacy) return null;
try {
const parsed: unknown = JSON.parse(legacy);
const routeDockTabs = Array.isArray(parsed) ? parsed.filter(isDockTab) : [];
return JSON.stringify({ state: { routeDockTabs, sidebarCollapsed: false }, version: 1 });
} catch {
return null;
}
}
/** 应用 UI Store 使用的 persist storage含旧 RouteDock key 迁移) */
export const appUiPersistStorage = createJSONStorage(() => appUiStorage);
const appUiAdapter: LegacyAdapter = {
legacyValue: legacyDockValue,
clearLegacy: () => {
localStorage.removeItem(LEGACY_DOCK_STORAGE_KEY);
},
};
export const appUiPersistStorage = createJSONStorage(() =>
legacyFallbackStorage(appUiAdapter),
);
/** 会话状态迁移:读取 v1 envelope 或直接 partial state */
export function migrateAuthState(persisted: unknown, _version: number): UserPersistedState {
if (isRecord(persisted) && isRecord(persisted.state)) {
const state = persisted.state as Record<string, unknown>;
return {
token: typeof state.token === 'string' ? state.token : null,
user: isRecord(state.user) ? (state.user as UserInfo) : null,
};
}
const existing = (isRecord(persisted) ? persisted : {}) as Partial<UserPersistedState>;
return {
token: existing.token ?? null,
user: existing.user ?? null,
};
}
/** 权限状态迁移:兼容 v1 envelope 与裸数组,恢复后一律 fail-closed */
export function migratePermissionState(
persisted: unknown,
_version: number,
): PermissionPersistedState {
if (isRecord(persisted) && isRecord(persisted.state)) {
const permissions = Array.isArray(persisted.state.permissions)
? persisted.state.permissions.filter(isString)
: [];
return { permissions };
}
if (Array.isArray(persisted)) {
return { permissions: persisted.filter(isString) };
}
const existing = (isRecord(persisted) ? persisted : {}) as Partial<PermissionPersistedState>;
return {
permissions: Array.isArray(existing.permissions) ? existing.permissions.filter(isString) : [],
};
}
/** 应用 UI 状态迁移:读取 v1 envelope 或直接 partial state */
export function migrateAppUiState(persisted: unknown, _version: number): AppPersistedState {
if (isRecord(persisted) && isRecord(persisted.state)) {
const state = persisted.state as Record<string, unknown>;
return {
routeDockTabs: Array.isArray(state.routeDockTabs)
? state.routeDockTabs.filter(isDockTab)
: [],
sidebarCollapsed: state.sidebarCollapsed === true,
};
}
const existing = (isRecord(persisted) ? persisted : {}) as Partial<AppPersistedState>;
return {
routeDockTabs: Array.isArray(existing.routeDockTabs) ? existing.routeDockTabs : [],
sidebarCollapsed: existing.sidebarCollapsed ?? false,
};
}
export type { AppPersistedState, PermissionPersistedState, UserPersistedState };

View File

@@ -1,8 +1,9 @@
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import {
permissionPersistStorage,
PERMISSION_STORAGE_NAME,
migratePermissionState,
permissionPersistStorage,
} from '../middleware/persist';
import type { PermissionPersistedState, PermissionStore } from './permissionTypes';
@@ -40,7 +41,8 @@ export const usePermissionStore = create<PermissionStore>()(
name: PERMISSION_STORAGE_NAME,
storage: permissionPersistStorage,
partialize: (state): PermissionPersistedState => ({ permissions: state.permissions }),
version: 1,
version: 2,
migrate: migratePermissionState,
},
),
{ name: 'permission-store', enabled: import.meta.env.DEV },

View File

@@ -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';

View File

@@ -9,6 +9,3 @@
/** 权限校验状态未知fail-closed→ 校验中 → 已就绪 */
export type StoreStatus = 'unknown' | 'loading' | 'ready';
/** 持久化时从 Store 中挑选出的字段 */
export type Partialize<T> = (state: T) => Partial<T>;

View File

@@ -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';
/**
* 用户会话 Storetoken + 用户资料)。
* 使用 zustand 官方推荐写法create()(devtools(persist(...)))。
* 持久化沿用旧 `token` / `user` localStorage key
* 持久化使用官方 persist + localStorage旧 `token` / `user` key 由 migrate 一次性迁移
*/
export const useUserStore = create<UserStore>()(
devtools(
@@ -21,7 +21,8 @@ export const useUserStore = create<UserStore>()(
name: AUTH_STORAGE_NAME,
storage: authPersistStorage,
partialize: (state): UserPersistedState => ({ token: state.token, user: state.user }),
version: 1,
version: 2,
migrate: migrateAuthState,
},
),
{ name: 'user-store', enabled: import.meta.env.DEV },

View File

@@ -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;

View File

@@ -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}$/);
}

View File

@@ -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);
}

View 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;
}

View 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;
}