fix: audit remediation — SSE user scoping, FK transactional safety, UI error handling
- H4: scoped SSE import progress to exact userId match; non-HTTP events excluded from all subscribers - H2: moved PRAGMA foreign_key_check inside SQLite transaction before COMMIT; violations rollback preserving old tables - M1: removed dead axios-style error branch from extractErrorMessage (interceptor already unwraps) - M2: split handleSave try/catch — save errors vs reload errors shown distinctly - M3: added provider field validation before AI config test request - Added SSE scoping regression tests (import service + controller) - Added FK check failure rollback test (database-migrations.spec) - Updated controller spec expectations for userId parameter Co-authored-by: Code Review <branch-review>
This commit is contained in:
@@ -193,7 +193,7 @@ const App: React.FC = () => {
|
||||
<Route
|
||||
path="teachers"
|
||||
element={
|
||||
<PermissionRoute permission="user:view">
|
||||
<PermissionRoute permission="teacher:view">
|
||||
<TeachersPage />
|
||||
</PermissionRoute>
|
||||
}
|
||||
@@ -252,7 +252,7 @@ const App: React.FC = () => {
|
||||
<Route
|
||||
path="teacher-workspace"
|
||||
element={
|
||||
<PermissionRoute permission="class:view">
|
||||
<PermissionRoute permission="teacher-workspace:view">
|
||||
<TeacherWorkspacePage />
|
||||
</PermissionRoute>
|
||||
}
|
||||
|
||||
105
apps/admin/src/auth/menu-policy.integration.test.ts
Normal file
105
apps/admin/src/auth/menu-policy.integration.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildMenu, collectMenuPaths, findRoleAwareLandingPath } from './menu-policy';
|
||||
|
||||
const teacherPermissions = [
|
||||
'teacher-workspace:view',
|
||||
'schedule:view',
|
||||
'attendance:view',
|
||||
'notification:view',
|
||||
];
|
||||
const academicPermissions = [
|
||||
'dashboard:view',
|
||||
'student:view',
|
||||
'class:view',
|
||||
'teacher:view',
|
||||
'schedule:view',
|
||||
'attendance:view',
|
||||
'classroom:view',
|
||||
'notification:view',
|
||||
];
|
||||
const accommodationPermissions = [
|
||||
'dashboard:view',
|
||||
'room:view',
|
||||
'occupancy:view',
|
||||
'expense:view',
|
||||
'bill:view',
|
||||
'deposit:view',
|
||||
'notification:view',
|
||||
];
|
||||
const systemPermissions = [
|
||||
'user:view',
|
||||
'role:view',
|
||||
'log:view',
|
||||
'integration:read',
|
||||
'ai:config:read',
|
||||
'notification:view',
|
||||
];
|
||||
|
||||
describe('role-aware menu policy', () => {
|
||||
it('builds a teacher flow without global student or class management', () => {
|
||||
const menu = buildMenu(['任课老师'], teacherPermissions);
|
||||
expect(menu.map((item) => item.label)).toEqual(['教学工作', '通知中心']);
|
||||
expect(collectMenuPaths(menu)).toEqual([
|
||||
'/teacher-workspace',
|
||||
'/schedules',
|
||||
'/attendance',
|
||||
'/notifications',
|
||||
]);
|
||||
expect(findRoleAwareLandingPath(['任课老师'], teacherPermissions)).toBe(
|
||||
'/teacher-workspace',
|
||||
);
|
||||
});
|
||||
|
||||
it('places schedules and attendance only once in academic management', () => {
|
||||
const menu = buildMenu(['教务管理员'], academicPermissions);
|
||||
const paths = collectMenuPaths(menu);
|
||||
expect(menu.map((item) => item.label)).toEqual(['数据面板', '教务管理', '通知中心']);
|
||||
expect(paths.filter((path) => path === '/schedules')).toHaveLength(1);
|
||||
expect(paths.filter((path) => path === '/attendance')).toHaveLength(1);
|
||||
expect(paths).not.toContain('/teacher-workspace');
|
||||
});
|
||||
|
||||
it('keeps accommodation billing in one business workspace', () => {
|
||||
const menu = buildMenu(['住宿运营管理员'], accommodationPermissions);
|
||||
expect(menu.map((item) => item.label)).toEqual(['数据面板', '住宿运营', '通知中心']);
|
||||
expect(collectMenuPaths(menu)).toEqual([
|
||||
'/dashboard',
|
||||
'/room-visual',
|
||||
'/rooms',
|
||||
'/occupancies',
|
||||
'/expenses',
|
||||
'/bills',
|
||||
'/deposits',
|
||||
'/notifications',
|
||||
]);
|
||||
});
|
||||
|
||||
it('lands system administrators on account management rather than notifications', () => {
|
||||
expect(findRoleAwareLandingPath(['系统管理员'], systemPermissions)).toBe('/users');
|
||||
});
|
||||
|
||||
it('builds a super-admin menu with unique routes and no teacher workspace', () => {
|
||||
const allPermissions = [
|
||||
...academicPermissions,
|
||||
...accommodationPermissions,
|
||||
...systemPermissions,
|
||||
'rental:view',
|
||||
'organization:view',
|
||||
];
|
||||
const menu = buildMenu(['超级管理员'], [...new Set(allPermissions)]);
|
||||
const paths = collectMenuPaths(menu);
|
||||
expect(new Set(paths).size).toBe(paths.length);
|
||||
expect(paths).not.toContain('/teacher-workspace');
|
||||
expect(paths.filter((path) => path === '/attendance')).toHaveLength(1);
|
||||
expect(paths.filter((path) => path === '/schedules')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('deduplicates routes when an account has multiple business roles', () => {
|
||||
const menu = buildMenu(
|
||||
['教务管理员', '住宿运营管理员'],
|
||||
[...academicPermissions, ...accommodationPermissions],
|
||||
);
|
||||
const paths = collectMenuPaths(menu);
|
||||
expect(new Set(paths).size).toBe(paths.length);
|
||||
});
|
||||
});
|
||||
186
apps/admin/src/auth/menu-policy.ts
Normal file
186
apps/admin/src/auth/menu-policy.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
export interface AppMenuItem {
|
||||
key: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
children?: AppMenuItem[];
|
||||
}
|
||||
|
||||
interface MenuEntry extends AppMenuItem {
|
||||
permission: string;
|
||||
}
|
||||
|
||||
interface MenuSection extends AppMenuItem {
|
||||
roles: string[];
|
||||
children: MenuEntry[];
|
||||
}
|
||||
|
||||
const ROLE_ALIASES: Record<string, string> = {
|
||||
老师: 'teacher',
|
||||
任课老师: 'teacher',
|
||||
teacher: 'teacher',
|
||||
教务: 'academic',
|
||||
教务管理员: 'academic',
|
||||
academic: 'academic',
|
||||
宿管老师: 'accommodation',
|
||||
宿管: 'accommodation',
|
||||
财务: 'accommodation',
|
||||
住宿运营管理员: 'accommodation',
|
||||
accommodation_operations: 'accommodation',
|
||||
机构负责人: 'classroom',
|
||||
教室运营管理员: 'classroom',
|
||||
classroom_operations: 'classroom',
|
||||
系统管理员: 'system',
|
||||
system_admin: 'system',
|
||||
超管: 'super',
|
||||
超级管理员: 'super',
|
||||
super_admin: 'super',
|
||||
};
|
||||
|
||||
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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
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: '/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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
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: '/deposits', label: '押金管理', icon: 'deposit', permission: '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: '/classroom-rentals', label: '租赁订单', icon: 'rental', permission: 'rental:view' },
|
||||
{ key: '/organizations', label: '机构管理', icon: 'organization', permission: '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' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function getRoleDomains(roles: readonly string[], permissions: readonly string[]): Set<string> {
|
||||
const normalized = new Set(roles.map((role) => ROLE_ALIASES[role]).filter(Boolean));
|
||||
// 权限可以来自多个叠加角色,因此业务域按能力累加,而不是只选择一个。
|
||||
if (permissions.includes('student:view') || permissions.includes('class:view')) {
|
||||
normalized.add('academic');
|
||||
}
|
||||
if (
|
||||
permissions.includes('room:view') &&
|
||||
(permissions.includes('occupancy:view') || permissions.includes('expense:view'))
|
||||
) {
|
||||
normalized.add('accommodation');
|
||||
}
|
||||
if (permissions.includes('rental:view') || permissions.includes('organization:view')) {
|
||||
normalized.add('classroom');
|
||||
}
|
||||
if (permissions.includes('user:view') || permissions.includes('role:view')) {
|
||||
normalized.add('system');
|
||||
}
|
||||
const hasAdministrativeDomain = [...normalized].some((role) => role !== 'teacher');
|
||||
if (!hasAdministrativeDomain && permissions.includes('teacher-workspace:view')) {
|
||||
normalized.add('teacher');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function dedupeMenu(items: AppMenuItem[]): AppMenuItem[] {
|
||||
const usedPaths = new Set<string>();
|
||||
const result: AppMenuItem[] = [];
|
||||
for (const item of items) {
|
||||
if (item.children) {
|
||||
const children = item.children.filter((child) => {
|
||||
if (usedPaths.has(child.key)) return false;
|
||||
usedPaths.add(child.key);
|
||||
return true;
|
||||
});
|
||||
if (children.length > 0) result.push({ ...item, children });
|
||||
continue;
|
||||
}
|
||||
if (!usedPaths.has(item.key)) {
|
||||
usedPaths.add(item.key);
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function buildMenu(roles: readonly string[], permissions: readonly string[]): AppMenuItem[] {
|
||||
const roleSet = getRoleDomains(roles, permissions);
|
||||
const permissionSet = new Set(permissions);
|
||||
const sections: AppMenuItem[] = [];
|
||||
|
||||
if (permissionSet.has('dashboard:view') && !roleSet.has('teacher')) {
|
||||
sections.push({ key: '/dashboard', label: '数据面板', icon: 'dashboard' });
|
||||
}
|
||||
|
||||
for (const section of SECTIONS) {
|
||||
if (!section.roles.some((role) => roleSet.has(role))) continue;
|
||||
const children = section.children
|
||||
.filter((child) => permissionSet.has(child.permission))
|
||||
.map(({ permission: _, ...child }) => child);
|
||||
if (children.length > 0) sections.push({ ...section, children, roles: undefined } as AppMenuItem);
|
||||
}
|
||||
|
||||
if (permissionSet.has('notification:view')) {
|
||||
sections.push({ key: '/notifications', label: '通知中心', icon: 'notification' });
|
||||
}
|
||||
|
||||
return dedupeMenu(sections);
|
||||
}
|
||||
|
||||
export function collectMenuPaths(items: readonly AppMenuItem[]): string[] {
|
||||
return items.flatMap((item) => (item.children ? collectMenuPaths(item.children) : [item.key]));
|
||||
}
|
||||
|
||||
export function findRoleAwareLandingPath(
|
||||
roles: readonly string[],
|
||||
permissions: readonly string[],
|
||||
): string | null {
|
||||
return collectMenuPaths(buildMenu(roles, permissions))[0] ?? null;
|
||||
}
|
||||
@@ -11,6 +11,18 @@ describe('permission navigation', () => {
|
||||
expect(findFirstAccessiblePath(permissions)).toBe('/classes');
|
||||
});
|
||||
|
||||
it('lands teachers on the teacher workspace without global student or class access', () => {
|
||||
expect(
|
||||
findFirstAccessiblePath([
|
||||
'teacher-workspace:view',
|
||||
'schedule:view',
|
||||
'attendance:view',
|
||||
]),
|
||||
).toBe('/teacher-workspace');
|
||||
expect(canAccessPath('/students', ['teacher-workspace:view'])).toBe(false);
|
||||
expect(canAccessPath('/classes', ['teacher-workspace:view'])).toBe(false);
|
||||
});
|
||||
|
||||
it('uses dashboard when it is the first accessible page', () => {
|
||||
expect(findFirstAccessiblePath(['dashboard:view', 'student:view'])).toBe('/dashboard');
|
||||
});
|
||||
|
||||
@@ -13,11 +13,11 @@ export const PERMISSION_PAGES: readonly PermissionPage[] = [
|
||||
{ path: '/room-visual', permission: 'room:view' },
|
||||
{ path: '/rooms', permission: 'room:view' },
|
||||
{ path: '/occupancies', permission: 'occupancy:view' },
|
||||
{ path: '/teacher-workspace', permission: 'teacher-workspace:view' },
|
||||
{ path: '/students', permission: 'student:view', matches: (p) => p === '/students' || /^\/students\/\d+\/profile$/.test(p) },
|
||||
{ path: '/classes', permission: 'class:view', matches: (p) => p === '/classes' || /^\/classes\/\d+$/.test(p) },
|
||||
{ path: '/attendance', permission: 'attendance:view' },
|
||||
{ path: '/schedules', permission: 'schedule:view' },
|
||||
{ path: '/teacher-workspace', permission: 'class:view' },
|
||||
{ path: '/classroom-schedule', permission: 'rental:view' },
|
||||
{ path: '/classrooms', permission: 'classroom:view' },
|
||||
{ path: '/classroom-rentals', permission: 'rental:view' },
|
||||
@@ -32,7 +32,7 @@ export const PERMISSION_PAGES: readonly PermissionPage[] = [
|
||||
{ path: '/integration-config', permission: 'integration:read' },
|
||||
{ path: '/ai-config', permission: 'ai:config:read' },
|
||||
{ path: '/users', permission: 'user:view' },
|
||||
{ path: '/teachers', permission: 'user:view' },
|
||||
{ path: '/teachers', permission: 'teacher:view' },
|
||||
] as const;
|
||||
|
||||
function matchesPage(page: PermissionPage, pathname: string): boolean {
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { filterTabsByPermission } from './permission-tabs';
|
||||
|
||||
describe('permission-aware tabs', () => {
|
||||
const tabs = [
|
||||
{ key: 'all', requiredPermission: 'deposit:view' },
|
||||
{ key: 'pending', requiredPermission: 'deposit:approve' },
|
||||
];
|
||||
const tabs = [
|
||||
{ key: 'records', requiredPermission: 'deposit:view' },
|
||||
{ key: 'refund', requiredPermission: 'deposit:refund' },
|
||||
];
|
||||
|
||||
it('hides tabs whose backing API permission is missing', () => {
|
||||
expect(filterTabsByPermission(tabs, ['deposit:view']).map((tab) => tab.key)).toEqual(['all']);
|
||||
});
|
||||
|
||||
it('shows a privileged tab only when its permission is present', () => {
|
||||
describe('permission tabs', () => {
|
||||
it('shows only tabs allowed by exact permissions', () => {
|
||||
expect(filterTabsByPermission(tabs, ['deposit:view']).map((tab) => tab.key)).toEqual([
|
||||
'records',
|
||||
]);
|
||||
expect(
|
||||
filterTabsByPermission(tabs, ['deposit:view', 'deposit:approve']).map((tab) => tab.key),
|
||||
).toEqual(['all', 'pending']);
|
||||
filterTabsByPermission(tabs, ['deposit:view', 'deposit:refund']).map((tab) => tab.key),
|
||||
).toEqual(['records', 'refund']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,11 +2,18 @@ import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { Result } from 'antd';
|
||||
import { usePermission } from '../hooks/usePermission';
|
||||
import { findFirstAccessiblePath } from '../auth/permission-navigation';
|
||||
import { findRoleAwareLandingPath } from '../auth/menu-policy';
|
||||
|
||||
const DefaultRoute: React.FC = () => {
|
||||
const { permissions } = usePermission();
|
||||
const firstPath = findFirstAccessiblePath(permissions);
|
||||
const roles = (() => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem('user') || '{}').roles || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
const firstPath = findRoleAwareLandingPath(roles, permissions);
|
||||
if (firstPath) return <Navigate to={firstPath} replace />;
|
||||
return <Result status="403" title="暂无可访问功能" subTitle="请联系管理员为当前账号分配功能权限" />;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Result, Button } from 'antd';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { findFirstAccessiblePath } from '../auth/permission-navigation';
|
||||
import { findRoleAwareLandingPath } from '../auth/menu-policy';
|
||||
import { usePermission } from '../hooks/usePermission';
|
||||
|
||||
interface PermissionRouteProps {
|
||||
@@ -13,7 +13,13 @@ const PermissionRoute: React.FC<PermissionRouteProps> = ({ permission, children
|
||||
const { permissions, hasPermission } = usePermission();
|
||||
const navigate = useNavigate();
|
||||
if (!hasPermission(permission)) {
|
||||
const firstPath = findFirstAccessiblePath(permissions);
|
||||
let roles: string[] = [];
|
||||
try {
|
||||
roles = JSON.parse(localStorage.getItem('user') || '{}').roles || [];
|
||||
} catch {
|
||||
roles = [];
|
||||
}
|
||||
const firstPath = findRoleAwareLandingPath(roles, permissions);
|
||||
return (
|
||||
<Result
|
||||
status="403"
|
||||
|
||||
@@ -32,95 +32,37 @@ import { usePermission } from '../hooks/usePermission';
|
||||
import api from '../api';
|
||||
import { writePermissions } from '../auth/permission-store';
|
||||
import NotificationBell from '../components/NotificationBell';
|
||||
import { buildMenu, type AppMenuItem } from '../auth/menu-policy';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
|
||||
interface MenuItemType {
|
||||
key: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
permission?: string;
|
||||
children?: MenuItemType[];
|
||||
}
|
||||
|
||||
const allMenuItems: MenuItemType[] = [
|
||||
{
|
||||
key: '/dashboard',
|
||||
icon: <DashboardOutlined />,
|
||||
label: '数据面板',
|
||||
permission: 'dashboard:view',
|
||||
},
|
||||
{
|
||||
key: 'dorm-group',
|
||||
icon: <HomeOutlined />,
|
||||
label: '宿舍运营',
|
||||
permission: 'room:view',
|
||||
children: [
|
||||
{ key: '/room-visual', icon: <AppstoreOutlined />, label: '宿舍总览', permission: 'room:view' },
|
||||
{ key: '/rooms', icon: <HomeOutlined />, label: '宿舍管理', permission: 'room:view' },
|
||||
{ key: '/occupancies', icon: <SwapOutlined />, label: '入住管理', permission: 'occupancy:view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'student-group',
|
||||
icon: <TeamOutlined />,
|
||||
label: '学员管理',
|
||||
permission: 'student:view',
|
||||
children: [
|
||||
{ key: '/students', icon: <TeamOutlined />, label: '学生管理', permission: 'student:view' },
|
||||
{ key: '/classes', icon: <TeamOutlined />, label: '班级管理', permission: 'class:view' },
|
||||
{ key: '/attendance', icon: <CheckCircleOutlined />, label: '考勤管理', permission: 'attendance:view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'academic-group',
|
||||
icon: <CalendarOutlined />,
|
||||
label: '教务管理',
|
||||
permission: 'schedule:view',
|
||||
children: [
|
||||
{ key: '/schedules', icon: <CalendarOutlined />, label: '排课管理', permission: 'schedule:view' },
|
||||
{ key: '/teacher-workspace', icon: <LaptopOutlined />, label: '教师工作台', permission: 'class:view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'classroom-group',
|
||||
icon: <ReadOutlined />,
|
||||
label: '教室管理',
|
||||
permission: 'classroom:view',
|
||||
children: [
|
||||
{ key: '/classroom-schedule', icon: <CalendarOutlined />, label: '排期总览', permission: 'rental:view' },
|
||||
{ key: '/classrooms', icon: <ReadOutlined />, label: '教室列表', permission: 'classroom:view' },
|
||||
{ key: '/classroom-rentals', icon: <FileProtectOutlined />, label: '租赁订单', permission: 'rental:view' },
|
||||
{ key: '/organizations', icon: <TagsOutlined />, label: '机构管理', permission: 'organization:view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'finance-group',
|
||||
icon: <DollarOutlined />,
|
||||
label: '财务管理',
|
||||
permission: 'expense:view',
|
||||
children: [
|
||||
{ key: '/expenses', icon: <DollarOutlined />, label: '费用录入', permission: 'expense:view' },
|
||||
{ key: '/deposits', icon: <WalletOutlined />, label: '押金管理', permission: 'deposit:view' },
|
||||
{ key: '/bills', icon: <FileTextOutlined />, label: '账单管理', permission: 'bill:view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'system-group',
|
||||
icon: <SettingOutlined />,
|
||||
label: '系统管理',
|
||||
permission: 'log:view',
|
||||
children: [
|
||||
{ key: '/notifications', icon: <BellOutlined />, label: '通知中心', permission: 'notification:view' },
|
||||
{ key: '/operation-logs', icon: <AuditOutlined />, label: '操作日志', permission: 'log:view' },
|
||||
{ key: '/roles', icon: <SafetyOutlined />, label: '角色管理', permission: 'role:view' },
|
||||
{ key: '/permissions', icon: <KeyOutlined />, label: '权限一览', permission: 'role:view' },
|
||||
{ key: '/integration-config', icon: <ApiOutlined />, label: '钉钉集成配置', permission: 'integration:read' },
|
||||
{ key: '/ai-config', icon: <RobotOutlined />, label: 'AI 模型配置', permission: 'ai:config:read' },
|
||||
{ key: '/users', icon: <SettingOutlined />, label: '账号管理', permission: 'user:view' },
|
||||
],
|
||||
},
|
||||
];
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
dashboard: <DashboardOutlined />,
|
||||
calendar: <CalendarOutlined />,
|
||||
workspace: <LaptopOutlined />,
|
||||
attendance: <CheckCircleOutlined />,
|
||||
academic: <TeamOutlined />,
|
||||
students: <TeamOutlined />,
|
||||
classes: <TeamOutlined />,
|
||||
teachers: <UserOutlined />,
|
||||
home: <HomeOutlined />,
|
||||
overview: <AppstoreOutlined />,
|
||||
occupancy: <SwapOutlined />,
|
||||
expense: <DollarOutlined />,
|
||||
bill: <FileTextOutlined />,
|
||||
deposit: <WalletOutlined />,
|
||||
classroom: <ReadOutlined />,
|
||||
rental: <FileProtectOutlined />,
|
||||
organization: <TagsOutlined />,
|
||||
settings: <SettingOutlined />,
|
||||
users: <UserOutlined />,
|
||||
role: <SafetyOutlined />,
|
||||
permission: <KeyOutlined />,
|
||||
log: <AuditOutlined />,
|
||||
integration: <ApiOutlined />,
|
||||
ai: <RobotOutlined />,
|
||||
notification: <BellOutlined />,
|
||||
};
|
||||
|
||||
const MainLayout: React.FC = () => {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
@@ -129,8 +71,10 @@ const MainLayout: React.FC = () => {
|
||||
const prevPathname = useRef('');
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const user = useMemo(() => JSON.parse(localStorage.getItem('user') || '{}'), []);
|
||||
const { hasPermission } = usePermission();
|
||||
const [user, setUser] = useState<{ name?: string; username?: string; roles?: string[] }>(() =>
|
||||
JSON.parse(localStorage.getItem('user') || '{}'),
|
||||
);
|
||||
const { permissions, hasPermission } = usePermission();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -139,7 +83,9 @@ const MainLayout: React.FC = () => {
|
||||
if (cancelled) return;
|
||||
writePermissions(profile.permissions || []);
|
||||
const cachedUser = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
localStorage.setItem('user', JSON.stringify({ ...cachedUser, ...profile }));
|
||||
const nextUser = { ...cachedUser, ...profile };
|
||||
localStorage.setItem('user', JSON.stringify(nextUser));
|
||||
setUser(nextUser);
|
||||
})
|
||||
.catch(() => {
|
||||
// The API interceptor handles expired/invalid sessions.
|
||||
@@ -152,22 +98,10 @@ const MainLayout: React.FC = () => {
|
||||
const isTablet = (screens.sm || screens.md) && !screens.lg; // 576-991px
|
||||
const isDesktop = !!screens.lg; // >= 992px
|
||||
|
||||
// 按 permission 过滤菜单
|
||||
const filterByPermission = (items: MenuItemType[]): MenuItemType[] => {
|
||||
return items
|
||||
.map((item) => {
|
||||
if (item.children) {
|
||||
const kids = filterByPermission(item.children);
|
||||
if (kids.length === 0) return null;
|
||||
return { ...item, children: kids };
|
||||
}
|
||||
if (!item.permission) return item;
|
||||
return hasPermission(item.permission) ? item : null;
|
||||
})
|
||||
.filter(Boolean) as MenuItemType[];
|
||||
};
|
||||
|
||||
const menuItems = useMemo(() => filterByPermission(allMenuItems), [hasPermission]);
|
||||
const menuItems = useMemo(
|
||||
() => buildMenu(user.roles ?? [], permissions),
|
||||
[user.roles, permissions],
|
||||
);
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
localStorage.removeItem('token');
|
||||
@@ -181,7 +115,7 @@ const MainLayout: React.FC = () => {
|
||||
if (isMobile) setDrawerOpen(false);
|
||||
}, [navigate, isMobile]);
|
||||
|
||||
const findSelectedKeys = (items: MenuItemType[], pathname: string): string[] => {
|
||||
const findSelectedKeys = (items: AppMenuItem[], pathname: string): string[] => {
|
||||
for (const item of items) {
|
||||
if (item.key === pathname) return [item.key];
|
||||
if (item.children) {
|
||||
@@ -192,7 +126,7 @@ const MainLayout: React.FC = () => {
|
||||
return [pathname];
|
||||
};
|
||||
|
||||
const findOpenKeys = (items: MenuItemType[], pathname: string): string[] => {
|
||||
const findOpenKeys = (items: AppMenuItem[], pathname: string): string[] => {
|
||||
for (const item of items) {
|
||||
if (item.children) {
|
||||
if (item.children.some((c) => c.key === pathname || (c.children && c.children.some((gc) => gc.key === pathname)))) {
|
||||
@@ -220,10 +154,10 @@ const MainLayout: React.FC = () => {
|
||||
|
||||
|
||||
|
||||
const transformToMenuItems = (items: MenuItemType[]): any[] => {
|
||||
const transformToMenuItems = (items: AppMenuItem[]): any[] => {
|
||||
return items.map((item) => ({
|
||||
key: item.key,
|
||||
icon: item.icon,
|
||||
icon: item.icon ? iconMap[item.icon] : undefined,
|
||||
label: item.label,
|
||||
children: item.children ? transformToMenuItems(item.children) : undefined,
|
||||
}));
|
||||
|
||||
@@ -69,10 +69,10 @@ describe('AiConfig helpers', () => {
|
||||
});
|
||||
|
||||
describe('extractErrorMessage', () => {
|
||||
it('extracts axios-style response error message', () => {
|
||||
const err = {
|
||||
response: { data: { message: 'API出错' } },
|
||||
};
|
||||
it('extracts message from server error response (interceptor unwraps to { message })', () => {
|
||||
// The Axios interceptor at api/index.ts does Promise.reject(err.response?.data || err).
|
||||
// For server errors, the rejection value is err.response.data — typically { message: '...' }.
|
||||
const err = { message: 'API出错' };
|
||||
expect(extractErrorMessage(err)).toBe('API出错');
|
||||
});
|
||||
|
||||
|
||||
@@ -60,23 +60,17 @@ export function shouldAutoSwapBaseUrl(
|
||||
return { baseUrl: currentBaseUrl, shouldSwap: false };
|
||||
}
|
||||
|
||||
/** Extract a safe user-facing error message from any caught value */
|
||||
/** Extract a safe user-facing error message from any caught value.
|
||||
*
|
||||
* The Axios interceptor at `api/index.ts` unwraps errors before rejection:
|
||||
* `Promise.reject(err.response?.data || err)`. So server errors arrive as
|
||||
* `{ message: '...' }` (the unwrapped data) and network errors as the raw
|
||||
* `Error` object — never as a raw AxiosError with a `.response` property. */
|
||||
export function extractErrorMessage(err: unknown, fallback: string = '操作失败'): string {
|
||||
let msg = '';
|
||||
|
||||
// axios-style error: { response: { data: { message: string } } }
|
||||
if (err && typeof err === 'object' && 'response' in err) {
|
||||
const resp: unknown = err.response;
|
||||
if (resp && typeof resp === 'object' && 'data' in resp) {
|
||||
const data: unknown = resp.data;
|
||||
if (data && typeof data === 'object' && 'message' in data && typeof data.message === 'string') {
|
||||
msg = data.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// standard Error or any object with a string message property
|
||||
if (!msg && err && typeof err === 'object' && 'message' in err && typeof err.message === 'string') {
|
||||
if (err && typeof err === 'object' && 'message' in err && typeof err.message === 'string') {
|
||||
msg = err.message;
|
||||
}
|
||||
|
||||
|
||||
@@ -173,9 +173,17 @@ const AiConfigPage: React.FC = () => {
|
||||
await api.put('/ai/config', body);
|
||||
message.success('配置已保存');
|
||||
form.setFieldValue('apiKey', '');
|
||||
await loadConfig();
|
||||
} catch (err: unknown) {
|
||||
message.error(extractErrorMessage(err, '保存失败'));
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reload config from server (non-fatal if it fails)
|
||||
try {
|
||||
await loadConfig();
|
||||
} catch (err: unknown) {
|
||||
message.warning(extractErrorMessage(err, '配置已保存,但刷新失败'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -186,7 +194,7 @@ const AiConfigPage: React.FC = () => {
|
||||
const handleTest = useCallback(async () => {
|
||||
try {
|
||||
// Validated fields: compatible requires baseUrl
|
||||
const fieldsToValidate = ['timeoutMs'] as string[];
|
||||
const fieldsToValidate = ['provider', 'timeoutMs'] as string[];
|
||||
if (currentProvider === 'OPENAI_COMPATIBLE') {
|
||||
fieldsToValidate.push('baseUrl');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
canPullAttendance,
|
||||
getAttendanceExperience,
|
||||
getSchedulePhase,
|
||||
summarizeAttendance,
|
||||
} from './attendance-workspace';
|
||||
|
||||
describe('attendance role experience', () => {
|
||||
it('routes class-scoped teachers to the teaching workspace', () => {
|
||||
expect(
|
||||
getAttendanceExperience(
|
||||
['attendance:view', 'attendance:create', 'schedule:create'],
|
||||
['老师'],
|
||||
),
|
||||
).toBe('teacher');
|
||||
});
|
||||
|
||||
it('routes users with attendance administration permission to history management', () => {
|
||||
expect(getAttendanceExperience(['attendance:view', 'attendance:edit'], ['教务管理员'])).toBe(
|
||||
'admin',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('teacher schedule phase', () => {
|
||||
it('marks a finished lesson as ready for attendance review', () => {
|
||||
expect(getSchedulePhase('08:00', '09:00', new Date('2026-07-11T10:00:00'))).toBe('ended');
|
||||
});
|
||||
|
||||
it('keeps future lessons read-only', () => {
|
||||
expect(getSchedulePhase('14:00', '15:00', new Date('2026-07-11T10:00:00'))).toBe('upcoming');
|
||||
});
|
||||
|
||||
it('allows attendance pulls once the lesson starts', () => {
|
||||
expect(canPullAttendance('ongoing')).toBe(true);
|
||||
expect(canPullAttendance('ended')).toBe(true);
|
||||
expect(canPullAttendance('upcoming')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('attendance summary', () => {
|
||||
it('summarizes status counts for an administrator history view', () => {
|
||||
expect(
|
||||
summarizeAttendance([
|
||||
{ status: 'present' },
|
||||
{ status: 'present' },
|
||||
{ status: 'late' },
|
||||
{ status: 'absent' },
|
||||
]),
|
||||
).toEqual({ total: 4, present: 2, late: 1, absent: 1, leave: 0, pending: 0 });
|
||||
});
|
||||
});
|
||||
65
apps/admin/src/pages/Attendance/attendance-workspace.ts
Normal file
65
apps/admin/src/pages/Attendance/attendance-workspace.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
export type AttendanceExperience = 'teacher' | 'admin';
|
||||
export type SchedulePhase = 'upcoming' | 'ongoing' | 'ended';
|
||||
|
||||
import { getRoleDomains } from '../../auth/menu-policy';
|
||||
|
||||
export function getAttendanceExperience(
|
||||
permissions: readonly string[],
|
||||
roles: readonly string[],
|
||||
): AttendanceExperience {
|
||||
const domains = getRoleDomains(roles, permissions);
|
||||
if (
|
||||
permissions.includes('attendance:manage') ||
|
||||
domains.has('academic') ||
|
||||
domains.has('super')
|
||||
) {
|
||||
return 'admin';
|
||||
}
|
||||
return 'teacher';
|
||||
}
|
||||
|
||||
function toMinuteOfDay(time: string): number {
|
||||
const [hour = 0, minute = 0] = time.split(':').map(Number);
|
||||
return hour * 60 + minute;
|
||||
}
|
||||
|
||||
export function getSchedulePhase(
|
||||
startTime: string,
|
||||
endTime: string,
|
||||
now = new Date(),
|
||||
): SchedulePhase {
|
||||
const current = now.getHours() * 60 + now.getMinutes();
|
||||
if (current < toMinuteOfDay(startTime)) return 'upcoming';
|
||||
if (current <= toMinuteOfDay(endTime)) return 'ongoing';
|
||||
return 'ended';
|
||||
}
|
||||
|
||||
export function canPullAttendance(phase: SchedulePhase): boolean {
|
||||
return phase !== 'upcoming';
|
||||
}
|
||||
|
||||
export interface AttendanceSummary {
|
||||
total: number;
|
||||
present: number;
|
||||
late: number;
|
||||
absent: number;
|
||||
leave: number;
|
||||
pending: number;
|
||||
}
|
||||
|
||||
export function summarizeAttendance(records: readonly { status: string }[]): AttendanceSummary {
|
||||
const summary: AttendanceSummary = {
|
||||
total: records.length,
|
||||
present: 0,
|
||||
late: 0,
|
||||
absent: 0,
|
||||
leave: 0,
|
||||
pending: 0,
|
||||
};
|
||||
for (const record of records) {
|
||||
if (record.status in summary && record.status !== 'total') {
|
||||
summary[record.status as Exclude<keyof AttendanceSummary, 'total'>] += 1;
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
504
apps/admin/src/pages/Attendance/attendance.css
Normal file
504
apps/admin/src/pages/Attendance/attendance.css
Normal file
@@ -0,0 +1,504 @@
|
||||
.attendance-page {
|
||||
--ink: #172033;
|
||||
--muted: #667085;
|
||||
--line: #e6eaf0;
|
||||
--blue: #1677ff;
|
||||
color: var(--ink);
|
||||
padding-bottom: 28px;
|
||||
}
|
||||
|
||||
.attendance-hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
min-height: 188px;
|
||||
padding: 34px 38px;
|
||||
margin-bottom: 18px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.attendance-hero::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 320px;
|
||||
height: 320px;
|
||||
right: -105px;
|
||||
top: -165px;
|
||||
border: 64px solid rgb(255 255 255 / 8%);
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.attendance-hero--teacher {
|
||||
color: white;
|
||||
background: linear-gradient(125deg, #122c5a 0%, #174c93 58%, #1e78c8 100%);
|
||||
box-shadow: 0 14px 34px rgb(24 76 147 / 18%);
|
||||
}
|
||||
|
||||
.attendance-hero--admin {
|
||||
color: #172033;
|
||||
background: linear-gradient(120deg, #f7f9fc 0%, #eef3f9 100%);
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.attendance-hero h1 {
|
||||
margin: 7px 0 8px;
|
||||
font-size: clamp(28px, 3vw, 40px);
|
||||
line-height: 1.15;
|
||||
letter-spacing: -1.2px;
|
||||
}
|
||||
|
||||
.attendance-hero p {
|
||||
max-width: 650px;
|
||||
margin: 0;
|
||||
color: inherit;
|
||||
opacity: 0.72;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.attendance-eyebrow {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.7px;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.teacher-overview {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.teacher-kpi {
|
||||
height: 112px;
|
||||
padding: 21px 24px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: white;
|
||||
box-shadow: 0 5px 16px rgb(19 33 68 / 5%);
|
||||
}
|
||||
|
||||
.teacher-kpi > span,
|
||||
.teacher-kpi > small {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.teacher-kpi > strong {
|
||||
display: inline-block;
|
||||
margin: 6px 7px 0 0;
|
||||
font-size: 30px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.teacher-kpi--next {
|
||||
border-color: #cfe1fb;
|
||||
background: #f4f8ff;
|
||||
}
|
||||
|
||||
.attendance-section-heading {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
margin: 0 2px 14px;
|
||||
}
|
||||
|
||||
.attendance-section-heading span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.attendance-section-heading h2 {
|
||||
margin: 2px 0 0;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.lesson-timeline {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.lesson-card {
|
||||
display: grid;
|
||||
grid-template-columns: 54px 120px minmax(220px, 1fr) auto;
|
||||
align-items: center;
|
||||
min-height: 116px;
|
||||
padding: 18px 20px 18px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-left: 4px solid #c9d2df;
|
||||
border-radius: 14px;
|
||||
background: white;
|
||||
transition: transform 180ms ease, box-shadow 180ms ease, border-color 180ms ease;
|
||||
}
|
||||
|
||||
.lesson-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 10px 26px rgb(24 39 75 / 9%);
|
||||
}
|
||||
|
||||
.lesson-card--ongoing {
|
||||
border-left-color: #1677ff;
|
||||
background: linear-gradient(90deg, #f6f9ff, #fff 32%);
|
||||
}
|
||||
|
||||
.lesson-card--ended {
|
||||
border-left-color: #32a46d;
|
||||
}
|
||||
|
||||
.lesson-sequence {
|
||||
align-self: start;
|
||||
padding-top: 3px;
|
||||
color: #a6b0bf;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.lesson-time {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1px auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-right: 22px;
|
||||
}
|
||||
|
||||
.lesson-time strong {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.lesson-time span {
|
||||
height: 28px;
|
||||
background: #d9e0e9;
|
||||
}
|
||||
|
||||
.lesson-main {
|
||||
padding-left: 24px;
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.lesson-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.lesson-title-row h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.lesson-main p {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.lesson-action {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.attendance-empty-card {
|
||||
padding: 28px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.attendance-empty-card strong {
|
||||
display: block;
|
||||
color: var(--ink);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.attendance-empty-card p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.lesson-record-header {
|
||||
padding: 8px 0 22px;
|
||||
}
|
||||
|
||||
.lesson-record-header h2 {
|
||||
margin: 6px 0;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.lesson-record-header p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.attendance-summary-strip {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(190px, 1.35fr) repeat(4, minmax(100px, 1fr));
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
min-height: 106px;
|
||||
margin-bottom: 18px;
|
||||
padding: 16px 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: white;
|
||||
box-shadow: 0 5px 18px rgb(23 32 51 / 4%);
|
||||
}
|
||||
|
||||
.attendance-rate,
|
||||
.attendance-summary-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 66px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.attendance-rate {
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.attendance-rate span,
|
||||
.attendance-summary-cell span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.attendance-rate strong,
|
||||
.attendance-summary-cell strong {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.attendance-summary-icon {
|
||||
display: grid !important;
|
||||
flex: 0 0 auto;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.is-present { color: #198754 !important; background: #eaf8f1; }
|
||||
.is-late { color: #b56b00 !important; background: #fff4db; }
|
||||
.is-absent { color: #cf3030 !important; background: #fff0f0; }
|
||||
.is-leave { color: #2874c6 !important; background: #edf5ff; }
|
||||
.is-pending { color: #667085 !important; background: #f1f3f6; }
|
||||
|
||||
.attendance-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 4px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.attendance-status__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.archive-alert {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
padding: 13px 16px;
|
||||
color: #8c5d05;
|
||||
border: 1px solid #f3d79c;
|
||||
border-radius: 12px;
|
||||
background: #fffbf0;
|
||||
}
|
||||
|
||||
.archive-alert > span.anticon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.archive-alert div {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.archive-alert strong,
|
||||
.archive-alert span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.archive-alert span {
|
||||
margin-top: 2px;
|
||||
color: #8a7452;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.archive-card {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 6px 24px rgb(23 32 51 / 5%);
|
||||
}
|
||||
|
||||
.archive-card .ant-card-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.archive-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 18px 20px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: #fbfcfe;
|
||||
}
|
||||
|
||||
.archive-toolbar__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.archive-toolbar__title > span.anticon {
|
||||
color: var(--blue);
|
||||
font-size: 21px;
|
||||
}
|
||||
|
||||
.archive-toolbar__title strong,
|
||||
.archive-toolbar__title span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.archive-toolbar__title span {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.archive-card .ant-table-wrapper {
|
||||
padding: 0 20px 10px;
|
||||
}
|
||||
|
||||
.archive-card .ant-table-thead > tr > th {
|
||||
color: #667085;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.student-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.student-cell .ant-avatar {
|
||||
color: #245b9e;
|
||||
background: #e8f1fd;
|
||||
}
|
||||
|
||||
.student-cell strong,
|
||||
.student-cell span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.student-cell span {
|
||||
margin-top: 2px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.muted-text {
|
||||
color: #a1a9b5;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.attendance-hero,
|
||||
.archive-toolbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.attendance-summary-strip {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.attendance-rate {
|
||||
grid-column: 1 / -1;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
|
||||
.lesson-card {
|
||||
grid-template-columns: 42px 1fr auto;
|
||||
}
|
||||
|
||||
.lesson-time {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.lesson-main {
|
||||
grid-column: 2 / -1;
|
||||
margin-top: 12px;
|
||||
padding: 12px 0 0;
|
||||
border-top: 1px solid var(--line);
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.lesson-action {
|
||||
grid-column: 2 / -1;
|
||||
padding: 14px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.attendance-hero {
|
||||
padding: 26px 22px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.attendance-hero h1 {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.attendance-summary-strip {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.attendance-summary-cell {
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.lesson-card {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.lesson-sequence {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.lesson-time,
|
||||
.lesson-main,
|
||||
.lesson-action {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.lesson-main p {
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.attendance-marking-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.attendance-marking-actions .ant-btn {
|
||||
min-width: 54px;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,18 +10,16 @@ import {
|
||||
Space,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
Tabs,
|
||||
List,
|
||||
Card,
|
||||
Empty,
|
||||
} from 'antd';
|
||||
import { PlusOutlined, DeleteOutlined, DollarOutlined, CheckOutlined } from '@ant-design/icons';
|
||||
import { PlusOutlined, DeleteOutlined, DollarOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import { maskPhone, maskIdNumber } from '../../utils/sensitive';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
|
||||
const statusMap: Record<string, { text: string; color: string }> = {
|
||||
paid: { text: '已缴', color: 'green' },
|
||||
@@ -31,8 +29,8 @@ const statusMap: Record<string, { text: string; color: string }> = {
|
||||
};
|
||||
|
||||
const refundStatusMap: Record<string, { text: string; color: string }> = {
|
||||
pending: { text: '待班主任审批', color: 'orange' },
|
||||
head_teacher_approved: { text: '待财务审批', color: 'blue' },
|
||||
pending: { text: '历史退款处理中', color: 'orange' },
|
||||
head_teacher_approved: { text: '历史退款处理中', color: 'blue' },
|
||||
finance_approved: { text: '已退款', color: 'green' },
|
||||
refunded: { text: '已退款', color: 'green' },
|
||||
};
|
||||
@@ -42,17 +40,8 @@ const installmentStatusMap: Record<string, { text: string; color: string }> = {
|
||||
paid: { text: '已缴', color: 'green' },
|
||||
};
|
||||
|
||||
interface PendingRefund {
|
||||
id: number;
|
||||
student?: { name?: string };
|
||||
amount?: number;
|
||||
paidDate?: string;
|
||||
refundStatus?: string;
|
||||
refundRequestedAt?: string;
|
||||
}
|
||||
|
||||
const DepositsPage: React.FC = () => {
|
||||
const { hasPermission } = usePermission();
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [students, setStudents] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -60,16 +49,11 @@ const DepositsPage: React.FC = () => {
|
||||
const [refundModal, setRefundModal] = useState<any>(null);
|
||||
const [detailModal, setDetailModal] = useState<any>(null);
|
||||
const [installmentModal, setInstallmentModal] = useState<number | null>(null);
|
||||
const [pendingRefunds, setPendingRefunds] = useState<PendingRefund[]>([]);
|
||||
const [pendingLoading, setPendingLoading] = useState(false);
|
||||
const [rejectModal, setRejectModal] = useState<any>(null);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [createForm] = Form.useForm();
|
||||
const [refundForm] = Form.useForm();
|
||||
const [installmentForm] = Form.useForm();
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
|
||||
const [activeTab, setActiveTab] = useState<string>('all');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const fetchData = async () => {
|
||||
@@ -77,7 +61,7 @@ const DepositsPage: React.FC = () => {
|
||||
try {
|
||||
const [d, s]: any[] = await Promise.all([
|
||||
api.get('/deposits'),
|
||||
hasPermission('deposit:create') ? api.get('/deposits/student-lookups') : Promise.resolve([]),
|
||||
api.get('/deposits/student-lookups'),
|
||||
]);
|
||||
setData(d);
|
||||
setStudents(s);
|
||||
@@ -87,20 +71,11 @@ const DepositsPage: React.FC = () => {
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const fetchPendingRefunds = async () => {
|
||||
setPendingLoading(true);
|
||||
try {
|
||||
const res = await api.get<PendingRefund[]>('/deposits/pending-refunds');
|
||||
setPendingRefunds(res || []);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载失败,请稍后重试');
|
||||
}
|
||||
setPendingLoading(false);
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [hasPermission]);
|
||||
}, []);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
return data.filter((d: any) => {
|
||||
@@ -166,44 +141,6 @@ const DepositsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestRefund = async (record: any) => {
|
||||
try {
|
||||
await api.post(`/deposits/${record.id}/request-refund`);
|
||||
message.success('退款申请已提交');
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleApproveRefund = async (record: any) => {
|
||||
try {
|
||||
await api.put(`/deposits/${record.id}/approve-refund`);
|
||||
message.success('审批通过');
|
||||
fetchPendingRefunds();
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleRejectRefund = async () => {
|
||||
if (!rejectModal) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.put(`/deposits/${rejectModal.id}/reject-refund`, { reason: rejectReason || '未说明原因' });
|
||||
message.success('已驳回退款申请');
|
||||
setRejectModal(null);
|
||||
setRejectReason('');
|
||||
fetchPendingRefunds();
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '驳回失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
const handleAddInstallment = async () => {
|
||||
if (installmentModal == null) return;
|
||||
const values = await installmentForm.validateFields();
|
||||
@@ -254,7 +191,7 @@ const DepositsPage: React.FC = () => {
|
||||
render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '退款审批',
|
||||
title: '退款状态',
|
||||
dataIndex: 'refundStatus',
|
||||
render: (s: string) =>
|
||||
s ? <Tag color={refundStatusMap[s]?.color}>{refundStatusMap[s]?.text || s}</Tag> : '-',
|
||||
@@ -289,7 +226,7 @@ const DepositsPage: React.FC = () => {
|
||||
{record.status === 'paid' && !record.refundStatus && (
|
||||
<>
|
||||
<PermissionButton
|
||||
permission="deposit:edit"
|
||||
permission="deposit:refund"
|
||||
size="small"
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
@@ -299,13 +236,6 @@ const DepositsPage: React.FC = () => {
|
||||
>
|
||||
退还
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
permission="deposit:edit"
|
||||
size="small"
|
||||
onClick={() => handleRequestRefund(record)}
|
||||
>
|
||||
申请退款
|
||||
</PermissionButton>
|
||||
</>
|
||||
)}
|
||||
<Popconfirm
|
||||
@@ -332,141 +262,67 @@ const DepositsPage: React.FC = () => {
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
], [handleRequestRefund, fetchData]);
|
||||
], [fetchData]);
|
||||
|
||||
|
||||
const pendingColumns = [
|
||||
{ title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' },
|
||||
{ title: '押金金额', dataIndex: 'amount', width: 110, render: (v: number) => `¥${Number(v).toFixed(2)}` },
|
||||
{ title: '缴纳日期', dataIndex: 'paidDate', width: 110 },
|
||||
{
|
||||
title: '审批状态', width: 120,
|
||||
dataIndex: 'refundStatus',
|
||||
render: (s: string) => <Tag color={refundStatusMap[s]?.color}>{refundStatusMap[s]?.text || s}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '申请时间', width: 160,
|
||||
dataIndex: 'refundRequestedAt',
|
||||
render: (v: any) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-'),
|
||||
},
|
||||
{
|
||||
title: '操作', width: 200,
|
||||
render: (_: unknown, record: PendingRefund) => (
|
||||
<Space>
|
||||
<PermissionButton
|
||||
permission="deposit:approve"
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<CheckOutlined />}
|
||||
onClick={() => handleApproveRefund(record)}
|
||||
>
|
||||
审批通过
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
permission="deposit:approve"
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => {
|
||||
setRejectModal(record);
|
||||
setRejectReason('');
|
||||
}}
|
||||
>
|
||||
驳回
|
||||
</PermissionButton>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => {
|
||||
setActiveTab(key);
|
||||
if (key === 'pending') fetchPendingRefunds();
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Space wrap>
|
||||
<Input.Search
|
||||
placeholder="搜索学生姓名"
|
||||
allowClear
|
||||
style={{ width: 180 }}
|
||||
onSearch={(v) => setSearchText(v)}
|
||||
onChange={(e) => {
|
||||
if (!e.target.value) setSearchText('');
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
key: 'all',
|
||||
label: '押金列表',
|
||||
children: (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Space wrap>
|
||||
<Input.Search
|
||||
placeholder="搜索学生姓名"
|
||||
allowClear
|
||||
style={{ width: 180 }}
|
||||
onSearch={(v) => setSearchText(v)}
|
||||
onChange={(e) => {
|
||||
if (!e.target.value) setSearchText('');
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
placeholder="状态筛选"
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
value={filterStatus}
|
||||
onChange={(v) => setFilterStatus(v)}
|
||||
options={[
|
||||
{ value: 'paid', label: '已缴' },
|
||||
{ value: 'refunded', label: '已全退' },
|
||||
{ value: 'partial_refund', label: '部分退还' },
|
||||
{ value: 'deducted', label: '已全扣' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
<PermissionButton
|
||||
permission="deposit:create"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
createForm.resetFields();
|
||||
createForm.setFieldsValue({ amount: 500, paidDate: dayjs() });
|
||||
setCreateModal(true);
|
||||
}}
|
||||
>
|
||||
收取押金
|
||||
</PermissionButton>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={filteredData}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }}
|
||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
...(hasPermission('deposit:approve')
|
||||
? [{
|
||||
key: 'pending',
|
||||
label: '待审批退款',
|
||||
children: (
|
||||
<Table
|
||||
columns={pendingColumns}
|
||||
dataSource={pendingRefunds}
|
||||
rowKey="id"
|
||||
loading={pendingLoading}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }}
|
||||
/>
|
||||
),
|
||||
}]
|
||||
: []),
|
||||
/>
|
||||
<Select
|
||||
placeholder="状态筛选"
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
value={filterStatus}
|
||||
onChange={(v) => setFilterStatus(v)}
|
||||
options={[
|
||||
{ value: 'paid', label: '已缴' },
|
||||
{ value: 'refunded', label: '已全退' },
|
||||
{ value: 'partial_refund', label: '部分退还' },
|
||||
{ value: 'deducted', label: '已全扣' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
<PermissionButton
|
||||
permission="deposit:create"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
createForm.resetFields();
|
||||
createForm.setFieldsValue({ amount: 500, paidDate: dayjs() });
|
||||
setCreateModal(true);
|
||||
}}
|
||||
>
|
||||
收取押金
|
||||
</PermissionButton>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={filteredData}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }}
|
||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||
/>
|
||||
|
||||
{/* Create Modal */}
|
||||
<Modal
|
||||
@@ -556,7 +412,7 @@ const DepositsPage: React.FC = () => {
|
||||
</p>
|
||||
{detailModal.refundStatus && (
|
||||
<p>
|
||||
<strong>退款审批:</strong>{' '}
|
||||
<strong>退款状态:</strong>{' '}
|
||||
<Tag color={refundStatusMap[detailModal.refundStatus]?.color}>
|
||||
{refundStatusMap[detailModal.refundStatus]?.text || detailModal.refundStatus}
|
||||
</Tag>
|
||||
@@ -644,23 +500,7 @@ const DepositsPage: React.FC = () => {
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Reject Refund Modal */}
|
||||
<Modal
|
||||
title={`驳回退款申请 - ${rejectModal?.student?.name || ''}`}
|
||||
open={!!rejectModal}
|
||||
onOk={handleRejectRefund}
|
||||
onCancel={() => { setRejectModal(null); setRejectReason(''); }}
|
||||
okText="确认驳回"
|
||||
okButtonProps={{ danger: true }}
|
||||
confirmLoading={saving}
|
||||
>
|
||||
<Input.TextArea
|
||||
aria-label="驳回原因"
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import {
|
||||
SaveOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined,
|
||||
SyncOutlined, BankOutlined, UserOutlined,
|
||||
DeleteOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { DataNode } from 'antd/es/tree';
|
||||
import type { TreeSelectProps } from 'antd/es/tree-select';
|
||||
@@ -64,6 +65,28 @@ interface ImportResult {
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
interface DingTalkAttendanceGroup {
|
||||
group_id: number;
|
||||
group_name: string;
|
||||
type: string;
|
||||
member_count: number;
|
||||
}
|
||||
|
||||
interface AttendanceGroupResponse {
|
||||
success: boolean;
|
||||
data: DingTalkAttendanceGroup[];
|
||||
}
|
||||
|
||||
interface DeleteAttendanceGroupsResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
total: number;
|
||||
deleted: Array<{ groupId: number; groupName: string }>;
|
||||
failed: Array<{ groupId: number; groupName: string; error: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const IntegrationConfigPage: React.FC = () => {
|
||||
const { hasAllPermissions } = usePermission();
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -85,6 +108,10 @@ const IntegrationConfigPage: React.FC = () => {
|
||||
const [classes, setClasses] = useState<ClassItem[]>([]);
|
||||
const [classForm] = Form.useForm();
|
||||
const [classModalOpen, setClassModalOpen] = useState(false);
|
||||
const [attendanceGroups, setAttendanceGroups] = useState<DingTalkAttendanceGroup[]>([]);
|
||||
const [deleteGroupsOpen, setDeleteGroupsOpen] = useState(false);
|
||||
const [loadingGroups, setLoadingGroups] = useState(false);
|
||||
const [deletingGroups, setDeletingGroups] = useState(false);
|
||||
|
||||
|
||||
const fetchConfig = async () => {
|
||||
@@ -194,6 +221,7 @@ const IntegrationConfigPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const buildTreeData = useCallback((nodes: DingOrgTreeNodeExt[]): DataNode[] => {
|
||||
return nodes.map((node) => {
|
||||
const users = node.users ?? [];
|
||||
@@ -282,6 +310,41 @@ const IntegrationConfigPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const openDeleteAllGroups = async () => {
|
||||
setLoadingGroups(true);
|
||||
try {
|
||||
const response = await api.get<AttendanceGroupResponse>('/sync/dingtalk/attendance-groups');
|
||||
setAttendanceGroups(response.data);
|
||||
setDeleteGroupsOpen(true);
|
||||
} catch (error: unknown) {
|
||||
message.error(error instanceof Error ? error.message : '获取钉钉考勤组失败');
|
||||
} finally {
|
||||
setLoadingGroups(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAllGroups = async () => {
|
||||
setDeletingGroups(true);
|
||||
try {
|
||||
const response = await api.post<DeleteAttendanceGroupsResponse>(
|
||||
'/sync/dingtalk/attendance-groups/delete-all',
|
||||
);
|
||||
setDeleteGroupsOpen(false);
|
||||
setAttendanceGroups([]);
|
||||
if (response.data.failed.length > 0) {
|
||||
message.warning(
|
||||
`已删除 ${response.data.deleted.length} 个,失败 ${response.data.failed.length} 个`,
|
||||
);
|
||||
} else {
|
||||
message.success(`已删除钉钉全部 ${response.data.deleted.length} 个考勤组`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
message.error(error instanceof Error ? error.message : '删除钉钉考勤组失败');
|
||||
} finally {
|
||||
setDeletingGroups(false);
|
||||
}
|
||||
};
|
||||
|
||||
const syncTabItems = config && hasAllPermissions('sync:read', 'class:view', 'class:edit')
|
||||
? [
|
||||
{
|
||||
@@ -314,6 +377,15 @@ const IntegrationConfigPage: React.FC = () => {
|
||||
>
|
||||
获取组织架构
|
||||
</Button>
|
||||
<PermissionButton
|
||||
permission="sync:trigger"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={loadingGroups}
|
||||
onClick={openDeleteAllGroups}
|
||||
>
|
||||
删除钉钉全部考勤组
|
||||
</PermissionButton>
|
||||
</Space>
|
||||
|
||||
{drawerOpen && (
|
||||
@@ -414,6 +486,38 @@ const IntegrationConfigPage: React.FC = () => {
|
||||
</Modal>
|
||||
</Drawer>
|
||||
)}
|
||||
<Modal
|
||||
title="确认删除钉钉全部考勤组"
|
||||
open={deleteGroupsOpen}
|
||||
okText="确认全部删除"
|
||||
okButtonProps={{ danger: true, disabled: attendanceGroups.length === 0 }}
|
||||
cancelText="取消"
|
||||
confirmLoading={deletingGroups}
|
||||
onOk={deleteAllGroups}
|
||||
onCancel={() => setDeleteGroupsOpen(false)}
|
||||
>
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={`将永久删除钉钉上的 ${attendanceGroups.length} 个考勤组`}
|
||||
description="本地班级和排课不会删除。删除后需在排课管理中重新同步,才能重建考勤组。"
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
<List
|
||||
size="small"
|
||||
bordered
|
||||
dataSource={attendanceGroups}
|
||||
style={{ maxHeight: 280, overflow: 'auto' }}
|
||||
renderItem={(group) => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
title={group.group_name}
|
||||
description={`ID ${group.group_id} · ${group.member_count} 人`}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ import { UserOutlined, LockOutlined } from '@ant-design/icons';
|
||||
import api from '../../api';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { writePermissions } from '../../auth/permission-store';
|
||||
import { findFirstAccessiblePath } from '../../auth/permission-navigation';
|
||||
import { findRoleAwareLandingPath } from '../../auth/menu-policy';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
@@ -22,7 +22,7 @@ const LoginPage: React.FC = () => {
|
||||
const permissions = res.user.permissions || [];
|
||||
writePermissions(permissions);
|
||||
message.success('登录成功');
|
||||
navigate(findFirstAccessiblePath(permissions) || '/', { replace: true });
|
||||
navigate(findRoleAwareLandingPath(res.user.roles || [], permissions) || '/', { replace: true });
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '登录失败');
|
||||
} finally {
|
||||
|
||||
@@ -65,7 +65,7 @@ const OccupanciesPage: React.FC = () => {
|
||||
try {
|
||||
const [occRes, stuRes, rmRes, tnRes] = (await Promise.allSettled([
|
||||
api.get('/occupancies', { params: { active: showActive ? 'true' : undefined, dateFrom: dateRange?.[0]?.format('YYYY-MM-DD'), dateTo: dateRange?.[1]?.format('YYYY-MM-DD') } }),
|
||||
api.get('/students'),
|
||||
api.get('/students/basic-lookups'),
|
||||
api.get('/rooms/overview'),
|
||||
api.get('/organizations'),
|
||||
])) as PromiseSettledResult<any>[];
|
||||
|
||||
@@ -19,6 +19,10 @@ const PermissionsPage: React.FC = () => {
|
||||
const groupNames: Record<string, string> = {
|
||||
dashboard: '数据面板',
|
||||
student: '学生管理',
|
||||
'student-scope': '学生数据范围',
|
||||
teacher: '教师管理',
|
||||
'teacher-workspace': '教师工作台',
|
||||
'attendance-scope': '考勤数据范围',
|
||||
room: '宿舍管理',
|
||||
occupancy: '入住管理',
|
||||
expense: '费用管理',
|
||||
|
||||
@@ -41,12 +41,14 @@ import {
|
||||
scheduleToFormValues,
|
||||
type ScheduleFormValues,
|
||||
} from './schedule-form';
|
||||
import { filterSchedulesForClass, isMaskedSchedule } from './schedule-visibility';
|
||||
import { classifySyncResult } from './sync-result';
|
||||
|
||||
// ---- Types ----
|
||||
|
||||
interface ClassScheduleItem {
|
||||
id: number;
|
||||
classId: number;
|
||||
id: number | null;
|
||||
classId: number | null;
|
||||
classroomId: number;
|
||||
weekDay: number;
|
||||
startTime: string;
|
||||
@@ -60,6 +62,7 @@ interface ClassScheduleItem {
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
canViewDetails?: boolean;
|
||||
}
|
||||
|
||||
interface ClassroomItem {
|
||||
@@ -91,6 +94,9 @@ interface ScheduleSyncResult {
|
||||
groupCount: number;
|
||||
syncedItems: number;
|
||||
skippedNoMapping: number;
|
||||
failedBatchCount: number;
|
||||
failedItems: number;
|
||||
errors: string[];
|
||||
groups: Array<{ className: string; groupId: number; itemCount: number }>;
|
||||
}
|
||||
|
||||
@@ -144,6 +150,9 @@ const SchedulesPage: React.FC = () => {
|
||||
groupCount: number;
|
||||
syncedItems: number;
|
||||
skippedNoMapping: number;
|
||||
failedBatchCount: number;
|
||||
failedItems: number;
|
||||
errors: string[];
|
||||
groups: Array<{ className: string; groupId: number; itemCount: number }>;
|
||||
} | null>(null);
|
||||
const [syncDateFrom, setSyncDateFrom] = useState<Dayjs>(dayjs);
|
||||
@@ -180,7 +189,14 @@ const SchedulesPage: React.FC = () => {
|
||||
},
|
||||
});
|
||||
setSyncResult(res.data);
|
||||
message.success(`同步完成:${res.data.syncedItems} 条排班已写入钉钉`);
|
||||
const classification = classifySyncResult(res.data);
|
||||
if (classification.level === 'error') {
|
||||
message.error(classification.message);
|
||||
} else if (classification.level === 'warning') {
|
||||
message.warning(classification.message);
|
||||
} else {
|
||||
message.success(classification.message);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '同步失败');
|
||||
@@ -238,7 +254,10 @@ const SchedulesPage: React.FC = () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [lookups, schedulesRes] = await Promise.all([
|
||||
api.get('/class-schedules/lookups') as Promise<{ classrooms: ClassroomItem[]; classes: ClassItem[] }>,
|
||||
api.get('/class-schedules/lookups') as Promise<{
|
||||
classrooms: ClassroomItem[];
|
||||
classes: ClassItem[];
|
||||
}>,
|
||||
api.get('/class-schedules/weekly', {
|
||||
params: {
|
||||
startDate: startDateStr,
|
||||
@@ -289,7 +308,7 @@ const SchedulesPage: React.FC = () => {
|
||||
const classroomId = Number(cId);
|
||||
filtered[classroomId] = {};
|
||||
for (const [wd, schedules] of Object.entries(dayMap)) {
|
||||
const matched = schedules.filter((s) => s.classId === filterClassId);
|
||||
const matched = filterSchedulesForClass(schedules, filterClassId);
|
||||
if (matched.length > 0) {
|
||||
filtered[classroomId][Number(wd)] = matched;
|
||||
}
|
||||
@@ -422,19 +441,22 @@ const SchedulesPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const openEditSchedule = (schedule: ClassScheduleItem) => {
|
||||
if (isMaskedSchedule(schedule) || schedule.id === null || schedule.classId === null) return;
|
||||
if (schedule.scheduleType === 'RENTAL') {
|
||||
message.warning('租赁排课请在租赁订单中修改');
|
||||
return;
|
||||
}
|
||||
const editableSchedule = { ...schedule, id: schedule.id, classId: schedule.classId };
|
||||
setEditingSchedule(schedule);
|
||||
setModalMode('edit');
|
||||
form.setFieldsValue(scheduleToFormValues(schedule));
|
||||
void loadClassTeachers(schedule.classId);
|
||||
form.setFieldsValue(scheduleToFormValues(editableSchedule));
|
||||
void loadClassTeachers(editableSchedule.classId);
|
||||
};
|
||||
|
||||
// ---- Delete schedule ----
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
const handleDelete = async (id: number | null) => {
|
||||
if (id === null) return;
|
||||
try {
|
||||
await api.delete(`/class-schedules/${id}`);
|
||||
message.success('排课已删除');
|
||||
@@ -681,20 +703,31 @@ const SchedulesPage: React.FC = () => {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{schedules.map((s) => (
|
||||
<Tooltip
|
||||
key={s.id}
|
||||
title={`${s.subject} · ${s.startTime}-${s.endTime} · ${s.startDate}~${s.endDate}`}
|
||||
key={`${s.id ?? 'busy'}-${s.classroomId}-${s.weekDay}-${s.startTime}-${s.endTime}`}
|
||||
title={
|
||||
isMaskedSchedule(s)
|
||||
? `已占用 · ${s.startTime}-${s.endTime}`
|
||||
: `${s.subject} · ${s.startTime}-${s.endTime} · ${s.startDate}~${s.endDate}`
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: '#e6f4ff',
|
||||
border: '1px solid #91caff',
|
||||
background: isMaskedSchedule(s) ? '#f5f5f5' : '#e6f4ff',
|
||||
border: isMaskedSchedule(s)
|
||||
? '1px solid #d9d9d9'
|
||||
: '1px solid #91caff',
|
||||
borderRadius: 4,
|
||||
padding: '2px 6px',
|
||||
fontSize: 12,
|
||||
lineHeight: '18px',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, color: '#1677ff' }}>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
color: isMaskedSchedule(s) ? '#595959' : '#1677ff',
|
||||
}}
|
||||
>
|
||||
{s.subject}
|
||||
</div>
|
||||
<div style={{ color: '#595959' }}>
|
||||
@@ -973,7 +1006,7 @@ const SchedulesPage: React.FC = () => {
|
||||
) : (
|
||||
selectedSchedules.map((s) => (
|
||||
<Card
|
||||
key={s.id}
|
||||
key={`${s.id ?? 'busy'}-${s.classroomId}-${s.weekDay}-${s.startTime}-${s.endTime}`}
|
||||
size="small"
|
||||
style={{ marginBottom: 8 }}
|
||||
styles={{ body: { padding: 12 } }}
|
||||
@@ -987,14 +1020,16 @@ const SchedulesPage: React.FC = () => {
|
||||
>
|
||||
<div>
|
||||
<div>
|
||||
<strong>科目:</strong>
|
||||
<Tag color="blue">{s.subject}</Tag>
|
||||
<strong>{isMaskedSchedule(s) ? '状态:' : '科目:'}</strong>
|
||||
<Tag color={isMaskedSchedule(s) ? 'default' : 'blue'}>{s.subject}</Tag>
|
||||
</div>
|
||||
<div>
|
||||
<strong>班级:</strong>
|
||||
{classes.find((c) => c.id === s.classId)?.name || `#${s.classId}`}
|
||||
</div>
|
||||
{s.teacherId != null && (
|
||||
{!isMaskedSchedule(s) && (
|
||||
<div>
|
||||
<strong>班级:</strong>
|
||||
{classes.find((c) => c.id === s.classId)?.name || `#${s.classId}`}
|
||||
</div>
|
||||
)}
|
||||
{!isMaskedSchedule(s) && s.teacherId != null && (
|
||||
<div>
|
||||
<strong>教师:</strong>
|
||||
{classTeachers.find((u) => u.userId === s.teacherId)?.name ||
|
||||
@@ -1010,46 +1045,50 @@ const SchedulesPage: React.FC = () => {
|
||||
<strong>日期:</strong>
|
||||
{s.startDate} ~ {s.endDate}
|
||||
</div>
|
||||
{s.notes && (
|
||||
{!isMaskedSchedule(s) && s.notes && (
|
||||
<div>
|
||||
<strong>备注:</strong>
|
||||
{s.notes}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Tag color={s.scheduleType === 'RENTAL' ? 'orange' : 'green'}>
|
||||
{s.scheduleType === 'RENTAL' ? '租赁' : '内部'}
|
||||
</Tag>
|
||||
<Tag color={s.status === 'active' ? 'green' : 'default'}>{s.status}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<Space>
|
||||
{s.scheduleType !== 'RENTAL' && (
|
||||
<PermissionButton
|
||||
permission="schedule:edit"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEditSchedule(s)}
|
||||
>
|
||||
编辑
|
||||
</PermissionButton>
|
||||
{!isMaskedSchedule(s) && (
|
||||
<div>
|
||||
<Tag color={s.scheduleType === 'RENTAL' ? 'orange' : 'green'}>
|
||||
{s.scheduleType === 'RENTAL' ? '租赁' : '内部'}
|
||||
</Tag>
|
||||
<Tag color={s.status === 'active' ? 'green' : 'default'}>{s.status}</Tag>
|
||||
</div>
|
||||
)}
|
||||
<Popconfirm
|
||||
title="确认删除该排课?"
|
||||
onConfirm={() => handleDelete(s.id)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<PermissionButton
|
||||
permission="schedule:delete"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
</div>
|
||||
{!isMaskedSchedule(s) && (
|
||||
<Space>
|
||||
{s.scheduleType !== 'RENTAL' && (
|
||||
<PermissionButton
|
||||
permission="schedule:edit"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEditSchedule(s)}
|
||||
>
|
||||
编辑
|
||||
</PermissionButton>
|
||||
)}
|
||||
<Popconfirm
|
||||
title="确认删除该排课?"
|
||||
onConfirm={() => handleDelete(s.id)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
删除
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
<PermissionButton
|
||||
permission="schedule:delete"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
>
|
||||
删除
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
@@ -1133,6 +1172,30 @@ const SchedulesPage: React.FC = () => {
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
{syncResult.failedBatchCount > 0 && (
|
||||
<>
|
||||
<Alert
|
||||
type="error"
|
||||
message={`${syncResult.failedBatchCount} 批写入失败,共 ${syncResult.failedItems} 条`}
|
||||
description={
|
||||
syncResult.errors.length > 0
|
||||
? syncResult.errors.slice(0, 5).map((err, i) => (
|
||||
<div key={i} style={{ wordBreak: 'break-all' }}>
|
||||
{err}
|
||||
</div>
|
||||
))
|
||||
: undefined
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
showIcon
|
||||
/>
|
||||
{syncResult.errors.length > 5 && (
|
||||
<div style={{ fontSize: 12, color: '#999', marginBottom: 16, marginTop: -12 }}>
|
||||
...以及其他 {syncResult.errors.length - 5} 条错误
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{syncResult.groups.length > 0 && (
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: 8 }}>按班级分组:</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface ScheduleFormValues {
|
||||
}
|
||||
|
||||
export interface EditableSchedule {
|
||||
id: number;
|
||||
id?: number;
|
||||
classId: number;
|
||||
classroomId: number;
|
||||
weekDay: number;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
filterSchedulesForClass,
|
||||
isMaskedSchedule,
|
||||
type VisibleSchedule,
|
||||
} from './schedule-visibility';
|
||||
|
||||
const ownSchedule: VisibleSchedule = {
|
||||
id: 1,
|
||||
classId: 8,
|
||||
canViewDetails: true,
|
||||
};
|
||||
const otherClassBusyBlock: VisibleSchedule = {
|
||||
id: null,
|
||||
classId: null,
|
||||
canViewDetails: false,
|
||||
};
|
||||
const anotherOwnVisibleSchedule: VisibleSchedule = {
|
||||
id: 2,
|
||||
classId: 9,
|
||||
canViewDetails: true,
|
||||
};
|
||||
|
||||
describe('schedule visibility helpers', () => {
|
||||
it('keeps masked shared-room occupancy visible while filtering to one class', () => {
|
||||
expect(
|
||||
filterSchedulesForClass([ownSchedule, otherClassBusyBlock, anotherOwnVisibleSchedule], 8),
|
||||
).toEqual([ownSchedule, otherClassBusyBlock]);
|
||||
});
|
||||
|
||||
it('keeps a rental schedule with explicit detail access unmasked', () => {
|
||||
expect(isMaskedSchedule({ id: 3, classId: null, canViewDetails: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('treats nullable IDs as masked for responses without an explicit detail flag', () => {
|
||||
expect(isMaskedSchedule({ id: null, classId: null })).toBe(true);
|
||||
expect(isMaskedSchedule(ownSchedule)).toBe(false);
|
||||
});
|
||||
});
|
||||
19
apps/admin/src/pages/Schedules/schedule-visibility.ts
Normal file
19
apps/admin/src/pages/Schedules/schedule-visibility.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export interface VisibleSchedule {
|
||||
id: number | null;
|
||||
classId: number | null;
|
||||
canViewDetails?: boolean;
|
||||
}
|
||||
|
||||
export const isMaskedSchedule = (
|
||||
schedule: Pick<VisibleSchedule, 'id' | 'classId' | 'canViewDetails'>,
|
||||
) =>
|
||||
schedule.canViewDetails === false ||
|
||||
(schedule.canViewDetails === undefined && (schedule.id === null || schedule.classId === null));
|
||||
|
||||
export const filterSchedulesForClass = <T extends VisibleSchedule>(
|
||||
schedules: T[],
|
||||
classId?: number,
|
||||
): T[] => {
|
||||
if (classId == null) return schedules;
|
||||
return schedules.filter((schedule) => isMaskedSchedule(schedule) || schedule.classId === classId);
|
||||
};
|
||||
125
apps/admin/src/pages/Schedules/sync-result.integration.test.ts
Normal file
125
apps/admin/src/pages/Schedules/sync-result.integration.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { classifySyncResult, errorSummary } from './sync-result';
|
||||
|
||||
describe('classifySyncResult', () => {
|
||||
const baseResult = {
|
||||
scheduleCount: 10,
|
||||
syncedItems: 50,
|
||||
skippedNoMapping: 0,
|
||||
failedBatchCount: 0,
|
||||
failedItems: 0,
|
||||
errors: [] as string[],
|
||||
};
|
||||
|
||||
it('returns success when no failures', () => {
|
||||
const result = classifySyncResult(baseResult);
|
||||
expect(result.hasFailure).toBe(false);
|
||||
expect(result.level).toBe('success');
|
||||
expect(result.message).toContain('同步完成');
|
||||
expect(result.message).toContain('50');
|
||||
});
|
||||
|
||||
it('returns warning when partial failure (some synced, some failed)', () => {
|
||||
const result = classifySyncResult({
|
||||
...baseResult,
|
||||
syncedItems: 30,
|
||||
failedBatchCount: 2,
|
||||
failedItems: 20,
|
||||
errors: ['err1', 'err2'],
|
||||
});
|
||||
expect(result.hasFailure).toBe(true);
|
||||
expect(result.level).toBe('warning');
|
||||
expect(result.message).toContain('部分失败');
|
||||
expect(result.message).toContain('30');
|
||||
expect(result.message).toContain('20');
|
||||
});
|
||||
|
||||
it('returns error when total failure (nothing synced)', () => {
|
||||
const result = classifySyncResult({
|
||||
...baseResult,
|
||||
syncedItems: 0,
|
||||
failedBatchCount: 1,
|
||||
failedItems: 50,
|
||||
errors: ['全部失败'],
|
||||
});
|
||||
expect(result.hasFailure).toBe(true);
|
||||
expect(result.level).toBe('error');
|
||||
expect(result.message).toContain('全部写入失败');
|
||||
expect(result.message).toContain('50');
|
||||
});
|
||||
|
||||
it('classifies attendance group failure as failure, not success', () => {
|
||||
const result = classifySyncResult({
|
||||
...baseResult,
|
||||
syncedItems: 45,
|
||||
failedBatchCount: 1,
|
||||
failedItems: 5,
|
||||
errors: ['考勤组 排课_强化班 创建/更新失败: permission denied'],
|
||||
});
|
||||
expect(result.hasFailure).toBe(true);
|
||||
expect(result.level).toBe('warning');
|
||||
expect(result.message).toContain('部分失败');
|
||||
// must NOT be success level
|
||||
expect(result.level).not.toBe('success');
|
||||
});
|
||||
|
||||
it('returns error when all shifts failed (errors present, nothing synced)', () => {
|
||||
const result = classifySyncResult({
|
||||
...baseResult,
|
||||
syncedItems: 0,
|
||||
failedBatchCount: 2,
|
||||
failedItems: 10,
|
||||
errors: [
|
||||
'创建班次 排课_09:00-11:00 失败: permission denied',
|
||||
'创建班次 排课_14:00-16:00 失败: permission denied',
|
||||
],
|
||||
});
|
||||
expect(result.hasFailure).toBe(true);
|
||||
expect(result.level).toBe('error');
|
||||
expect(result.message).toContain('全部写入失败');
|
||||
});
|
||||
|
||||
it('returns warning when errors-only with some syncedItems (no failedBatchCount)', () => {
|
||||
// Edge case: errors from shift creation that don't bump failedBatchCount
|
||||
// but do leave errors[] non-empty, yet some items synced from other classes
|
||||
const result = classifySyncResult({
|
||||
...baseResult,
|
||||
syncedItems: 30,
|
||||
failedBatchCount: 0,
|
||||
failedItems: 0,
|
||||
errors: ['创建班次 排课_09:00-11:00 失败: network error'],
|
||||
});
|
||||
expect(result.hasFailure).toBe(true);
|
||||
expect(result.level).toBe('warning');
|
||||
expect(result.message).toContain('部分失败');
|
||||
});
|
||||
|
||||
it('returns error when errors-only with zero syncedItems (no failedBatchCount)', () => {
|
||||
const result = classifySyncResult({
|
||||
...baseResult,
|
||||
syncedItems: 0,
|
||||
failedBatchCount: 0,
|
||||
failedItems: 0,
|
||||
errors: ['创建班次 排课_09:00-11:00 失败: permission denied'],
|
||||
});
|
||||
expect(result.hasFailure).toBe(true);
|
||||
expect(result.level).toBe('error');
|
||||
expect(result.message).toContain('全部写入失败');
|
||||
});
|
||||
});
|
||||
|
||||
describe('errorSummary', () => {
|
||||
it('returns all errors when 5 or fewer', () => {
|
||||
const errors = ['e1', 'e2', 'e3'];
|
||||
expect(errorSummary(errors)).toEqual(['e1', 'e2', 'e3']);
|
||||
});
|
||||
|
||||
it('returns first 5 when more than 5', () => {
|
||||
const errors = ['e1', 'e2', 'e3', 'e4', 'e5', 'e6', 'e7'];
|
||||
expect(errorSummary(errors)).toEqual(['e1', 'e2', 'e3', 'e4', 'e5']);
|
||||
});
|
||||
|
||||
it('returns empty for empty errors', () => {
|
||||
expect(errorSummary([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
58
apps/admin/src/pages/Schedules/sync-result.ts
Normal file
58
apps/admin/src/pages/Schedules/sync-result.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/** 同步结果分类 — 从组件中提取以便测试。 */
|
||||
|
||||
export interface SyncResultInput {
|
||||
scheduleCount: number;
|
||||
syncedItems: number;
|
||||
skippedNoMapping: number;
|
||||
failedBatchCount: number;
|
||||
failedItems: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface SyncResultClassification {
|
||||
/** 是否有任何失败 */
|
||||
hasFailure: boolean;
|
||||
/** 通知级别:'success' | 'warning' | 'error' */
|
||||
level: 'success' | 'warning' | 'error';
|
||||
/** 适合展示给用户的消息文本 */
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将同步结果分类为成功/部分失败/全部失败。
|
||||
* - (failedBatchCount > 0 || errors 非空) 且 syncedItems > 0 → warning(部分失败)
|
||||
* - (failedBatchCount > 0 || errors 非空) 且 syncedItems === 0 → error(全部失败)
|
||||
* - 否则 → success
|
||||
*/
|
||||
export function classifySyncResult(result: SyncResultInput): SyncResultClassification {
|
||||
const hasFailure = result.failedBatchCount > 0 || result.errors.length > 0;
|
||||
|
||||
if (!hasFailure) {
|
||||
return {
|
||||
hasFailure: false,
|
||||
level: 'success',
|
||||
message: `同步完成:${result.syncedItems} 条排班已写入钉钉`,
|
||||
};
|
||||
}
|
||||
|
||||
if (result.syncedItems > 0) {
|
||||
return {
|
||||
hasFailure: true,
|
||||
level: 'warning',
|
||||
message: `同步部分失败:${result.syncedItems} 条成功,${result.failedItems} 条失败`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hasFailure: true,
|
||||
level: 'error',
|
||||
message: `同步失败:${result.failedItems} 条全部写入失败`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 从错误列表中提取摘要(最多 5 条)。
|
||||
*/
|
||||
export function errorSummary(errors: string[]): string[] {
|
||||
return errors.slice(0, 5);
|
||||
}
|
||||
@@ -28,8 +28,12 @@ interface ProfileFormValues {
|
||||
}
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
super_admin: '超管',
|
||||
teacher: '老师',
|
||||
super_admin: '超级管理员',
|
||||
teacher: '任课老师',
|
||||
academic: '教务管理员',
|
||||
accommodation_operations: '住宿运营管理员',
|
||||
classroom_operations: '教室运营管理员',
|
||||
system_admin: '系统管理员',
|
||||
class_teacher: '班主任',
|
||||
dormitory_supervisor: '宿管',
|
||||
institution_head: '机构负责人',
|
||||
|
||||
Reference in New Issue
Block a user