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

@@ -1,4 +1,4 @@
import { Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
import { BadRequestException, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequirePermission } from '../auth/decorators/permission.decorator';
import { SyncService } from './sync.service';
@@ -11,8 +11,12 @@ export class SyncController {
@Post('trigger')
@RequirePermission('sync:trigger')
async triggerSync(@Query('platform') platform?: SyncPlatform) {
const logs = await this.syncService.triggerSync(platform);
async triggerSync(
@Query('platform') platform?: SyncPlatform,
@Query('rootDeptId') rootDeptId?: string,
) {
const rootId = rootDeptId ? this.parseRootDeptId(rootDeptId) : 1;
const logs = await this.syncService.triggerSync(platform, rootId);
return { synced: logs.length, logs };
}
@@ -27,6 +31,15 @@ export class SyncController {
};
}
/** 获取钉钉组织部门树,供前端选择同步起点 */
@Get('dingtalk/org-tree')
@RequirePermission('sync:read')
async getDingTalkOrgTree(@Query('rootDeptId') rootDeptId?: string) {
const rootId = rootDeptId ? this.parseRootDeptId(rootDeptId) : 1;
const tree = await this.syncService.getDingTalkOrgTree(rootId);
return { success: true, data: tree };
}
@Get('logs')
@RequirePermission('sync:read')
async getLogs(
@@ -35,4 +48,12 @@ export class SyncController {
) {
return this.syncService.getLogs(platform, limit ? Number(limit) : 50);
}
private parseRootDeptId(rootDeptId: string): number {
const parsed = parseInt(rootDeptId, 10);
if (isNaN(parsed)) {
throw new BadRequestException('rootDeptId must be a valid integer');
}
return parsed;
}
}

View File

@@ -33,7 +33,7 @@ export class SyncService {
}
// ── Sync DingTalk ──
async syncDingTalk(): Promise<SyncLog> {
async syncDingTalk(rootDeptId = 1): Promise<SyncLog> {
const platform: SyncPlatform = 'dingtalk';
const syncType = await this.determineSyncType(platform);
@@ -45,7 +45,7 @@ export class SyncService {
// ── Call existing integration APIs ──
// Integration hooks — extend here to call DingTalk APIs with lastSyncAt
const recordsCount = await this.performDingTalkSync(lastSyncAt);
const recordsCount = await this.performDingTalkSync(lastSyncAt, rootDeptId);
await this.updateLastSyncAt(platform);
await this.finishSyncLog(log, 'success', recordsCount);
@@ -87,10 +87,15 @@ export class SyncService {
}
// ── Manual trigger ──
async triggerSync(platform?: SyncPlatform): Promise<SyncLog[]> {
if (platform === 'dingtalk') return [await this.syncDingTalk()];
async triggerSync(platform?: SyncPlatform, rootDeptId = 1): Promise<SyncLog[]> {
if (platform === 'dingtalk') return [await this.syncDingTalk(rootDeptId)];
if (platform === 'wecom') return [await this.syncWeCom()];
return [await this.syncDingTalk(), await this.syncWeCom()];
return [await this.syncDingTalk(rootDeptId), await this.syncWeCom()];
}
/** 获取钉钉组织部门树,供前端选择同步起点 */
async getDingTalkOrgTree(rootDeptId = 1) {
return this.dingTalkService.fetchOrgTree(rootDeptId);
}
// ── Sync log queries ──
@@ -154,9 +159,9 @@ export class SyncService {
await this.syncLogRepo.save(log);
}
private async performDingTalkSync(lastSyncAt: Date | null): Promise<number> {
private async performDingTalkSync(lastSyncAt: Date | null, rootDeptId = 1): Promise<number> {
// Stage 1: Sync departments and users
const result = await this.dingTalkService.syncAll();
const result = await this.dingTalkService.syncAll(rootDeptId);
let total = result.deptCount + result.userCount;
// Stage 2: Import attendance data (last 7 days or since last sync)
@@ -175,7 +180,7 @@ export class SyncService {
this.logger.log(`Importing DingTalk attendance: ${start} ~ ${end}`);
const mappings = await this.mappingRepo.find();
const userIds = mappings.map((m) => m.dingUserId).filter(Boolean);
const userIds = mappings.map((m) => m.dingUserId);
const importResult = await this.attendanceImportService.importFromDingTalk({
startDate: start,
endDate: end,