Files
gongxue-base/apps/admin/src/pages/Attendance/index.tsx

865 lines
29 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, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert,
Avatar,
Button,
Card,
Col,
DatePicker,
Drawer,
Empty,
Form,
Input,
Modal,
Progress,
Row,
Select,
Space,
Spin,
Table,
Tag,
Tooltip,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
ArrowRightOutlined,
CheckCircleFilled,
ClockCircleOutlined,
EditOutlined,
ExportOutlined,
FileSearchOutlined,
ReloadOutlined,
ScheduleOutlined,
TeamOutlined,
WarningFilled,
} from '@ant-design/icons';
import dayjs, { type Dayjs } from 'dayjs';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
import { usePermission } from '../../hooks/usePermission';
import { message } from '../../ui/app-message';
import {
canPullAttendance,
filterLessonAttendanceRecords,
getAttendanceExperience,
getPunchDisplayInfo,
getSchedulePhase,
summarizeLessonCheckins,
type AttendanceSummary,
type LessonAttendanceFilter,
type SchedulePhase,
} from './attendance-workspace';
import './attendance.css';
const { RangePicker } = DatePicker;
const SESSION_OPTIONS = [
{ value: 'morning_reading', label: '早自习' },
{ value: 'morning', label: '上午' },
{ value: 'afternoon', label: '下午' },
{ value: 'evening_study', label: '晚自习' },
{ value: 'night_check', label: '晚寝' },
];
const SESSION_MAP: Record<string, string> = Object.fromEntries(
SESSION_OPTIONS.map((item) => [item.value, item.label]),
);
const STATUS_META: Record<
string,
{ label: string; color: string; className: string; short: string }
> = {
present: { label: '出勤', color: 'success', className: 'is-present', short: '勤' },
late: { label: '迟到', color: 'warning', className: 'is-late', short: '迟' },
absent: { label: '缺勤', color: 'error', className: 'is-absent', short: '缺' },
leave: { label: '请假', color: 'processing', className: 'is-leave', short: '假' },
pending: { label: '待确认', color: 'default', className: 'is-pending', short: '待' },
};
const STATUS_OPTIONS = Object.entries(STATUS_META)
.filter(([value]) => value !== 'pending')
.map(([value, item]) => ({ value, label: item.label }));
interface ClassOption {
classId: number;
className: string;
}
interface AttendanceRecordItem {
id: number;
studentId: number;
classId: number | null;
attendanceDate: string;
session: string;
status: string;
source?: string;
remark: string | null;
createdAt: string;
student: { id: number; name: string };
class: { id: number; name: string } | null;
scheduleId?: number | null;
attendanceSessionId?: number | null;
punchTime?: string | null;
punchSource?: string | null;
punchDeviceName?: string | null;
punchDeviceId?: string | null;
}
interface AssignedClass {
classId: number;
className: string;
classCode: string;
roleType: string;
subject: string;
}
interface TodaySchedule {
id: number;
classId: number;
classroomId: number;
startTime: string;
endTime: string;
subject: string;
}
interface LessonAttendanceSession {
id: number;
scheduleId: number;
classId: number;
lessonDate: string;
status: 'in_progress' | 'completed';
}
interface LessonAttendanceResponse {
schedule: TodaySchedule;
session: LessonAttendanceSession | null;
records: AttendanceRecordItem[];
}
interface TeacherWorkspaceData {
assignedClasses: AssignedClass[];
todaySchedules: TodaySchedule[];
}
interface AlertItem {
studentId: number;
studentName: string;
className: string;
type: string;
count: number;
lastDate: string;
}
const EMPTY_SUMMARY: AttendanceSummary = {
total: 0,
present: 0,
late: 0,
absent: 0,
leave: 0,
pending: 0,
};
function readCurrentRoles(): string[] {
try {
const user = JSON.parse(localStorage.getItem('user') || '{}') as { roles?: string[] };
return Array.isArray(user.roles) ? user.roles : [];
} catch {
return [];
}
}
function AttendanceStatusTag({ status }: { status: string }) {
const meta = STATUS_META[status] ?? {
label: status,
color: 'default',
className: 'is-pending',
short: '?',
};
return (
<span className={`attendance-status ${meta.className}`}>
<span className="attendance-status__dot" />
{meta.label}
</span>
);
}
function SummaryStrip({ summary }: { summary: AttendanceSummary }) {
const rate = summary.total > 0 ? Math.round((summary.present / summary.total) * 100) : 0;
return (
<div className="attendance-summary-strip">
<div className="attendance-rate">
<Progress
type="circle"
percent={rate}
size={64}
strokeWidth={9}
strokeColor="#1677ff"
trailColor="#e8eef7"
/>
<div>
<span></span>
<strong>{summary.total} </strong>
</div>
</div>
{[
['present', summary.present],
['late', summary.late],
['absent', summary.absent],
['leave', summary.leave],
].map(([status, value]) => {
const meta = STATUS_META[String(status)];
return (
<div className="attendance-summary-cell" key={String(status)}>
<span className={`attendance-summary-icon ${meta.className}`}>{meta.short}</span>
<div>
<strong>{value}</strong>
<span>{meta.label}</span>
</div>
</div>
);
})}
</div>
);
}
function LessonCheckinSummaryStrip({ records }: { records: readonly AttendanceRecordItem[] }) {
const summary = summarizeLessonCheckins(records);
const rate = summary.total > 0 ? Math.round((summary.checkedIn / summary.total) * 100) : 0;
return (
<div className="attendance-summary-strip">
<div className="attendance-rate">
<Progress type="circle" percent={rate} size={64} strokeWidth={9} />
<div><span></span><strong>{summary.total} </strong></div>
</div>
<div className="attendance-summary-cell">
<span className="attendance-summary-icon is-present"></span>
<div><strong>{summary.checkedIn}</strong><span></span></div>
</div>
<div className="attendance-summary-cell">
<span className="attendance-summary-icon is-absent"></span>
<div><strong>{summary.notCheckedIn}</strong><span></span></div>
</div>
</div>
);
}
const AttendancePage: React.FC = () => {
const { permissions, hasPermission } = usePermission();
const roles = useMemo(readCurrentRoles, []);
const experience = getAttendanceExperience(permissions, roles);
if (experience === 'teacher') {
return <TeacherAttendanceWorkspace />;
}
return <AdminAttendanceArchive canEdit={hasPermission('attendance:edit')} />;
};
const TeacherAttendanceWorkspace: React.FC = () => {
const [loading, setLoading] = useState(true);
const [workspace, setWorkspace] = useState<TeacherWorkspaceData | null>(null);
const [selectedSchedule, setSelectedSchedule] = useState<TodaySchedule | null>(null);
const [lessonSession, setLessonSession] = useState<LessonAttendanceSession | null>(null);
const [lessonRecords, setLessonRecords] = useState<AttendanceRecordItem[]>([]);
const [recordLoading, setRecordLoading] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const [studentKeyword, setStudentKeyword] = useState('');
const [checkinFilter, setCheckinFilter] = useState<LessonAttendanceFilter>('all');
const loadWorkspace = useCallback(async () => {
setLoading(true);
try {
setWorkspace(await api.get<TeacherWorkspaceData>('/rbac/teacher-workspace'));
} catch (error: unknown) {
message.error((error as { message?: string })?.message || '加载今日课程失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadWorkspace();
}, [loadWorkspace]);
const classNameById = useMemo(
() => new Map(workspace?.assignedClasses.map((item) => [item.classId, item.className]) ?? []),
[workspace],
);
const openAttendance = useCallback(async (schedule: TodaySchedule) => {
setStudentKeyword('');
setCheckinFilter('all');
setSelectedSchedule(schedule);
setDrawerOpen(true);
setRecordLoading(true);
try {
const today = dayjs().format('YYYY-MM-DD');
const data = await api.post<LessonAttendanceResponse>(
`/attendance-lessons/schedules/${schedule.id}/pull`,
{ date: today },
);
setSelectedSchedule(data.schedule);
setLessonSession(data.session);
setLessonRecords(data.records);
message.success('钉钉打卡已更新;课程截止后系统将自动结算');
} catch (error: unknown) {
setLessonSession(null);
setLessonRecords([]);
message.error(error instanceof Error ? error.message : '加载本节课考勤失败');
} finally {
setRecordLoading(false);
}
}, []);
const updateLessonRecord = useCallback(
async (record: AttendanceRecordItem, status: string) => {
const previous = record.status;
setLessonRecords((items) =>
items.map((item) => (item.id === record.id ? { ...item, status } : item)),
);
try {
await api.put(`/attendance-records/${record.id}`, { status });
} catch (error: unknown) {
setLessonRecords((items) =>
items.map((item) => (item.id === record.id ? { ...item, status: previous } : item)),
);
message.error((error as { message?: string })?.message || '更新考勤失败');
}
},
[],
);
const now = new Date();
const schedules = workspace?.todaySchedules ?? [];
const startedCount = schedules.filter(
(item) => canPullAttendance(getSchedulePhase(item.startTime, item.endTime, now)),
).length;
const nextSchedule = schedules.find(
(item) => getSchedulePhase(item.startTime, item.endTime, now) !== 'ended',
);
const isAttendanceCompleted = lessonSession?.status === 'completed';
const filteredLessonRecords = useMemo(
() => filterLessonAttendanceRecords(lessonRecords, studentKeyword, checkinFilter),
[lessonRecords, studentKeyword, checkinFilter],
);
return (
<div className="attendance-page teacher-attendance">
<section className="attendance-hero attendance-hero--teacher">
<div>
<span className="attendance-eyebrow">TEACHING DAY · {dayjs().format('MM月DD日 dddd')}</span>
<h1></h1>
<p></p>
</div>
<Button icon={<ReloadOutlined />} onClick={() => void loadWorkspace()}>
</Button>
</section>
<Row gutter={[16, 16]} className="teacher-overview">
<Col xs={24} md={8}>
<div className="teacher-kpi"><span></span><strong>{schedules.length}</strong><small></small></div>
</Col>
<Col xs={24} md={8}>
<div className="teacher-kpi"><span></span><strong>{startedCount}</strong><small></small></div>
</Col>
<Col xs={24} md={8}>
<div className="teacher-kpi teacher-kpi--next"><span></span><strong>{nextSchedule ? nextSchedule.startTime : '—'}</strong><small>{nextSchedule?.subject || '今天没有更多课程'}</small></div>
</Col>
</Row>
<div className="attendance-section-heading">
<div><span></span><h2></h2></div>
<span className="attendance-section-note"></span>
</div>
<Spin spinning={loading}>
{schedules.length === 0 ? (
<Card className="attendance-empty-card">
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={<div><strong></strong><p></p></div>}
/>
</Card>
) : (
<div className="lesson-timeline">
{schedules.map((schedule, index) => {
const phase = getSchedulePhase(schedule.startTime, schedule.endTime, now);
return (
<LessonCard
key={schedule.id}
schedule={schedule}
phase={phase}
className={classNameById.get(schedule.classId) || `班级 ${schedule.classId}`}
index={index + 1}
onOpen={() => void openAttendance(schedule)}
/>
);
})}
</div>
)}
</Spin>
<Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} width={960} title={null} className="attendance-drawer">
<div className="lesson-record-header">
<span className="attendance-eyebrow">LESSON ATTENDANCE</span>
<h2>{selectedSchedule?.subject || '课程考勤'}</h2>
<p>{selectedSchedule ? classNameById.get(selectedSchedule.classId) : ''} · {selectedSchedule?.startTime}{selectedSchedule?.endTime} · {dayjs().format('YYYY-MM-DD')}</p>
</div>
{lessonSession && (
<Alert
type={isAttendanceCompleted ? 'success' : 'info'}
showIcon
title={isAttendanceCompleted ? '本节课考勤已结算' : '当前打卡结果;课程截止后将自动做最终结算'}
style={{ marginBottom: 16 }}
/>
)}
<LessonCheckinSummaryStrip records={lessonRecords} />
<div className="lesson-record-filters">
<Input.Search
allowClear
value={studentKeyword}
placeholder="搜索学生姓名"
onChange={(event) => setStudentKeyword(event.target.value)}
className="lesson-record-search"
/>
<Select<LessonAttendanceFilter>
value={checkinFilter}
onChange={setCheckinFilter}
options={[
{ value: 'all', label: '全部学生' },
{ value: 'checked_in', label: '已打卡' },
{ value: 'not_checked_in', label: '未打卡' },
]}
className="lesson-record-filter-select"
/>
<span className="lesson-record-filter-count">
{filteredLessonRecords.length} / {lessonRecords.length}
</span>
</div>
<Table<AttendanceRecordItem>
rowKey="id"
loading={recordLoading}
dataSource={filteredLessonRecords}
pagination={false}
locale={{
emptyText: <Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={lessonRecords.length === 0 ? '本节课尚未开始点名' : '没有符合条件的学生'}
/>,
}}
columns={[
{
title: '学生', dataIndex: ['student', 'name'],
render: (name: string) => <div className="student-cell"><Avatar size={32}>{name?.slice(0, 1)}</Avatar><strong>{name || '-'}</strong></div>,
},
{
title: '考勤结果', dataIndex: 'status', width: 230,
render: (value: string, record: AttendanceRecordItem) => {
const checkedIn = value === 'present' || value === 'late';
return (
<div className="attendance-marking-actions">
<Button
size="small"
type={checkedIn ? 'primary' : 'default'}
onClick={() => void updateLessonRecord(record, 'present')}
>
</Button>
<Button
size="small"
type={!checkedIn ? 'primary' : 'default'}
danger={!checkedIn}
onClick={() => void updateLessonRecord(record, 'absent')}
>
</Button>
</div>
);
},
},
{ title: '当前状态', dataIndex: 'status', width: 105, render: (value: string) => <AttendanceStatusTag status={value === 'present' || value === 'late' ? 'present' : 'absent'} /> },
{
title: '打卡设备',
width: 220,
render: (_: unknown, record: AttendanceRecordItem) => {
const info = getPunchDisplayInfo(record);
if (!info) return <span className="muted-text"></span>;
return (
<div className="punch-device-cell">
<Tag color={info.machine ? 'green' : 'blue'}>{info.label}</Tag>
{info.detail && <strong>{info.detail}</strong>}
{info.time && <span>{dayjs(info.time).format('HH:mm:ss')}</span>}
</div>
);
},
},
{ title: '备注', dataIndex: 'remark', render: (value: string | null) => value || <span className="muted-text"></span> },
]}
/>
</Drawer>
</div>
);
};
const LessonCard: React.FC<{
schedule: TodaySchedule;
phase: SchedulePhase;
className: string;
index: number;
onOpen: () => void;
}> = ({ schedule, phase, className, index, onOpen }) => {
const phaseMeta = {
upcoming: { label: '待上课', icon: <ClockCircleOutlined />, tone: 'upcoming' },
ongoing: { label: '进行中', icon: <ScheduleOutlined />, tone: 'ongoing' },
ended: { label: '已结束', icon: <CheckCircleFilled />, tone: 'ended' },
}[phase];
return (
<article className={`lesson-card lesson-card--${phaseMeta.tone}`}>
<div className="lesson-sequence">{String(index).padStart(2, '0')}</div>
<div className="lesson-time"><strong>{schedule.startTime}</strong><span /><strong>{schedule.endTime}</strong></div>
<div className="lesson-main">
<div className="lesson-title-row"><h3>{schedule.subject}</h3><Tag icon={phaseMeta.icon}>{phaseMeta.label}</Tag></div>
<p><TeamOutlined /> {className}<span> {schedule.classroomId}</span></p>
</div>
<div className="lesson-action">
{phase === 'upcoming' ? (
<Tooltip title="课程尚未开始"><Button disabled></Button></Tooltip>
) : (
<Button type="primary" onClick={onOpen}>
{phase === 'ongoing' ? '查看当前考勤' : '拉取 / 查看考勤'} <ArrowRightOutlined />
</Button>
)}
</div>
</article>
);
};
const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const [records, setRecords] = useState<AttendanceRecordItem[]>([]);
const [summary, setSummary] = useState<AttendanceSummary>(EMPTY_SUMMARY);
const [alerts, setAlerts] = useState<AlertItem[]>([]);
const [classOptions, setClassOptions] = useState<ClassOption[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [total, setTotal] = useState(0);
const [classId, setClassId] = useState<number>();
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>([
dayjs().subtract(30, 'day'),
dayjs(),
]);
const [status, setStatus] = useState<string>();
const [session, setSession] = useState<string>();
const [editRecord, setEditRecord] = useState<AttendanceRecordItem | null>(null);
const [editForm] = Form.useForm();
const buildParams = useCallback(
(withPagination = true) => {
const params: Record<string, string | number> = {};
if (withPagination) Object.assign(params, { page, pageSize });
if (classId) params.classId = classId;
if (dateRange?.[0]) params.dateFrom = dateRange[0].format('YYYY-MM-DD');
if (dateRange?.[1]) params.dateTo = dateRange[1].format('YYYY-MM-DD');
if (status) params.status = status;
if (session) params.session = session;
return params;
},
[page, pageSize, classId, dateRange, status, session],
);
const loadRecords = useCallback(async () => {
setLoading(true);
try {
const [recordData, summaryData] = await Promise.all([
api.get<{ list: AttendanceRecordItem[]; total: number }>('/attendance-records', {
params: buildParams(true),
}),
api.get<AttendanceSummary>('/attendance-records/summary', {
params: buildParams(false),
}),
]);
setRecords(recordData.list);
setTotal(recordData.total);
setSummary({ ...EMPTY_SUMMARY, ...summaryData });
} catch (error: unknown) {
message.error((error as { message?: string })?.message || '加载历史考勤失败');
} finally {
setLoading(false);
}
}, [buildParams]);
useEffect(() => {
void Promise.all([
api.get<ClassOption[]>('/attendance-records/classes').then(setClassOptions),
api.get<AlertItem[]>('/attendance-records/alerts').then(setAlerts),
]).catch(() => undefined);
}, []);
useEffect(() => {
void loadRecords();
}, [loadRecords]);
const resetFilters = () => {
setClassId(undefined);
setDateRange([dayjs().subtract(30, 'day'), dayjs()]);
setStatus(undefined);
setSession(undefined);
setPage(1);
};
const handleExport = useCallback(() => {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(buildParams(false))) params.set(key, String(value));
const token = localStorage.getItem('token');
fetch(`/api/attendance-records/export?${params.toString()}`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((response) => {
if (!response.ok) throw new Error('导出失败');
return response.blob();
})
.then((blob) => {
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `历史考勤-${dayjs().format('YYYYMMDD')}.xlsx`;
anchor.click();
URL.revokeObjectURL(url);
})
.catch(() => message.error('导出失败'));
}, [buildParams]);
const submitEdit = async () => {
if (!editRecord) return;
try {
const values = await editForm.validateFields();
await api.put(`/attendance-records/${editRecord.id}`, values);
message.success('考勤记录已更新');
setEditRecord(null);
void loadRecords();
} catch (error: unknown) {
if ((error as { errorFields?: unknown })?.errorFields) return;
message.error((error as { message?: string })?.message || '更新失败');
}
};
const columns: ColumnsType<AttendanceRecordItem> = [
{
title: '学生',
dataIndex: ['student', 'name'],
fixed: 'left',
width: 150,
render: (name: string, record) => (
<div className="student-cell">
<Avatar size={34}>{name?.slice(0, 1)}</Avatar>
<div>
<strong>{name || '-'}</strong>
<span>{record.class?.name || '未关联班级'}</span>
</div>
</div>
),
},
{ title: '日期', dataIndex: 'attendanceDate', width: 120 },
{
title: '时段',
dataIndex: 'session',
width: 110,
render: (value: string) => SESSION_MAP[value] || value,
},
{
title: '结果',
dataIndex: 'status',
width: 110,
render: (value: string) => <AttendanceStatusTag status={value} />,
},
{
title: '记录来源',
dataIndex: 'source',
width: 110,
render: (value: string) => (value === 'dingtalk' ? '钉钉同步' : value === 'schedule' ? '课程生成' : value === 'lesson' ? '课堂点名' : '人工记录'),
},
{
title: '备注',
dataIndex: 'remark',
ellipsis: true,
render: (value: string | null) => value || <span className="muted-text"></span>,
},
{
title: '归档时间',
dataIndex: 'createdAt',
width: 165,
render: (value: string) => dayjs(value).format('YYYY-MM-DD HH:mm'),
},
...(canEdit
? [
{
title: '操作',
key: 'action',
fixed: 'right' as const,
width: 80,
render: (_: unknown, record: AttendanceRecordItem) => (
<Button
type="text"
icon={<EditOutlined />}
onClick={() => {
setEditRecord(record);
editForm.setFieldsValue({ status: record.status, remark: record.remark });
}}
>
</Button>
),
},
]
: []),
];
return (
<div className="attendance-page admin-attendance">
<section className="attendance-hero attendance-hero--admin">
<div>
<span className="attendance-eyebrow">ATTENDANCE ARCHIVE</span>
<h1></h1>
<p></p>
</div>
<PermissionButton
permission="attendance:export"
icon={<ExportOutlined />}
size="large"
onClick={handleExport}
>
</PermissionButton>
</section>
<SummaryStrip summary={summary} />
{alerts.length > 0 && (
<div className="archive-alert">
<WarningFilled />
<div>
<strong>{alerts.length} </strong>
<span> 14 </span>
</div>
<Tooltip title={alerts.slice(0, 5).map((item) => `${item.studentName}${item.type}${item.count}`).join('')}>
<Button type="link"></Button>
</Tooltip>
</div>
)}
<Card className="archive-card" bordered={false}>
<div className="archive-toolbar">
<div className="archive-toolbar__title">
<FileSearchOutlined />
<div>
<strong></strong>
<span> 30 </span>
</div>
</div>
<Space wrap size={10}>
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="全部班级"
value={classId}
onChange={(value) => {
setClassId(value);
setPage(1);
}}
options={classOptions.map((item) => ({ value: item.classId, label: item.className }))}
style={{ width: 170 }}
/>
<RangePicker
value={dateRange}
onChange={(value) => {
setDateRange(value as [Dayjs, Dayjs] | null);
setPage(1);
}}
/>
<Select
allowClear
placeholder="考勤结果"
value={status}
onChange={(value) => {
setStatus(value);
setPage(1);
}}
options={STATUS_OPTIONS}
style={{ width: 130 }}
/>
<Select
allowClear
placeholder="课程时段"
value={session}
onChange={(value) => {
setSession(value);
setPage(1);
}}
options={SESSION_OPTIONS}
style={{ width: 130 }}
/>
<Button icon={<ReloadOutlined />} onClick={resetFilters}>
</Button>
</Space>
</div>
<Table<AttendanceRecordItem>
rowKey="id"
columns={columns}
dataSource={records}
loading={loading}
scroll={{ x: 1050 }}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (value) => `${value} 条历史记录`,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage);
setPageSize(nextPageSize);
},
}}
locale={{
emptyText: (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="当前条件下没有历史考勤记录" />
),
}}
/>
</Card>
<Modal
open={Boolean(editRecord)}
title="考勤记录纠错"
okText="保存修正"
onOk={submitEdit}
onCancel={() => setEditRecord(null)}
>
<Alert
type="info"
showIcon
title="管理员修正会保留操作日志"
style={{ marginBottom: 20 }}
/>
<Form form={editForm} layout="vertical">
<Form.Item name="status" label="考勤结果" rules={[{ required: true }]}>
<Select options={STATUS_OPTIONS} />
</Form.Item>
<Form.Item name="remark" label="修正说明">
<Input.TextArea rows={3} placeholder="建议填写修正原因,便于后续审计" />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default AttendancePage;