Files
gongxue-base/apps/admin/src/pages/Attendance/AttendanceAdminWorkspace.tsx
wangziqi 8b5fa98028 feat(admin): 管理端考勤明细批量纠错
- 原始考勤明细表支持行选择(跨页保留),选中后出现
  「标记正常/标记请假/标记缺勤」批量操作条
- 批量提交前确认,成功后按 updated/failed 数量反馈,
  失败记录(如已结算)提示原因;筛选条件变化自动清空选择
- 复用 PUT /attendance-records/batch-status 接口

aislop scan: 5 引擎 0 issues
2026-08-07 18:03:22 +08:00

213 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React from 'react';
import { Button, Card, Empty, Input, Spin, Table } from 'antd';
import { ExportOutlined } from '@ant-design/icons';
import PermissionButton from '../../components/PermissionButton';
import {
ADMIN_METRIC_META,
STATUS_META,
displayAttendanceStatus,
type AdminStudentPanel,
type AttendanceRecordItem,
} from './AttendanceAdmin.helpers';
export const AttendanceAdminWorkspace: React.FC<{
metricFilter: string;
studentSearch: string;
onSearchChange: (value: string) => void;
onExport: () => void;
visibleStudents: AdminStudentPanel[];
loading: boolean;
selectedStudentId?: number;
onSelectStudent: (student: AdminStudentPanel) => void;
sortAttendanceRecords: (items: readonly AttendanceRecordItem[]) => AttendanceRecordItem[];
sessionMap: Record<string, string>;
records: AttendanceRecordItem[];
columns: any[];
page: number;
pageSize: number;
total: number;
onPageChange: (page: number, pageSize: number) => void;
/** 明细表批量纠错 */
selectedRecordKeys: number[];
onSelectRecords: (keys: number[]) => void;
onBatchCorrect: (status: string) => void;
batchCorrecting: boolean;
}> = ({
metricFilter,
studentSearch,
onSearchChange,
onExport,
visibleStudents,
loading,
selectedStudentId,
onSelectStudent,
sortAttendanceRecords,
sessionMap,
records,
columns,
page,
pageSize,
total,
onPageChange,
selectedRecordKeys,
onSelectRecords,
onBatchCorrect,
batchCorrecting,
}) => {
return (
<>
<section className="student-workspace">
<header className="student-workspace-header">
<div>
<h3></h3>
<span>
{metricFilter === 'all'
? `显示全部 ${visibleStudents.length} 名学生`
: `筛出 ${
ADMIN_METRIC_META.find((item) => item.key === metricFilter)?.label || ''
}相关 ${visibleStudents.length} 名学生`}
</span>
</div>
<div className="student-workspace-tools">
<Input.Search
allowClear
placeholder="搜索姓名或学号"
value={studentSearch}
onChange={(event) => onSearchChange(event.target.value)}
/>
<PermissionButton
permission="attendance:export"
icon={<ExportOutlined />}
onClick={onExport}
>
</PermissionButton>
</div>
</header>
<div className="student-legend">
<span>
<i className="is-present" />
</span>
<span>
<i className="is-leave" />
</span>
<span>
<i className="is-absent" />
</span>
<em>/</em>
</div>
<Spin spinning={loading}>
{visibleStudents.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="未找到匹配学生,请调整筛选条件或搜索关键词"
/>
) : (
<div className="student-card-grid">
{visibleStudents.map((student) => (
<button
type="button"
key={student.key}
className={`student-attendance-card ${
selectedStudentId === student.studentId ? 'selected' : ''
}`}
onClick={() => onSelectStudent(student)}
>
<span className="student-attendance-head">
<strong>{student.studentName}</strong>
{student.studentNo && <small> {student.studentNo}</small>}
</span>
<span className="student-status-blocks">
{sortAttendanceRecords(student.records).map((record) => {
const currentStatus = displayAttendanceStatus(record.status);
const meta = STATUS_META[currentStatus] ?? STATUS_META.absent;
const sessionLabel = sessionMap[record.session] || record.session;
return (
<span
key={record.id}
className={`student-status-block ${meta.className}`}
title={`${sessionLabel}${meta.label}`}
>
{meta.label === '出勤' ? '正常' : meta.label}
</span>
);
})}
</span>
</button>
))}
</div>
)}
</Spin>
</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,
total,
showSizeChanger: true,
showTotal: (value) => `${value} 条历史记录`,
onChange: onPageChange,
}}
locale={{
emptyText: (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="当前条件下没有历史考勤记录"
/>
),
}}
/>
</Card>
</>
);
};