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,31 @@
/**
* Download a file from the API as a blob and trigger a browser download.
*
* @param endpoint - API path (e.g. '/rooms/template')
* @param filename - Suggested filename for the download
*/
export async function downloadBlob(endpoint: string, filename: string): Promise<void> {
const baseURL = import.meta.env.PROD
? '/api'
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const token = localStorage.getItem('token');
const res = await fetch(`${baseURL}${endpoint}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(text || `下载失败 (HTTP ${res.status})`);
}
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);
}

View File

@@ -0,0 +1,9 @@
export const maskPhone = (phone: string): string => {
if (!phone || phone.length < 7) return phone || '-';
return phone.slice(0, 3) + '****' + phone.slice(-4);
};
export const maskIdNumber = (id: string): string => {
if (!id || id.length < 8) return id || '-';
return id.slice(0, 3) + '***********' + id.slice(-4);
};