Files
gongxue-base/apps/admin/src/pages/AttendanceDevices.tsx
wangziqi 1b4ba893fd feat: 空状态引导全面应用与考勤批量标记
admin:
- 17 个列表页空态统一为 QueryEmpty 引导:学生/账单/入住/费用/押金/
  教室/房间/班级/考试/排课/机构/考勤机/钱包/租赁/通知/角色等,
  有创建权限的页面附带主操作按钮,无权限时纯展示
- 教师端课堂点名新增「全部已打卡/全部未打卡」批量按钮:
  仅作用于状态不一致的记录,确认后调用批量接口,展示成功/失败数量

server:
- 新增 PUT /attendance-records/batch-status 批量改状态接口
  (ids ≤200,逐条权限校验与会话锁,部分失败返回 failedIds,
  审计日志记录批量结果;路由声明在 :id 之前避免被捕获)

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

402 lines
12 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, { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useApiMutation } from '../hooks/useApiMutation';
import { validateResponse } from '../utils/validate';
import { attendanceDevicesSchema, classroomOptionsSchema } from '../api/schemas';
import { Form, Input, Modal, Popconfirm, Select, Space, Table, Tag } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { PlusOutlined } from '@ant-design/icons';
import api from '../api';
import PermissionButton from '../components/PermissionButton';
import EditableCell from '../components/EditableCell';
import { QueryErrorState, QueryEmpty } from '../components/QueryState';
import { message } from '../ui/app-message';
import { useDirtyGuard } from '../hooks/useDirtyGuard';
import { usePermission } from '../hooks/usePermission';
interface ClassroomOption {
id: number;
name: string;
building?: string | null;
}
interface AttendanceDeviceRow {
id: number;
deviceSn: string;
deviceName: string;
classroomId: number;
classroom?: ClassroomOption | null;
status: 'active' | 'disabled';
location?: string | null;
notes?: string | null;
}
const statusMeta = {
active: { text: '启用', color: 'green' },
disabled: { text: '停用', color: 'default' },
} as const;
const AttendanceDevicesPage: React.FC = () => {
const { hasPermission } = usePermission();
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<AttendanceDeviceRow | null>(null);
const [saving, setSaving] = useState(false);
const [keyword, setKeyword] = useState('');
const [form] = Form.useForm();
const formGuard = useDirtyGuard(form);
const {
data: fetchResult = { devices: [], classrooms: [] },
isLoading,
isFetching,
isError,
refetch,
} = useQuery<{ devices: AttendanceDeviceRow[]; classrooms: ClassroomOption[] }>({
queryKey: ['attendance-devices'],
queryFn: async () => {
const [devices, classroomList] = await Promise.all([
api.get<AttendanceDeviceRow[]>('/attendance-devices'),
api.get<ClassroomOption[]>('/classrooms'),
]);
return {
devices: validateResponse<AttendanceDeviceRow[]>(attendanceDevicesSchema, devices),
classrooms: validateResponse<ClassroomOption[]>(
classroomOptionsSchema,
classroomList,
).filter((item: any) => item.status !== 'archived'),
};
},
});
const data = fetchResult.devices;
const classrooms = fetchResult.classrooms;
const loading = isLoading || isFetching;
const saveMutation = useApiMutation(
async (values: Record<string, unknown>) =>
editing
? api.put(`/attendance-devices/${editing.id}`, values)
: api.post('/attendance-devices', values),
{ invalidate: [['attendance-devices']] },
);
const saveCellMutation = useApiMutation(
async ({ record, field, value }: { record: AttendanceDeviceRow; field: string; value: unknown }) =>
api.put(`/attendance-devices/${record.id}`, { [field]: value }),
{ invalidate: [['attendance-devices']] },
);
const deleteMutation = useApiMutation(
async (id: number) => api.delete(`/attendance-devices/${id}`),
{ invalidate: [['attendance-devices']] },
);
const classroomOptions = useMemo(
() =>
classrooms.map((item) => ({
value: item.id,
label: item.building ? `${item.name}${item.building}` : item.name,
})),
[classrooms],
);
const filteredData = useMemo(() => {
const text = keyword.trim().toLocaleLowerCase('zh-CN');
if (!text) return data;
return data.filter((item) =>
[item.deviceSn, item.deviceName, item.classroom?.name, item.location].some((value) =>
(value || '').toLocaleLowerCase('zh-CN').includes(text),
),
);
}, [data, keyword]);
const openCreate = () => {
setEditing(null);
form.resetFields();
form.setFieldsValue({ status: 'active' });
formGuard.snapshot();
setModalOpen(true);
};
const openEdit = (record: AttendanceDeviceRow) => {
setEditing(record);
form.setFieldsValue({
deviceSn: record.deviceSn,
deviceName: record.deviceName,
classroomId: record.classroomId,
status: record.status,
location: record.location,
notes: record.notes,
});
formGuard.snapshot();
setModalOpen(true);
};
const handleSave = async () => {
const values = await form.validateFields();
setSaving(true);
try {
await saveMutation.mutateAsync(values);
message.success(editing ? '考勤机绑定已更新' : '考勤机绑定已创建');
setModalOpen(false);
setEditing(null);
form.resetFields();
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setSaving(false);
}
};
const saveCell = async (record: AttendanceDeviceRow, field: string, value: unknown) => {
try {
await saveCellMutation.mutateAsync({ record, field, value });
message.success('已保存');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const handleDelete = async (id: number) => {
try {
await deleteMutation.mutateAsync(id);
message.success('已停用绑定');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const columns: ColumnsType<AttendanceDeviceRow> = [
{
title: '设备名称',
dataIndex: 'deviceName',
width: 180,
render: (value: string, record) => (
<EditableCell
value={value}
required
permission="classroom:edit"
onSave={(next) => saveCell(record, 'deviceName', next)}
>
{value}
</EditableCell>
),
},
{
title: 'SN 码',
dataIndex: 'deviceSn',
width: 220,
render: (value, record) => (
<EditableCell
value={value}
required
permission="classroom:edit"
onSave={(next) => saveCell(record, 'deviceSn', next)}
>
<span style={{ fontFamily: 'monospace' }}>{value}</span>
</EditableCell>
),
},
{
title: '绑定教室',
dataIndex: ['classroom', 'name'],
width: 160,
render: (_value, record) => (
<EditableCell
value={record.classroomId}
editor="select"
options={classrooms.map((item) => ({
value: item.id,
label: item.building ? `${item.building} · ${item.name}` : item.name,
}))}
permission="classroom:edit"
required
onSave={(next) => saveCell(record, 'classroomId', next)}
>
{record.classroom?.name || `教室 ${record.classroomId}`}
</EditableCell>
),
},
{
title: '位置',
dataIndex: 'location',
render: (value, record) => (
<EditableCell
value={value}
permission="classroom:edit"
onSave={(next) => saveCell(record, 'location', next)}
>
{value || <span style={{ color: '#999' }}></span>}
</EditableCell>
),
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (value: keyof typeof statusMeta, record) => (
<EditableCell
value={value}
editor="select"
options={[
{ value: 'active', label: '启用' },
{ value: 'disabled', label: '停用' },
]}
permission="classroom:edit"
onSave={(next) => saveCell(record, 'status', next)}
>
<Tag color={statusMeta[value]?.color}>{statusMeta[value]?.text || value}</Tag>
</EditableCell>
),
},
{
title: '备注',
dataIndex: 'notes',
ellipsis: true,
render: (value, record) => (
<EditableCell
value={value}
editor="textarea"
permission="classroom:edit"
onSave={(next) => saveCell(record, 'notes', next)}
>
{value || <span style={{ color: '#999' }}></span>}
</EditableCell>
),
},
{
title: '操作',
width: 150,
render: (_, record) => (
<Space>
<PermissionButton
permission="classroom:edit"
size="small"
type="link"
onClick={() => openEdit(record)}
>
</PermissionButton>
<Popconfirm title="确定停用此考勤机绑定?" onConfirm={() => handleDelete(record.id)}>
<PermissionButton permission="classroom:edit" size="small" danger>
</PermissionButton>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 8,
}}
>
<Input.Search
allowClear
placeholder="搜索设备/SN/教室"
style={{ width: 260 }}
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
/>
<PermissionButton
permission="classroom:edit"
type="primary"
icon={<PlusOutlined />}
onClick={openCreate}
>
</PermissionButton>
</div>
{isError ? (
<QueryErrorState
title="考勤机数据加载失败"
description="请检查网络后重试。"
onRetry={() => void refetch()}
/>
) : (
<Table<AttendanceDeviceRow>
rowKey="id"
columns={columns}
dataSource={filteredData}
loading={loading}
locale={{
emptyText: (
<QueryEmpty
description="暂无考勤机绑定"
action={
hasPermission('classroom:edit')
? { label: '添加考勤机', icon: <PlusOutlined />, onClick: openCreate }
: undefined
}
/>
),
}}
pagination={{ defaultPageSize: 20, showSizeChanger: true }}
/>
)}
<Modal
title={editing ? '编辑考勤机绑定' : '添加考勤机绑定'}
open={modalOpen}
onOk={handleSave}
onCancel={() =>
formGuard.confirmClose(() => {
setModalOpen(false);
setEditing(null);
})
}
confirmLoading={saving}
okText="保存"
>
<Form form={form} layout="vertical">
<Form.Item
name="deviceName"
label="设备名称"
rules={[{ required: true, message: '请输入设备名称' }]}
>
<Input placeholder="如彼岸游境_N1604" />
</Form.Item>
<Form.Item
name="deviceSn"
label="SN 码"
rules={[{ required: true, message: '请输入钉钉返回的 deviceSN' }]}
>
<Input placeholder="如300419260325WN1604" />
</Form.Item>
<Form.Item
name="classroomId"
label="绑定教室"
rules={[{ required: true, message: '请选择绑定教室' }]}
>
<Select
showSearch
optionFilterProp="label"
options={classroomOptions}
placeholder="选择教室"
/>
</Form.Item>
<Form.Item name="status" label="状态" initialValue="active">
<Select
options={[
{ value: 'active', label: '启用' },
{ value: 'disabled', label: '停用' },
]}
/>
</Form.Item>
<Form.Item name="location" label="位置">
<Input placeholder="如:教学楼一楼东侧" />
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default AttendanceDevicesPage;