forked from wangziqi/gongxue-base
fix permissions and teacher attendance workflows
This commit is contained in:
@@ -1,7 +1,20 @@
|
||||
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Table, Button, Modal, Form, DatePicker, Select, Space, message,
|
||||
Tag, Card, Input, Tooltip, Row, Col, Tabs, Alert,
|
||||
Table,
|
||||
Button,
|
||||
Modal,
|
||||
Form,
|
||||
DatePicker,
|
||||
Select,
|
||||
Space,
|
||||
Tag,
|
||||
Card,
|
||||
Input,
|
||||
Tooltip,
|
||||
Row,
|
||||
Col,
|
||||
Tabs,
|
||||
Alert,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
@@ -13,6 +26,8 @@ import {
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
@@ -51,7 +66,6 @@ const SOURCE_OPTIONS = [
|
||||
{ value: 'dingtalk', label: '钉钉导入' },
|
||||
];
|
||||
|
||||
|
||||
const MATCH_STATUS_MAP: Record<string, { text: string; color: string }> = {
|
||||
unmatched: { text: '未处理', color: 'default' },
|
||||
pending: { text: '待匹配', color: 'orange' },
|
||||
@@ -98,16 +112,28 @@ interface BatchRecordInput {
|
||||
interface DingRecord {
|
||||
id: number;
|
||||
dingUserId: string;
|
||||
checkTime: string;
|
||||
rawStatus: string;
|
||||
attendanceDate: string;
|
||||
checkInTime?: string;
|
||||
checkOutTime?: string;
|
||||
timeResult: string;
|
||||
matchStatus: string;
|
||||
studentId?: number;
|
||||
}
|
||||
interface AlertItem { studentId: number; studentName: string; className: string; type: string; count: number; lastDate: string }
|
||||
interface AlertItem {
|
||||
studentId: number;
|
||||
studentName: string;
|
||||
className: string;
|
||||
type: string;
|
||||
count: number;
|
||||
lastDate: string;
|
||||
}
|
||||
|
||||
// ── Component ──
|
||||
|
||||
const AttendancePage: React.FC = () => {
|
||||
const { hasAnyPermission } = usePermission();
|
||||
const canManageAllAttendance = hasAnyPermission('class:edit', 'attendance:edit');
|
||||
|
||||
// ── State ──
|
||||
const [records, setRecords] = useState<AttendanceRecordItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -127,7 +153,11 @@ const AttendancePage: React.FC = () => {
|
||||
// View toggle
|
||||
const [calendarView, setCalendarView] = useState(false);
|
||||
const [calendarData, setCalendarData] = useState<
|
||||
{ studentId: number; studentName: string; days: { date: string; session: string; status: string }[] }[]
|
||||
{
|
||||
studentId: number;
|
||||
studentName: string;
|
||||
days: { date: string; session: string; status: string }[];
|
||||
}[]
|
||||
>([]);
|
||||
const [calendarLoading, setCalendarLoading] = useState(false);
|
||||
|
||||
@@ -143,8 +173,9 @@ const AttendancePage: React.FC = () => {
|
||||
const [dingMatchStatus, setDingMatchStatus] = useState<string | undefined>(undefined);
|
||||
// DingTalk import
|
||||
const [importModalOpen, setImportModalOpen] = useState(false);
|
||||
const [importDateRange, setImportDateRange] = useState<[Dayjs, Dayjs] | null>(null);
|
||||
const [importAutoMatch, setImportAutoMatch] = useState(true);
|
||||
const [importClassId, setImportClassId] = useState<number | undefined>(undefined);
|
||||
const [importClassOptions, setImportClassOptions] = useState<ClassOption[]>([]);
|
||||
const [importDateRange, setImportDateRange] = useState<[Dayjs, Dayjs] | null>([dayjs(), dayjs()]);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importProgressMsg, setImportProgressMsg] = useState('');
|
||||
|
||||
@@ -152,7 +183,9 @@ const AttendancePage: React.FC = () => {
|
||||
const [matchModalOpen, setMatchModalOpen] = useState(false);
|
||||
const [matchRecordId, setMatchRecordId] = useState<number | null>(null);
|
||||
const [matchStudentSearch, setMatchStudentSearch] = useState('');
|
||||
const [matchStudentResults, setMatchStudentResults] = useState<{ id: number; name: string }[]>([]);
|
||||
const [matchStudentResults, setMatchStudentResults] = useState<{ id: number; name: string }[]>(
|
||||
[],
|
||||
);
|
||||
const [matchStudentLoading, setMatchStudentLoading] = useState(false);
|
||||
const [matchSubmitting, setMatchSubmitting] = useState(false);
|
||||
|
||||
@@ -165,7 +198,9 @@ const AttendancePage: React.FC = () => {
|
||||
const [batchRemark, setBatchRemark] = useState('');
|
||||
const [batchSubmitting, setBatchSubmitting] = useState(false);
|
||||
const [studentSearch, setStudentSearch] = useState('');
|
||||
const [studentSearchResults, setStudentSearchResults] = useState<{ id: number; name: string }[]>([]);
|
||||
const [studentSearchResults, setStudentSearchResults] = useState<{ id: number; name: string }[]>(
|
||||
[],
|
||||
);
|
||||
const [studentSearchLoading, setStudentSearchLoading] = useState(false);
|
||||
|
||||
// Edit modal
|
||||
@@ -173,11 +208,14 @@ const AttendancePage: React.FC = () => {
|
||||
const [editRecord, setEditRecord] = useState<AttendanceRecordItem | null>(null);
|
||||
const [editForm] = Form.useForm();
|
||||
|
||||
|
||||
// ── Edit record ──
|
||||
const handleEdit = (record: AttendanceRecordItem) => {
|
||||
setEditRecord(record);
|
||||
editForm.setFieldsValue({ session: record.session, status: record.status, remark: record.remark });
|
||||
editForm.setFieldsValue({
|
||||
session: record.session,
|
||||
status: record.status,
|
||||
remark: record.remark,
|
||||
});
|
||||
setEditModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -216,7 +254,10 @@ const AttendancePage: React.FC = () => {
|
||||
if (filterStatus) params.status = filterStatus;
|
||||
if (filterSource) params.source = filterSource;
|
||||
|
||||
const data = await api.get<{ list: AttendanceRecordItem[]; total: number }>('/attendance-records', { params });
|
||||
const data = await api.get<{ list: AttendanceRecordItem[]; total: number }>(
|
||||
'/attendance-records',
|
||||
{ params },
|
||||
);
|
||||
setRecords(data.list);
|
||||
setTotal(data.total);
|
||||
} catch (e: unknown) {
|
||||
@@ -235,7 +276,13 @@ const AttendancePage: React.FC = () => {
|
||||
}
|
||||
setCalendarLoading(true);
|
||||
try {
|
||||
const res = await api.get<{ studentId: number; studentName: string; days: { date: string; session: string; status: string }[] }[]>('/attendance-records/calendar', {
|
||||
const res = await api.get<
|
||||
{
|
||||
studentId: number;
|
||||
studentName: string;
|
||||
days: { date: string; session: string; status: string }[];
|
||||
}[]
|
||||
>('/attendance-records/calendar', {
|
||||
params: { classId: filterClassId },
|
||||
});
|
||||
setCalendarData(res);
|
||||
@@ -257,7 +304,9 @@ const AttendancePage: React.FC = () => {
|
||||
if (filterDateRange?.[1]) params.dateTo = filterDateRange[1].format('YYYY-MM-DD');
|
||||
if (dingMatchStatus) params.matchStatus = dingMatchStatus;
|
||||
|
||||
const data = await api.get<{ list: DingRecord[]; total: number }>('/ding-attendance-raw', { params });
|
||||
const data = await api.get<{ list: DingRecord[]; total: number }>('/ding-attendance-raw', {
|
||||
params,
|
||||
});
|
||||
setDingRecords(data.list);
|
||||
setDingTotal(data.total);
|
||||
} catch (e: unknown) {
|
||||
@@ -268,8 +317,42 @@ const AttendancePage: React.FC = () => {
|
||||
}
|
||||
}, [dingPage, dingPageSize, filterClassId, filterDateRange, dingMatchStatus]);
|
||||
|
||||
// ── Classes the current teacher may import from DingTalk ──
|
||||
const fetchImportClasses = useCallback(async () => {
|
||||
try {
|
||||
const options = await api.get<ClassOption[]>('/attendance-records/import/dingtalk/classes');
|
||||
setImportClassOptions(options);
|
||||
setImportClassId(
|
||||
(current) => current ?? (options.length === 1 ? options[0].classId : undefined),
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
setImportClassOptions([]);
|
||||
message.error(err?.message || '加载可拉取班级失败,请刷新页面后重试');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const openDingTalkImportModal = () => {
|
||||
const today = dayjs();
|
||||
setImportDateRange([today, today]);
|
||||
setImportProgressMsg('');
|
||||
setImportModalOpen(true);
|
||||
void fetchImportClasses();
|
||||
};
|
||||
|
||||
const closeDingTalkImportModal = () => {
|
||||
setImportModalOpen(false);
|
||||
setImportClassId(undefined);
|
||||
setImportDateRange([dayjs(), dayjs()]);
|
||||
setImportProgressMsg('');
|
||||
};
|
||||
|
||||
// ── DingTalk import handler ──
|
||||
const handleImportDingTalk = useCallback(async () => {
|
||||
if (!importClassId) {
|
||||
message.warning('请选择要拉取考勤的班级');
|
||||
return;
|
||||
}
|
||||
if (!importDateRange?.[0] || !importDateRange?.[1]) {
|
||||
message.warning('请选择导入日期范围');
|
||||
return;
|
||||
@@ -279,11 +362,16 @@ const AttendancePage: React.FC = () => {
|
||||
|
||||
try {
|
||||
const result = await api.post<{
|
||||
success: boolean; imported: number; skipped: number; matched: number; errors: string[]; duration: number;
|
||||
success: boolean;
|
||||
imported: number;
|
||||
skipped: number;
|
||||
matched: number;
|
||||
errors: string[];
|
||||
duration: number;
|
||||
}>('/attendance-records/import/dingtalk', {
|
||||
classId: importClassId,
|
||||
start: importDateRange[0].format('YYYY-MM-DD'),
|
||||
end: importDateRange[1].format('YYYY-MM-DD'),
|
||||
autoMatch: importAutoMatch,
|
||||
});
|
||||
|
||||
setImportProgressMsg('');
|
||||
@@ -305,7 +393,7 @@ const AttendancePage: React.FC = () => {
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}, [importDateRange, importAutoMatch, fetchDingRecords]);
|
||||
}, [canManageAllAttendance, importClassId, importDateRange, fetchDingRecords]);
|
||||
|
||||
// ── Effects ──
|
||||
useEffect(() => {
|
||||
@@ -313,10 +401,15 @@ const AttendancePage: React.FC = () => {
|
||||
}, [fetchClasses]);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.get<AlertItem[]>('/attendance-records/alerts')
|
||||
.then((data) => { if (!cancelled) setAlerts(data); })
|
||||
api
|
||||
.get<AlertItem[]>('/attendance-records/alerts')
|
||||
.then((data) => {
|
||||
if (!cancelled) setAlerts(data);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -342,8 +435,10 @@ const AttendancePage: React.FC = () => {
|
||||
}
|
||||
setStudentSearchLoading(true);
|
||||
try {
|
||||
const data = await api.get<{ list?: { id: number; name: string }[] } | { id: number; name: string }[]>('/students', { params: { search: value, pageSize: 10 } });
|
||||
const list = Array.isArray(data) ? data : data.list ?? [];
|
||||
const data = await api.get<
|
||||
{ list?: { id: number; name: string }[] } | { id: number; name: string }[]
|
||||
>('/students', { params: { search: value, pageSize: 10 } });
|
||||
const list = Array.isArray(data) ? data : (data.list ?? []);
|
||||
setStudentSearchResults(list);
|
||||
} catch {
|
||||
setStudentSearchResults([]);
|
||||
@@ -397,7 +492,15 @@ const AttendancePage: React.FC = () => {
|
||||
} finally {
|
||||
setBatchSubmitting(false);
|
||||
}
|
||||
}, [batchStudents, batchDate, batchSession, batchStatus, batchRemark, filterClassId, fetchRecords]);
|
||||
}, [
|
||||
batchStudents,
|
||||
batchDate,
|
||||
batchSession,
|
||||
batchStatus,
|
||||
batchRemark,
|
||||
filterClassId,
|
||||
fetchRecords,
|
||||
]);
|
||||
|
||||
// ── Reset filters ──
|
||||
const handleReset = useCallback(() => {
|
||||
@@ -425,8 +528,10 @@ const AttendancePage: React.FC = () => {
|
||||
}
|
||||
setMatchStudentLoading(true);
|
||||
try {
|
||||
const data = await api.get<{ list?: { id: number; name: string }[] } | { id: number; name: string }[]>('/students', { params: { search: value, pageSize: 10 } });
|
||||
const list = Array.isArray(data) ? data : data.list ?? [];
|
||||
const data = await api.get<
|
||||
{ list?: { id: number; name: string }[] } | { id: number; name: string }[]
|
||||
>('/students', { params: { search: value, pageSize: 10 } });
|
||||
const list = Array.isArray(data) ? data : (data.list ?? []);
|
||||
setMatchStudentResults(list);
|
||||
} catch {
|
||||
setMatchStudentResults([]);
|
||||
@@ -435,22 +540,25 @@ const AttendancePage: React.FC = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMatchSubmit = useCallback(async (studentId: number) => {
|
||||
if (matchRecordId === null) return;
|
||||
setMatchSubmitting(true);
|
||||
try {
|
||||
await api.post(`/ding-attendance-raw/${matchRecordId}/match`, { studentId });
|
||||
message.success('匹配成功');
|
||||
setMatchModalOpen(false);
|
||||
setMatchRecordId(null);
|
||||
fetchDingRecords();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '匹配失败');
|
||||
} finally {
|
||||
setMatchSubmitting(false);
|
||||
}
|
||||
}, [matchRecordId, fetchDingRecords]);
|
||||
const handleMatchSubmit = useCallback(
|
||||
async (studentId: number) => {
|
||||
if (matchRecordId === null) return;
|
||||
setMatchSubmitting(true);
|
||||
try {
|
||||
await api.post(`/ding-attendance-raw/${matchRecordId}/match`, { studentId });
|
||||
message.success('匹配成功');
|
||||
setMatchModalOpen(false);
|
||||
setMatchRecordId(null);
|
||||
fetchDingRecords();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '匹配失败');
|
||||
} finally {
|
||||
setMatchSubmitting(false);
|
||||
}
|
||||
},
|
||||
[matchRecordId, fetchDingRecords],
|
||||
);
|
||||
|
||||
// ── Report download ──
|
||||
const handleExportReport = useCallback(() => {
|
||||
@@ -491,15 +599,17 @@ const AttendancePage: React.FC = () => {
|
||||
},
|
||||
{
|
||||
title: '打卡时间',
|
||||
dataIndex: 'checkTime',
|
||||
key: 'checkTime',
|
||||
width: 160,
|
||||
render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '-'),
|
||||
width: 180,
|
||||
render: (_: unknown, record: DingRecord) => {
|
||||
const value = record.checkInTime || record.checkOutTime;
|
||||
return value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : record.attendanceDate || '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '打卡状态',
|
||||
dataIndex: 'rawStatus',
|
||||
key: 'rawStatus',
|
||||
dataIndex: 'timeResult',
|
||||
key: 'timeResult',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
@@ -519,9 +629,14 @@ const AttendancePage: React.FC = () => {
|
||||
render: (_: unknown, record: DingRecord) => {
|
||||
if (record.matchStatus === 'matched') return <span>-</span>;
|
||||
return (
|
||||
<Button size="small" type="link" onClick={() => openMatchModal(record.id)}>
|
||||
<PermissionButton
|
||||
permission="attendance:edit"
|
||||
size="small"
|
||||
type="link"
|
||||
onClick={() => openMatchModal(record.id)}
|
||||
>
|
||||
匹配
|
||||
</Button>
|
||||
</PermissionButton>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -596,7 +711,11 @@ const AttendancePage: React.FC = () => {
|
||||
width: 80,
|
||||
fixed: 'right' as const,
|
||||
render: (_: unknown, record: AttendanceRecordItem) => (
|
||||
<PermissionButton permission="attendance:edit" size="small" onClick={() => handleEdit(record)}>
|
||||
<PermissionButton
|
||||
permission="attendance:edit"
|
||||
size="small"
|
||||
onClick={() => handleEdit(record)}
|
||||
>
|
||||
编辑
|
||||
</PermissionButton>
|
||||
),
|
||||
@@ -616,46 +735,68 @@ const AttendancePage: React.FC = () => {
|
||||
return Array.from(dates).sort();
|
||||
}, [calendarData]);
|
||||
|
||||
const calendarColumns = useMemo(() => [
|
||||
{
|
||||
title: '学生',
|
||||
dataIndex: 'studentName',
|
||||
key: 'studentName',
|
||||
width: 100,
|
||||
fixed: 'left' as const,
|
||||
},
|
||||
...calendarDates.map((date) => ({
|
||||
title: (
|
||||
<div style={{ textAlign: 'center', fontSize: 12 }}>
|
||||
<div>{dayjs(date).format('MM/DD')}</div>
|
||||
<div style={{ color: '#999' }}>{dayjs(date).format('ddd')}</div>
|
||||
</div>
|
||||
),
|
||||
key: date,
|
||||
width: 80,
|
||||
render: (_: unknown, record: { studentId: number; studentName: string; days: { date: string; session: string; status: string }[] }) => {
|
||||
const dayRecord = record.days.find((d) => d.date === date);
|
||||
if (!dayRecord) return <span style={{ color: '#d9d9d9' }}>-</span>;
|
||||
const statusInfo = STATUS_MAP[dayRecord.status];
|
||||
return (
|
||||
<Tooltip title={`${SESSION_MAP[dayRecord.session] || dayRecord.session}: ${statusInfo?.text || dayRecord.status}`}>
|
||||
<Tag color={statusInfo?.color || 'default'} style={{ margin: 0, cursor: 'pointer' }}>
|
||||
{statusInfo?.text || dayRecord.status}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
const calendarColumns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: '学生',
|
||||
dataIndex: 'studentName',
|
||||
key: 'studentName',
|
||||
width: 100,
|
||||
fixed: 'left' as const,
|
||||
},
|
||||
})),
|
||||
], [calendarDates]);
|
||||
...calendarDates.map((date) => ({
|
||||
title: (
|
||||
<div style={{ textAlign: 'center', fontSize: 12 }}>
|
||||
<div>{dayjs(date).format('MM/DD')}</div>
|
||||
<div style={{ color: '#999' }}>{dayjs(date).format('ddd')}</div>
|
||||
</div>
|
||||
),
|
||||
key: date,
|
||||
width: 80,
|
||||
render: (
|
||||
_: unknown,
|
||||
record: {
|
||||
studentId: number;
|
||||
studentName: string;
|
||||
days: { date: string; session: string; status: string }[];
|
||||
},
|
||||
) => {
|
||||
const dayRecord = record.days.find((d) => d.date === date);
|
||||
if (!dayRecord) return <span style={{ color: '#d9d9d9' }}>-</span>;
|
||||
const statusInfo = STATUS_MAP[dayRecord.status];
|
||||
return (
|
||||
<Tooltip
|
||||
title={`${SESSION_MAP[dayRecord.session] || dayRecord.session}: ${statusInfo?.text || dayRecord.status}`}
|
||||
>
|
||||
<Tag color={statusInfo?.color || 'default'} style={{ margin: 0, cursor: 'pointer' }}>
|
||||
{statusInfo?.text || dayRecord.status}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
})),
|
||||
],
|
||||
[calendarDates],
|
||||
);
|
||||
|
||||
// ── Render ──
|
||||
return (
|
||||
<div>
|
||||
{alerts.length > 0 && (
|
||||
<Alert type="warning" showIcon closable
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
closable
|
||||
title={`考勤预警:${alerts.length} 名学生异常`}
|
||||
description={alerts.map(a => `${a.studentName}(${a.className || '-'}):${a.type} ${a.count}次,最近${a.lastDate}`).join(';')}
|
||||
style={{ marginBottom: 16 }} />)}
|
||||
description={alerts
|
||||
.map(
|
||||
(a) =>
|
||||
`${a.studentName}(${a.className || '-'}):${a.type} ${a.count}次,最近${a.lastDate}`,
|
||||
)
|
||||
.join(';')}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
@@ -752,12 +893,13 @@ const AttendancePage: React.FC = () => {
|
||||
>
|
||||
批量录入
|
||||
</PermissionButton>
|
||||
<Button
|
||||
<PermissionButton
|
||||
permission="attendance:export"
|
||||
icon={<ExportOutlined />}
|
||||
onClick={handleExportReport}
|
||||
>
|
||||
导出考勤报表
|
||||
</Button>
|
||||
</PermissionButton>
|
||||
</Space>
|
||||
<Button
|
||||
icon={calendarView ? <UnorderedListOutlined /> : <CalendarOutlined />}
|
||||
@@ -858,14 +1000,15 @@ const AttendancePage: React.FC = () => {
|
||||
/>
|
||||
</Col>
|
||||
<Col>
|
||||
<Button
|
||||
<PermissionButton
|
||||
permission="attendance:create"
|
||||
type="primary"
|
||||
icon={<CloudDownloadOutlined />}
|
||||
loading={importing}
|
||||
onClick={() => setImportModalOpen(true)}
|
||||
onClick={openDingTalkImportModal}
|
||||
>
|
||||
从钉钉拉取考勤
|
||||
</Button>
|
||||
</PermissionButton>
|
||||
</Col>
|
||||
</Row>
|
||||
{importing && (
|
||||
@@ -996,39 +1139,53 @@ const AttendancePage: React.FC = () => {
|
||||
|
||||
{/* ── DingTalk import modal ── */}
|
||||
<Modal
|
||||
title="从钉钉拉取考勤数据"
|
||||
title={canManageAllAttendance ? '从钉钉拉取考勤数据' : '同步今日考勤'}
|
||||
open={importModalOpen}
|
||||
onOk={handleImportDingTalk}
|
||||
onCancel={() => { setImportModalOpen(false); setImportDateRange(null); setImportProgressMsg(''); }}
|
||||
onCancel={closeDingTalkImportModal}
|
||||
confirmLoading={importing}
|
||||
okText="开始拉取"
|
||||
okText={canManageAllAttendance ? '开始拉取' : '同步今日'}
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="日期范围" required>
|
||||
<RangePicker
|
||||
value={importDateRange}
|
||||
onChange={(dates) => setImportDateRange(dates as [Dayjs, Dayjs] | null)}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="自动匹配">
|
||||
<Form.Item label="班级" required>
|
||||
<Select
|
||||
value={importAutoMatch ? 'yes' : 'no'}
|
||||
onChange={(v) => setImportAutoMatch(v === 'yes')}
|
||||
options={[
|
||||
{ value: 'yes', label: '是 — 导入后按姓名自动匹配学生' },
|
||||
{ value: 'no', label: '否 — 仅导入原始数据,稍后手动匹配' },
|
||||
]}
|
||||
value={importClassId}
|
||||
onChange={setImportClassId}
|
||||
placeholder="选择自己任教的班级"
|
||||
options={importClassOptions.map((item) => ({
|
||||
value: item.classId,
|
||||
label: item.className,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
{canManageAllAttendance ? (
|
||||
<Form.Item label="日期范围" required>
|
||||
<RangePicker
|
||||
value={importDateRange}
|
||||
onChange={(dates) => setImportDateRange(dates as [Dayjs, Dayjs] | null)}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={`将同步今天(${dayjs().format('YYYY-MM-DD')})的钉钉考勤`}
|
||||
description="老师端只同步当天数据,不展示历史日期范围。历史数据请联系管理员处理。"
|
||||
/>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
|
||||
{/* ── Edit modal ── */}
|
||||
<Modal title="编辑考勤" open={editModalOpen} onOk={handleEditSubmit} onCancel={() => setEditModalOpen(false)}>
|
||||
<Modal
|
||||
title="编辑考勤"
|
||||
open={editModalOpen}
|
||||
onOk={handleEditSubmit}
|
||||
onCancel={() => setEditModalOpen(false)}
|
||||
>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="session" label="时段" rules={[{ required: true }]}>
|
||||
<Select options={SESSION_OPTIONS.map((s) => ({ value: s.value, label: s.label }))} />
|
||||
@@ -1083,7 +1240,6 @@ const AttendancePage: React.FC = () => {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user