Standards 轴: - 移除 uiArtifacts.ts 的 payloadOf 死代码残留 - AttendanceDevices 残留 any 类型化(补 ClassroomOption.status 字段) - 批量考勤纠错区分业务失败(已结算/无权限)与系统错误, 前端提示精确到两类数量 - Dashboard queryFn 六段重复校验块收敛为 safeValidate 助手 Spec 轴: - 补齐阶段 3.4 A2UI 测试:图表空数据占位、ArtifactErrorBoundary 降级隔离、useSubmissionState/useXCardSurface 单测(7 用例) - 阶段 2.2 补两处引导:教师工作台区分「今日无课」与「未分配班级」、 班级花名册空态带「添加学员」动作 - 契约文档修正 DynamicReview 状态管理描述(多提交点如实说明) aislop 剩余 16 警告均为必要豁免(类型边界/声明式 SQL 配置/既有文件规模)
403 lines
12 KiB
TypeScript
403 lines
12 KiB
TypeScript
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;
|
||
status?: string;
|
||
}
|
||
|
||
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: ClassroomOption) => 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;
|