feat(admin): 管理端考勤明细批量纠错

- 原始考勤明细表支持行选择(跨页保留),选中后出现
  「标记正常/标记请假/标记缺勤」批量操作条
- 批量提交前确认,成功后按 updated/failed 数量反馈,
  失败记录(如已结算)提示原因;筛选条件变化自动清空选择
- 复用 PUT /attendance-records/batch-status 接口

aislop scan: 5 引擎 0 issues
This commit is contained in:
2026-08-07 18:03:22 +08:00
parent 1b4ba893fd
commit 8b5fa98028
2 changed files with 93 additions and 2 deletions

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Card, Empty, Input, Spin, Table } from 'antd';
import { Button, Card, Empty, Input, Spin, Table } from 'antd';
import { ExportOutlined } from '@ant-design/icons';
import PermissionButton from '../../components/PermissionButton';
import {
@@ -27,6 +27,11 @@ export const AttendanceAdminWorkspace: React.FC<{
pageSize: number;
total: number;
onPageChange: (page: number, pageSize: number) => void;
/** 明细表批量纠错 */
selectedRecordKeys: number[];
onSelectRecords: (keys: number[]) => void;
onBatchCorrect: (status: string) => void;
batchCorrecting: boolean;
}> = ({
metricFilter,
studentSearch,
@@ -44,6 +49,10 @@ export const AttendanceAdminWorkspace: React.FC<{
pageSize,
total,
onPageChange,
selectedRecordKeys,
onSelectRecords,
onBatchCorrect,
batchCorrecting,
}) => {
return (
<>
@@ -135,12 +144,51 @@ export const AttendanceAdminWorkspace: React.FC<{
</section>
<Card className="student-record-card" variant="borderless" title="原始考勤明细">
{selectedRecordKeys.length > 0 ? (
<div
style={{
marginBottom: 8,
display: 'flex',
alignItems: 'center',
gap: 8,
flexWrap: 'wrap',
}}
>
<span> {selectedRecordKeys.length} </span>
<Button
size="small"
type="primary"
loading={batchCorrecting}
onClick={() => onBatchCorrect('present')}
>
</Button>
<Button size="small" loading={batchCorrecting} onClick={() => onBatchCorrect('leave')}>
</Button>
<Button
size="small"
danger
loading={batchCorrecting}
onClick={() => onBatchCorrect('absent')}
>
</Button>
<Button size="small" type="text" onClick={() => onSelectRecords([])}>
</Button>
</div>
) : null}
<Table<AttendanceRecordItem>
rowKey="id"
columns={columns}
dataSource={records}
loading={loading}
scroll={{ x: 'max-content' }}
rowSelection={{
selectedRowKeys: selectedRecordKeys,
onChange: (keys) => onSelectRecords(keys as number[]),
}}
pagination={{
current: page,
pageSize,

View File

@@ -1,7 +1,8 @@
import React, { useCallback, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useApiMutation } from '../../hooks/useApiMutation';
import { validateResponse } from '../../utils/validate';
import { getErrorMessage } from '../../utils/error';
import {
attendanceAlertsSchema,
attendanceClassOptionsSchema,
@@ -57,6 +58,9 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
const [studentSearch, setStudentSearch] = useState('');
const [selectedStudent, setSelectedStudent] = useState<AdminStudentPanel | null>(null);
const [correctingRecordId, setCorrectingRecordId] = useState<number | null>(null);
// 明细表批量纠错:选中的记录 ID + 执行中状态
const [selectedRecordIds, setSelectedRecordIds] = useState<number[]>([]);
const [batchCorrecting, setBatchCorrecting] = useState(false);
const queryClient = useQueryClient();
useVisibleRefetch(['attendance', 'records']);
const recordQueryKey = [
@@ -71,6 +75,11 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
scheduleId,
] as const;
// 筛选条件变化时清空批量选择,避免把上一批条件的记录带到新条件下提交
useEffect(() => {
setSelectedRecordIds([]);
}, [classId, attendanceDate, status, session, scheduleId]);
const { data: classOptions = [] } = useQuery<ClassOption[]>({
queryKey: ['attendance', 'meta', 'classes'],
queryFn: async () =>
@@ -400,6 +409,36 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
}
};
/** 批量纠错:对选中的明细记录统一标记状态 */
const batchCorrectStatus = async (nextStatus: string) => {
if (selectedRecordIds.length === 0) return;
const statusLabel =
nextStatus === 'present' ? '正常' : nextStatus === 'leave' ? '请假' : '缺勤';
modal.confirm({
title: `将选中的 ${selectedRecordIds.length} 条记录标记为「${statusLabel}」?`,
content: '此操作会立即写入考勤记录;已结算的记录无法修改。',
okText: '确认',
cancelText: '取消',
onOk: async () => {
setBatchCorrecting(true);
try {
const res = await api.put<{ updated: number; failed: number; failedIds: number[] }>(
'/attendance-records/batch-status',
{ ids: selectedRecordIds, status: nextStatus },
);
setSelectedRecordIds([]);
message.success(`已更新 ${res.updated} 条记录`);
if (res.failed > 0) message.warning(`${res.failed} 条更新失败(可能已结算)`);
void refetchRecords();
} catch (e: unknown) {
message.error(getErrorMessage(e, '批量更新失败'));
} finally {
setBatchCorrecting(false);
}
},
});
};
const saveAdminRecordCell = async (
record: AttendanceRecordItem,
field: 'status' | 'remark',
@@ -544,6 +583,10 @@ export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit
setPage(nextPage);
setPageSize(nextPageSize);
}}
selectedRecordKeys={selectedRecordIds}
onSelectRecords={setSelectedRecordIds}
onBatchCorrect={batchCorrectStatus}
batchCorrecting={batchCorrecting}
/>
)}