Files
gongxue-base/apps/admin/src/pages/AttendanceDevices.tsx
wangziqi 4b60e0c018 fix: code-review 审查问题修复 + A2UI 测试补齐
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 配置/既有文件规模)
2026-08-08 09:43:48 +08:00

403 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;
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;