}
onClick={() => {
+ setEditingSchedule(null);
setModalMode('create');
form.resetFields();
+ form.setFieldsValue({
+ classroomId: selectedCell?.classroomId,
+ weekDay:
+ selectedCell?.weekDay ?? (selectedDate ? selectedDate.day() || 7 : undefined),
+ dateRange: selectedDate ? [selectedDate, selectedDate] : undefined,
+ });
}}
>
新增排课
@@ -877,7 +976,13 @@ const SchedulesPage: React.FC = () => {
style={{ marginBottom: 8 }}
styles={{ body: { padding: 12 } }}
>
-
+
科目:
@@ -890,9 +995,9 @@ const SchedulesPage: React.FC = () => {
{s.teacherId != null && (
教师:
- {users.find((u) => u.id === s.teacherId)?.name
- || users.find((u) => u.id === s.teacherId)?.username
- || `#${s.teacherId}`}
+ {classTeachers.find((u) => u.userId === s.teacherId)?.name ||
+ classTeachers.find((u) => u.userId === s.teacherId)?.username ||
+ `#${s.teacherId}`}
)}
@@ -916,21 +1021,33 @@ const SchedulesPage: React.FC = () => {
{s.status}
-
handleDelete(s.id)}
- okText="删除"
- cancelText="取消"
- >
- }
+
+ {s.scheduleType !== 'RENTAL' && (
+ }
+ onClick={() => openEditSchedule(s)}
+ >
+ 编辑
+
+ )}
+ handleDelete(s.id)}
+ okText="删除"
+ cancelText="取消"
>
- 删除
-
-
+ }
+ >
+ 删除
+
+
+
))
@@ -943,22 +1060,45 @@ const SchedulesPage: React.FC = () => {
{ setSyncModalOpen(false); setSyncResult(null); }}
- footer={syncResult ? [
- ,
- ] : [
- ,
- }
- loading={syncing}
- onClick={handleSyncSchedule}
- disabled={!syncStatus || syncStatus.activeSchedules === 0}
- >
- 开始同步
- ,
- ]}
+ onCancel={() => {
+ setSyncModalOpen(false);
+ setSyncResult(null);
+ }}
+ footer={
+ syncResult
+ ? [
+ ,
+ ]
+ : [
+ ,
+ }
+ loading={syncing}
+ onClick={handleSyncSchedule}
+ disabled={!syncStatus || syncStatus.activeSchedules === 0}
+ >
+ 开始同步
+ ,
+ ]
+ }
width={560}
>
{syncResult ? (
@@ -1014,11 +1154,18 @@ const SchedulesPage: React.FC = () => {
title="已就绪班级"
value={syncStatus.mappedClasses}
suffix={`/ ${syncStatus.totalClasses}`}
- valueStyle={{ color: syncStatus.mappedClasses < syncStatus.totalClasses ? '#faad14' : '#3f8600' }}
+ valueStyle={{
+ color:
+ syncStatus.mappedClasses < syncStatus.totalClasses ? '#faad14' : '#3f8600',
+ }}
/>
-
+
{syncStatus.mappedClasses < syncStatus.totalClasses && (
@@ -1031,9 +1178,13 @@ const SchedulesPage: React.FC = () => {
)}
同步参数
-
+
起始日期:
- d && setSyncDateFrom(d)} allowClear={false} />
+ d && setSyncDateFrom(d)}
+ allowClear={false}
+ />
天数:
+
+
+
+
+
仅允许考勤机打卡
+
+ 开启后将关闭外勤、定位、Wi-Fi 和手机蓝牙打卡,并禁止无排班打卡。
+
+
+
+
+ {attendanceMachineOnly && (
+
+ )}
{syncStatus.activeSchedules === 0 && (
-
+
)}
) : (
diff --git a/apps/admin/src/pages/Schedules/schedule-form.integration.test.ts b/apps/admin/src/pages/Schedules/schedule-form.integration.test.ts
new file mode 100644
index 0000000..d87ec6e
--- /dev/null
+++ b/apps/admin/src/pages/Schedules/schedule-form.integration.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from 'vitest';
+import dayjs from 'dayjs';
+import { buildSchedulePayload, scheduleToFormValues } from './schedule-form';
+
+describe('schedule edit form mapping', () => {
+ it('fills an existing schedule into editable form values', () => {
+ const values = scheduleToFormValues({
+ id: 3,
+ classId: 1,
+ classroomId: 1,
+ weekDay: 5,
+ subject: '语文',
+ teacherId: null,
+ startTime: '14:00',
+ endTime: '18:00',
+ startDate: '2026-07-01',
+ endDate: '2026-07-31',
+ });
+
+ expect(values.classroomId).toBe(1);
+ expect(values.weekDay).toBe(5);
+ expect(values.timeRange.map((item) => item.format('HH:mm'))).toEqual(['14:00', '18:00']);
+ expect(values.dateRange.map((item) => item.format('YYYY-MM-DD'))).toEqual([
+ '2026-07-01',
+ '2026-07-31',
+ ]);
+ });
+
+ it('builds the update payload from edited form values', () => {
+ expect(
+ buildSchedulePayload({
+ classId: 1,
+ classroomId: 2,
+ weekDay: 6,
+ subject: '作文',
+ teacherId: 4,
+ timeRange: [dayjs('2026-01-01 13:30'), dayjs('2026-01-01 17:20')],
+ dateRange: [dayjs('2026-08-01'), dayjs('2026-08-31')],
+ }),
+ ).toEqual({
+ classId: 1,
+ classroomId: 2,
+ weekDay: 6,
+ subject: '作文',
+ teacherId: 4,
+ startTime: '13:30',
+ endTime: '17:20',
+ startDate: '2026-08-01',
+ endDate: '2026-08-31',
+ });
+ });
+});
diff --git a/apps/admin/src/pages/Schedules/schedule-form.ts b/apps/admin/src/pages/Schedules/schedule-form.ts
new file mode 100644
index 0000000..d72cb41
--- /dev/null
+++ b/apps/admin/src/pages/Schedules/schedule-form.ts
@@ -0,0 +1,46 @@
+import dayjs, { type Dayjs } from 'dayjs';
+
+export interface ScheduleFormValues {
+ classId: number;
+ classroomId: number;
+ weekDay: number;
+ subject: string;
+ teacherId?: number;
+ timeRange: [Dayjs, Dayjs];
+ dateRange: [Dayjs, Dayjs];
+}
+
+export interface EditableSchedule {
+ id: number;
+ classId: number;
+ classroomId: number;
+ weekDay: number;
+ subject: string;
+ teacherId: number | null;
+ startTime: string;
+ endTime: string;
+ startDate: string;
+ endDate: string;
+}
+
+export const scheduleToFormValues = (schedule: EditableSchedule): ScheduleFormValues => ({
+ classId: schedule.classId,
+ classroomId: schedule.classroomId,
+ weekDay: schedule.weekDay,
+ subject: schedule.subject,
+ teacherId: schedule.teacherId ?? undefined,
+ timeRange: [dayjs(`2000-01-01 ${schedule.startTime}`), dayjs(`2000-01-01 ${schedule.endTime}`)],
+ dateRange: [dayjs(schedule.startDate), dayjs(schedule.endDate)],
+});
+
+export const buildSchedulePayload = (values: ScheduleFormValues) => ({
+ classId: values.classId,
+ classroomId: values.classroomId,
+ weekDay: values.weekDay,
+ subject: values.subject,
+ teacherId: values.teacherId,
+ startTime: values.timeRange[0].format('HH:mm'),
+ endTime: values.timeRange[1].format('HH:mm'),
+ startDate: values.dateRange[0].format('YYYY-MM-DD'),
+ endDate: values.dateRange[1].format('YYYY-MM-DD'),
+});
diff --git a/apps/admin/src/pages/Students/index.tsx b/apps/admin/src/pages/Students/index.tsx
index 752bd89..f2baf6c 100644
--- a/apps/admin/src/pages/Students/index.tsx
+++ b/apps/admin/src/pages/Students/index.tsx
@@ -1,38 +1,40 @@
-import React, { useEffect, useState, useMemo, useCallback } from 'react';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
- Table,
+ App,
Button,
- Modal,
+ Card,
+ Col,
+ Descriptions,
+ Drawer,
+ Empty,
Form,
Input,
+ Modal,
+ Popconfirm,
+ Row,
Select,
Space,
- message,
+ Table,
Tag,
- Popconfirm,
Upload,
- App,
- Row,
- Col,
- Card,
- Drawer,
- Descriptions,
- Empty,
} from 'antd';
+import type { UploadProps } from 'antd';
import {
- PlusOutlined,
- UploadOutlined,
- DownloadOutlined,
- UndoOutlined,
- InboxOutlined,
- ExportOutlined,
DeleteOutlined,
+ DownloadOutlined,
+ ExportOutlined,
EyeOutlined,
+ InboxOutlined,
+ PlusOutlined,
+ SwapOutlined,
+ UndoOutlined,
+ UploadOutlined,
} from '@ant-design/icons';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
import StudentProfileContent from '../../components/StudentProfileContent';
-import { maskPhone, maskIdNumber } from '../../utils/sensitive';
+import { maskIdNumber, maskPhone } from '../../utils/sensitive';
+import { message } from '../../ui/app-message';
const statusMap: Record
= {
active: { text: '在读', color: 'green' },
@@ -129,10 +131,13 @@ const StudentsPage: React.FC = () => {
const fetchData = useCallback(async () => {
setLoading(true);
try {
- const params: Record = { name: searchName || undefined, includeArchived: 'true' };
+ const params: Record = {
+ name: searchName || undefined,
+ includeArchived: 'true',
+ };
if (filterStatus) params.status = filterStatus;
if (filterTenantId) params.tenantId = filterTenantId;
- const res = await api.get('/students', { params }) as Array>;
+ const res = (await api.get('/students', { params })) as Array>;
const list = res as Array>;
const archived = list.filter((r) => r.status === 'archived');
setArchivedCount(archived.length);
@@ -149,9 +154,12 @@ const StudentsPage: React.FC = () => {
}, [fetchData]);
useEffect(() => {
- api.get('/tenants', { params: { includeArchived: 'false' } }).then((res: unknown) => {
- setTenants(res as Array<{ id: number; name: string }>);
- }).catch(() => {});
+ api
+ .get('/tenants', { params: { includeArchived: 'false' } })
+ .then((res: unknown) => {
+ setTenants(res as Array<{ id: number; name: string }>);
+ })
+ .catch(() => {});
}, []);
const handleSave = async () => {
const values = await form.validateFields();
@@ -213,6 +221,23 @@ const StudentsPage: React.FC = () => {
.catch(() => message.error('下载失败'));
};
+ const handleMatchImport: UploadProps['customRequest'] = async ({ file, onSuccess, onError }) => {
+ const formData = new FormData();
+ formData.append('file', file as File);
+ try {
+ const res = (await api.post('/students/import-match', formData, {
+ headers: { 'Content-Type': 'multipart/form-data' },
+ })) as { message: string };
+ message.success(res.message);
+ onSuccess?.(res);
+ fetchData();
+ } catch (e: unknown) {
+ const err = e as { message?: string };
+ message.error(err?.message || '匹配导入失败');
+ onError?.(e instanceof Error ? e : new Error(err?.message || '匹配导入失败'));
+ }
+ };
+
const handleExport = () => {
const baseURL = import.meta.env.PROD
? '/api'
@@ -232,128 +257,178 @@ const StudentsPage: React.FC = () => {
.catch(() => message.error('导出失败'));
};
- const columns = useMemo(() => [
- { title: 'ID', dataIndex: 'id', width: 70 },
- {
- title: '姓名',
- dataIndex: 'name',
- width: 120,
- render: (v: string, record: any) => (
-
- ),
- },
- {
- title: '电话',
- dataIndex: 'phone',
- width: 140,
- render: (v: string, record: any) => {
- if (!v) return '-';
- return (
-
- {maskPhone(v)}
-
-
- );
+ const columns = useMemo(
+ () => [
+ { title: 'ID', dataIndex: 'id', width: 70 },
+ {
+ title: '姓名',
+ dataIndex: 'name',
+ width: 120,
+ render: (v: string, record: any) => (
+
+ ),
},
- },
- {
- title: '学号',
- dataIndex: 'studentNo',
- width: 120,
- render: (v: string) => v || '-',
- },
- {
- title: '身份证',
- dataIndex: 'idNumber',
- width: 180,
- render: (v: string, record: any) => {
- if (!v) return '-';
- return (
-
- {maskIdNumber(v)}
-
-
- );
- },
- },
- { title: '民族', dataIndex: 'ethnicity', width: 90 },
- { title: '紧急联系人', dataIndex: 'emergencyContact', width: 100 },
- { title: '紧急联系人电话', dataIndex: 'emergencyPhone', width: 130 },
- {
- title: '所属机构',
- dataIndex: 'tenant',
- width: 100,
- render: (tenant: { name?: string } | null) =>
- tenant?.name ? {tenant.name} : '-',
- },
- { title: '负责人', dataIndex: 'supervisor', width: 100 },
- {
- title: '状态',
- dataIndex: 'status',
- width: 80,
- render: (s: string) => {statusMap[s]?.text || s},
- },
- {
- title: '操作',
- width: 180,
- render: (_: any, record: any) => (
-
- {record.status === 'archived' ? (
- handleRestore(record.id)}
- okText="恢复"
- cancelText="取消"
- >
- } type="link">
- 恢复
-
-
- ) : (
- <>
- {
+ if (!v) return '-';
+ return (
+
+ {maskPhone(v)}
+
- {
- setEditing(record);
- form.setFieldsValue(record);
- setModalOpen(true);
- }}
+ style={{ padding: '8px 4px' }}
+ onClick={() => handleViewSensitive(record.id, '电话', v)}
+ title="点击查看完整号码"
>
- 编辑
-
+
+
+
+ );
+ },
+ },
+ {
+ title: '学号',
+ dataIndex: 'studentNo',
+ width: 120,
+ render: (v: string) => v || '-',
+ },
+ {
+ title: '身份证',
+ dataIndex: 'idNumber',
+ width: 180,
+ render: (v: string, record: any) => {
+ if (!v) return '-';
+ return (
+
+ {maskIdNumber(v)}
+
+
+ );
+ },
+ },
+ { title: '民族', dataIndex: 'ethnicity', width: 90 },
+ { title: '紧急联系人', dataIndex: 'emergencyContact', width: 100 },
+ { title: '紧急联系人电话', dataIndex: 'emergencyPhone', width: 130 },
+ {
+ title: '所属机构',
+ dataIndex: 'tenant',
+ width: 100,
+ render: (tenant: { name?: string } | null) =>
+ tenant?.name ? (
+
+ {tenant.name}
+
+ ) : (
+ '-'
+ ),
+ },
+ { title: '负责人', dataIndex: 'supervisor', width: 100 },
+ {
+ title: '状态',
+ dataIndex: 'status',
+ width: 80,
+ render: (s: string) => (
+
+ {statusMap[s]?.text || s}
+
+ ),
+ },
+ {
+ title: '操作',
+ width: 180,
+ render: (_: any, record: any) => (
+
+ {record.status === 'archived' ? (
handleArchive(record.id)}
- okText="归档"
+ title="确定恢复此学生?恢复后将重新出现在学生列表中。"
+ onConfirm={() => handleRestore(record.id)}
+ okText="恢复"
cancelText="取消"
>
- }>
- 归档
+ }
+ type="link"
+ >
+ 恢复
- >
- )}
-
- ),
- },
- ], [handleViewSensitive, openDrawer, showArchived, tenants]);
+ ) : (
+ <>
+ openDrawer(record.id)}
+ >
+ 档案
+
+ {
+ setEditing(record);
+ form.setFieldsValue(record);
+ setModalOpen(true);
+ }}
+ >
+ 编辑
+
+ handleArchive(record.id)}
+ okText="归档"
+ cancelText="取消"
+ >
+ }
+ >
+ 归档
+
+
+ >
+ )}
+
+ ),
+ },
+ ],
+ [handleViewSensitive, openDrawer, showArchived, tenants],
+ );
return (
-
+
{
allowClear
style={{ width: 250 }}
/>
-