fix permissions and teacher attendance workflows

This commit is contained in:
2026-07-10 20:40:52 +08:00
parent 247879f276
commit 8ed1682b90
95 changed files with 4745 additions and 1237 deletions

View File

@@ -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>
);
};