feat: DingTalk attendance import + integration config + expense types + UI polish

Server:
- Add DingTalk attendance import service with SSE progress streaming
- Add IntegrationConfig entity & module for multi-tenant DingTalk setup
- Add ExpenseType entity & ExpenseTypesModule
- Add SeedModule for DB initialization
- Add UserDingMapping entity for DingTalk user linkage
- Attendance service: import flow with dedup & student auto-mapping
- Rooms service: time-range overlap queries
- Sync controller/service: DingTalk integration wiring
- Permission guard: refactor to pure re-export
- Campus scope middleware: tenant-aware filtering

Admin UI:
- Attendance page: import UI with progress & result summary
- All pages: tableStyle/tablePagination standardization
- Login page: responsive styling
- Sensitive data: useViewSensitive hook for masked viewing
- Vite config: path aliases, build optimization
- Test infra: vitest config, test utilities

Docs: PRD DingTalk batch 1 & 2 design docs
This commit is contained in:
2026-07-09 09:11:56 +08:00
parent f1959f0d2a
commit 42d3f0e27f
71 changed files with 5331 additions and 609 deletions

View File

@@ -0,0 +1,226 @@
/**
* 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',
rentalType: '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',
};
// ── Tenant (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',
'tenant:view', 'tenant:add', 'tenant:update', 'tenant: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', 'user:delete',
'department:view', 'department:add', 'department:update', 'department:delete',
'dashboard:view',
] as const;

View File

@@ -0,0 +1,168 @@
/**
* 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';
// ── 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);
localStorage.setItem('token', json.data.token);
localStorage.setItem('user', JSON.stringify(json.data.user));
return json.data;
}
/**
* Logout: clear localStorage.
*/
export function logout(): void {
localStorage.removeItem('token');
localStorage.removeItem('user');
localStorage.removeItem('permissions');
localStorage.removeItem('currentCampusId');
}
// ── API helpers (authenticated) ─────────────────────────────────────
function authHeaders(): Record<string, string> {
const token = localStorage.getItem('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

@@ -0,0 +1,24 @@
/**
* Vitest browser-mode setup.
* Runs once before all tests.
*/
import { beforeAll, afterEach } from 'vitest';
// Base URL: the Vite dev server proxies /api → localhost:3003
const BASE = 'http://localhost:3002';
beforeAll(() => {
// Ensure we're running against the local dev server
console.log(`[setup] browser integration tests → ${BASE}`);
});
afterEach(() => {
// Clear localStorage between tests to avoid state leakage
// (only clear auth-related keys; keep other state if needed)
localStorage.removeItem('token');
localStorage.removeItem('user');
localStorage.removeItem('permissions');
localStorage.removeItem('currentCampusId');
});
export { BASE };