refactor: 前端应用骨架迁移至 zustand 并统一路由权限壳
This commit is contained in:
@@ -1,268 +0,0 @@
|
||||
/**
|
||||
* Test fixtures — consistent test data used across integration tests.
|
||||
*
|
||||
* These mirror the PRD data models and are used to seed/verify API responses.
|
||||
* All IDs are prefixed "test-" to distinguish from real data in a shared dev DB.
|
||||
*/
|
||||
|
||||
// ── Auth ────────────────────────────────────────────────────────────
|
||||
|
||||
export const CREDENTIALS = {
|
||||
superAdmin: { username: 'admin', password: 'admin123' },
|
||||
staff: { username: 'staff1', password: 'staff123' },
|
||||
classTeacher: { username: 'teacher1', password: 'teacher123' },
|
||||
student: { username: 'student1', password: 'student123' },
|
||||
} as const;
|
||||
|
||||
// ── Student (PRD §3) ────────────────────────────────────────────────
|
||||
|
||||
export const SAMPLE_STUDENT = {
|
||||
name: '测试学员A',
|
||||
phone: '13800000001',
|
||||
idCard: '110101200001011234',
|
||||
gender: '男',
|
||||
ethnicity: '汉族',
|
||||
status: 'active',
|
||||
emergencyContact: '张三',
|
||||
emergencyPhone: '13900000001',
|
||||
studentNo: 'TEST-2026-001',
|
||||
};
|
||||
|
||||
export const SAMPLE_STUDENT_B = {
|
||||
name: '测试学员B',
|
||||
phone: '13800000002',
|
||||
idCard: '110101200001011235',
|
||||
gender: '女',
|
||||
ethnicity: '汉族',
|
||||
status: 'active',
|
||||
emergencyContact: '李四',
|
||||
emergencyPhone: '13900000002',
|
||||
studentNo: 'TEST-2026-002',
|
||||
};
|
||||
|
||||
// ── Class (PRD §5) ──────────────────────────────────────────────────
|
||||
|
||||
export const SAMPLE_CLASS = {
|
||||
name: '2026届文化课冲刺1班',
|
||||
code: 'TEST-WHK-2026-001',
|
||||
classType: '文化课',
|
||||
startDate: '2026-03-01',
|
||||
endDate: '2026-06-30',
|
||||
status: '在读',
|
||||
maxStudents: 40,
|
||||
};
|
||||
|
||||
// ── Schedule (PRD §6) ───────────────────────────────────────────────
|
||||
|
||||
export const SAMPLE_SCHEDULE = {
|
||||
weekDay: 1, // 周一
|
||||
startTime: '09:00',
|
||||
endTime: '10:30',
|
||||
subject: '语文',
|
||||
scheduleType: 'INTERNAL',
|
||||
status: 'active',
|
||||
};
|
||||
|
||||
// Conflicting schedule: same classroom, same weekday, overlapping time
|
||||
export const CONFLICT_SCHEDULE = {
|
||||
weekDay: 1,
|
||||
startTime: '09:30', // overlaps with 09:00-10:30
|
||||
endTime: '11:00',
|
||||
subject: '数学',
|
||||
scheduleType: 'INTERNAL',
|
||||
status: 'active',
|
||||
};
|
||||
|
||||
// Non-conflicting: same classroom, same weekday, non-overlapping
|
||||
export const NON_CONFLICT_SCHEDULE = {
|
||||
weekDay: 1,
|
||||
startTime: '10:30', // exactly at boundary — no overlap
|
||||
endTime: '12:00',
|
||||
subject: '英语',
|
||||
scheduleType: 'INTERNAL',
|
||||
status: 'active',
|
||||
};
|
||||
|
||||
// ── Room / Dormitory (PRD §7) ───────────────────────────────────────
|
||||
|
||||
export const SAMPLE_ROOM = {
|
||||
roomNumber: 'TEST-401',
|
||||
building: '1号楼',
|
||||
floor: 4,
|
||||
capacity: 6,
|
||||
status: 'available',
|
||||
gender: '男',
|
||||
rentalCategory: 'short',
|
||||
roomType: '标准间',
|
||||
};
|
||||
|
||||
export const SAMPLE_LONG_RENT_ROOM = {
|
||||
roomNumber: 'TEST-501',
|
||||
building: '1号楼',
|
||||
floor: 5,
|
||||
capacity: 4,
|
||||
status: 'available',
|
||||
gender: '女',
|
||||
rentalCategory: 'long',
|
||||
monthlyRate: 800,
|
||||
roomType: '标准间',
|
||||
};
|
||||
|
||||
// ── Occupancy (PRD §8) ──────────────────────────────────────────────
|
||||
|
||||
export const SAMPLE_OCCUPANCY = {
|
||||
checkInDate: '2026-03-01',
|
||||
billingStartDate: '2026-03-01',
|
||||
billingEndDate: '2026-06-30',
|
||||
stayType: 'short',
|
||||
};
|
||||
|
||||
// ── Bill / Expense (PRD §9-10) ──────────────────────────────────────
|
||||
|
||||
export const SAMPLE_EXPENSE = {
|
||||
type: 'water',
|
||||
amount: 150.0,
|
||||
billingMonth: '2026-03',
|
||||
description: '3月水费公摊',
|
||||
};
|
||||
|
||||
export const SAMPLE_PERSONAL_EXPENSE = {
|
||||
type: 'damage',
|
||||
amount: 50.0,
|
||||
description: '损坏赔偿-台灯',
|
||||
};
|
||||
|
||||
// ── Deposit (PRD §11) ───────────────────────────────────────────────
|
||||
|
||||
export const SAMPLE_DEPOSIT = {
|
||||
amount: 500.0,
|
||||
type: 'collect' as const,
|
||||
notes: '入学押金',
|
||||
};
|
||||
|
||||
// ── Attendance (PRD §13) ────────────────────────────────────────────
|
||||
|
||||
export const SAMPLE_ATTENDANCE = {
|
||||
attendanceDate: '2026-03-15',
|
||||
session: '上午',
|
||||
status: '出勤',
|
||||
source: '人工点名',
|
||||
courseName: '语文',
|
||||
};
|
||||
|
||||
export const SAMPLE_ATTENDANCE_ABSENT = {
|
||||
attendanceDate: '2026-03-16',
|
||||
session: '上午',
|
||||
status: '缺勤',
|
||||
source: '人工点名',
|
||||
courseName: '语文',
|
||||
};
|
||||
|
||||
// ── Classroom (PRD §6) ──────────────────────────────────────────────
|
||||
|
||||
export const SAMPLE_CLASSROOM = {
|
||||
name: 'TEST-301教室',
|
||||
building: '教学楼A',
|
||||
floor: 3,
|
||||
capacity: 50,
|
||||
roomType: '大',
|
||||
status: 'available',
|
||||
};
|
||||
|
||||
// ── Organization (PRD §12) ────────────────────────────────────────────────
|
||||
|
||||
export const SAMPLE_TENANT = {
|
||||
name: '测试合作机构A',
|
||||
contact: '王经理',
|
||||
phone: '13700000001',
|
||||
color: '#1890ff',
|
||||
status: 'active',
|
||||
};
|
||||
|
||||
// ── Operation Log expectation (PRD §18) ─────────────────────────────
|
||||
|
||||
export const LOG_ACTIONS = {
|
||||
STUDENT_CREATE: { module: 'students', action: 'create' },
|
||||
STUDENT_UPDATE: { module: 'students', action: 'update' },
|
||||
STUDENT_DELETE: { module: 'students', action: 'delete' },
|
||||
BILL_GENERATE: { module: 'bills', action: 'generate' },
|
||||
BILL_CONFIRM: { module: 'bills', action: 'confirm' },
|
||||
DEPOSIT_COLLECT: { module: 'deposits', action: 'collect' },
|
||||
DEPOSIT_REFUND: { module: 'deposits', action: 'refund' },
|
||||
OCCUPANCY_CHECKIN: { module: 'occupancies', action: 'create' },
|
||||
OCCUPANCY_CHECKOUT: { module: 'occupancies', action: 'checkout' },
|
||||
EXPENSE_CREATE: { module: 'expenses', action: 'create' },
|
||||
CLASS_CREATE: { module: 'classes', action: 'create' },
|
||||
CLASS_DELETE: { module: 'classes', action: 'delete' },
|
||||
SCHEDULE_CREATE: { module: 'schedules', action: 'create' },
|
||||
ATTENDANCE_BATCH: { module: 'attendance', action: 'batch' },
|
||||
SENSITIVE_VIEW: { module: 'students', action: 'view_sensitive' },
|
||||
} as const;
|
||||
|
||||
// ── Permission nodes (PRD §17) ──────────────────────────────────────
|
||||
|
||||
export const PERMISSION_NODES = [
|
||||
'student:view',
|
||||
'student:add',
|
||||
'student:update',
|
||||
'student:delete',
|
||||
'student:import',
|
||||
'student:export',
|
||||
'room:view',
|
||||
'room:add',
|
||||
'room:update',
|
||||
'room:delete',
|
||||
'occupancy:view',
|
||||
'occupancy:add',
|
||||
'occupancy:update',
|
||||
'bill:view',
|
||||
'bill:generate',
|
||||
'bill:confirm',
|
||||
'bill:markPaid',
|
||||
'bill:export',
|
||||
'expense:view',
|
||||
'expense:add',
|
||||
'expense:update',
|
||||
'expense:delete',
|
||||
'deposit:view',
|
||||
'deposit:collect',
|
||||
'deposit:refund',
|
||||
'class:view',
|
||||
'class:add',
|
||||
'class:update',
|
||||
'class:delete',
|
||||
'schedule:view',
|
||||
'schedule:add',
|
||||
'schedule:update',
|
||||
'schedule:delete',
|
||||
'attendance:view',
|
||||
'attendance:add',
|
||||
'attendance:update',
|
||||
'attendance:delete',
|
||||
'attendance:batch',
|
||||
'classroom:view',
|
||||
'classroom:add',
|
||||
'classroom:update',
|
||||
'classroom:delete',
|
||||
'organization:view',
|
||||
'organization:create',
|
||||
'organization:edit',
|
||||
'organization:delete',
|
||||
'rental:view',
|
||||
'rental:add',
|
||||
'rental:update',
|
||||
'rental:delete',
|
||||
'archive:view',
|
||||
'archive:import',
|
||||
'archive:export',
|
||||
'report:generate',
|
||||
'log:view',
|
||||
'role:view',
|
||||
'role:add',
|
||||
'role:update',
|
||||
'role:delete',
|
||||
'user:view',
|
||||
'user:add',
|
||||
'user:update',
|
||||
'dashboard:view',
|
||||
] as const;
|
||||
@@ -1,173 +0,0 @@
|
||||
/**
|
||||
* Shared browser-test helpers.
|
||||
*
|
||||
* Import this in every `*.integration.test.ts` file.
|
||||
* Provides login, API calling, and page-navigation utilities
|
||||
* that work inside the Vitest browser environment.
|
||||
*/
|
||||
import { expect } from 'vitest';
|
||||
import { CREDENTIALS } from './fixtures';
|
||||
import { BASE } from './setup';
|
||||
import { usePermissionStore } from '../store/permission/permissionStore';
|
||||
import { useUserStore } from '../store/user/userStore';
|
||||
import type { UserInfo } from '../store/user/userTypes';
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────
|
||||
|
||||
interface ApiResponse<T = unknown> {
|
||||
code: number;
|
||||
data: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
type Role = keyof typeof CREDENTIALS;
|
||||
|
||||
// ── Auth helpers ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Login as a specific role and store the token in localStorage.
|
||||
* Returns the parsed response data.
|
||||
*/
|
||||
export async function loginAs(
|
||||
role: Role,
|
||||
): Promise<{ token: string; user: Record<string, unknown> }> {
|
||||
const creds = CREDENTIALS[role];
|
||||
const res = await fetch(`${BASE}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(creds),
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
const json = (await res.json()) as ApiResponse<{ token: string; user: Record<string, unknown> }>;
|
||||
expect(json.code).toBe(0);
|
||||
useUserStore.getState().setSession(json.data.token, json.data.user as unknown as UserInfo);
|
||||
usePermissionStore
|
||||
.getState()
|
||||
.writePermissions((json.data.user.permissions ?? []) as string[]);
|
||||
return json.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout: clear localStorage.
|
||||
*/
|
||||
export function logout(): void {
|
||||
useUserStore.getState().logout();
|
||||
usePermissionStore.getState().clearPermissions();
|
||||
}
|
||||
|
||||
// ── API helpers (authenticated) ─────────────────────────────────────
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const token = useUserStore.getState().token;
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function apiGet<T = unknown>(url: string): Promise<ApiResponse<T>> {
|
||||
const res = await fetch(`${BASE}${url}`, { headers: authHeaders() });
|
||||
return (await res.json()) as ApiResponse<T>;
|
||||
}
|
||||
|
||||
export async function apiPost<T = unknown>(url: string, body?: unknown): Promise<ApiResponse<T>> {
|
||||
const res = await fetch(`${BASE}${url}`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return (await res.json()) as ApiResponse<T>;
|
||||
}
|
||||
|
||||
export async function apiPut<T = unknown>(url: string, body?: unknown): Promise<ApiResponse<T>> {
|
||||
const res = await fetch(`${BASE}${url}`, {
|
||||
method: 'PUT',
|
||||
headers: authHeaders(),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return (await res.json()) as ApiResponse<T>;
|
||||
}
|
||||
|
||||
export async function apiDelete<T = unknown>(url: string): Promise<ApiResponse<T>> {
|
||||
const res = await fetch(`${BASE}${url}`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders(),
|
||||
});
|
||||
return (await res.json()) as ApiResponse<T>;
|
||||
}
|
||||
|
||||
// ── Page helpers ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Navigate to a page and wait for it to load.
|
||||
*/
|
||||
export async function goTo(path: string): Promise<void> {
|
||||
document.location.href = `${BASE}${path}`;
|
||||
// Wait for React to render
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert the current page URL contains the given path.
|
||||
*/
|
||||
export async function assertOnPage(path: string): Promise<void> {
|
||||
// Wait a tick for SPA routing
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
expect(window.location.pathname).toContain(path);
|
||||
}
|
||||
|
||||
// ── Wait helpers ────────────────────────────────────────────────────
|
||||
|
||||
/** Poll until a condition is true or timeout. */
|
||||
export async function waitFor(
|
||||
condition: () => boolean | Promise<boolean>,
|
||||
timeout = 5000,
|
||||
interval = 200,
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
if (await condition()) return;
|
||||
await new Promise((r) => setTimeout(r, interval));
|
||||
}
|
||||
throw new Error(`waitFor timed out after ${timeout}ms`);
|
||||
}
|
||||
|
||||
// ── Assertion helpers ───────────────────────────────────────────────
|
||||
|
||||
/** Assert an API response is successful (code === 0). */
|
||||
export function assertOk<T>(res: ApiResponse<T>, msg?: string): T {
|
||||
expect(res.code, msg ?? 'API should return code 0').toBe(0);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/** Assert an API response is an error (code !== 0). */
|
||||
export function assertError(res: ApiResponse, expectedCode?: number): void {
|
||||
expect(res.code).not.toBe(0);
|
||||
if (expectedCode !== undefined) {
|
||||
expect(res.code).toBe(expectedCode);
|
||||
}
|
||||
}
|
||||
|
||||
/** Assert a 403 is returned (permission denied). */
|
||||
export async function assertForbidden(promise: Promise<Response>): Promise<void> {
|
||||
const res = await promise;
|
||||
expect(res.status).toBe(403);
|
||||
}
|
||||
|
||||
/** Assert a 401 is returned (unauthenticated). */
|
||||
export async function assertUnauthenticated(promise: Promise<Response>): Promise<void> {
|
||||
const res = await promise;
|
||||
expect(res.status).toBe(401);
|
||||
}
|
||||
|
||||
// ── Sensitive data helpers (PRD §3.3) ───────────────────────────────
|
||||
|
||||
/** Assert phone number is masked: 138****0001 */
|
||||
export function assertPhoneMasked(displayed: string): void {
|
||||
expect(displayed).toMatch(/^\d{3}\*{4}\d{4}$/);
|
||||
}
|
||||
|
||||
/** Assert ID card is masked: 110101********1234 */
|
||||
export function assertIdCardMasked(displayed: string): void {
|
||||
expect(displayed).toMatch(/^\d{6}\*{8}\d{4}$/);
|
||||
}
|
||||
Reference in New Issue
Block a user