281 lines
10 KiB
TypeScript
281 lines
10 KiB
TypeScript
import { Button, Card, Drawer, Form, Input, Popconfirm, Select, Space, Table, Tag, Typography, message } from "antd";
|
|
import { DeleteOutlined, DesktopOutlined, EditOutlined, PlusOutlined, SearchOutlined, UserOutlined } from "@ant-design/icons";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { createDevice, deleteDevice, listDevices, listUsers, updateDevice } from "@/api";
|
|
import PageHeader from "@/components/shared/PageHeader";
|
|
import { useDict } from "@/hooks/useDict";
|
|
import { usePermission } from "@/hooks/usePermission";
|
|
import type { DeviceInfo, SysUser } from "@/types";
|
|
import { getStandardPagination } from "@/utils/pagination";
|
|
import "./index.less";
|
|
|
|
const { Text } = Typography;
|
|
|
|
type DeviceFormValues = {
|
|
userId: number;
|
|
deviceCode: string;
|
|
deviceName?: string;
|
|
status: number;
|
|
};
|
|
|
|
export default function Devices() {
|
|
const { t } = useTranslation();
|
|
const { can } = usePermission();
|
|
const { items: statusDict } = useDict("sys_common_status");
|
|
const [loading, setLoading] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [searchText, setSearchText] = useState("");
|
|
|
|
const handleSearch = () => {};
|
|
|
|
const handleResetSearch = () => {
|
|
setSearchText("");
|
|
};
|
|
const [devices, setDevices] = useState<DeviceInfo[]>([]);
|
|
const [users, setUsers] = useState<SysUser[]>([]);
|
|
const [open, setOpen] = useState(false);
|
|
const [editing, setEditing] = useState<DeviceInfo | null>(null);
|
|
const [form] = Form.useForm<DeviceFormValues>();
|
|
|
|
const loadData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [deviceList, userList] = await Promise.all([listDevices(), listUsers()]);
|
|
setDevices(deviceList || []);
|
|
setUsers(userList || []);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, []);
|
|
|
|
const userMap = useMemo(() => {
|
|
const map: Record<number, SysUser> = {};
|
|
users.forEach((user) => {
|
|
map[user.userId] = user;
|
|
});
|
|
return map;
|
|
}, [users]);
|
|
|
|
const filteredData = useMemo(() => {
|
|
if (!searchText) {
|
|
return devices;
|
|
}
|
|
const lower = searchText.toLowerCase();
|
|
return devices.filter((device) => {
|
|
const owner = userMap[device.userId];
|
|
return (
|
|
device.deviceCode.toLowerCase().includes(lower) ||
|
|
(device.deviceName || "").toLowerCase().includes(lower) ||
|
|
(owner?.displayName || "").toLowerCase().includes(lower) ||
|
|
String(device.userId).includes(lower)
|
|
);
|
|
});
|
|
}, [devices, searchText, userMap]);
|
|
|
|
const openCreate = () => {
|
|
setEditing(null);
|
|
form.resetFields();
|
|
form.setFieldsValue({ status: 1 });
|
|
setOpen(true);
|
|
};
|
|
|
|
const openEdit = (record: DeviceInfo) => {
|
|
setEditing(record);
|
|
form.setFieldsValue({
|
|
userId: record.userId,
|
|
deviceCode: record.deviceCode,
|
|
deviceName: record.deviceName,
|
|
status: record.status ?? 1
|
|
});
|
|
setOpen(true);
|
|
};
|
|
|
|
const submit = async () => {
|
|
const values = await form.validateFields();
|
|
setSaving(true);
|
|
try {
|
|
const payload: Partial<DeviceInfo> = {
|
|
userId: values.userId,
|
|
deviceCode: values.deviceCode,
|
|
deviceName: values.deviceName,
|
|
status: values.status
|
|
};
|
|
|
|
if (editing) {
|
|
await updateDevice(editing.deviceId, payload);
|
|
} else {
|
|
await createDevice(payload);
|
|
}
|
|
|
|
message.success(t("devicesExt.operationSucceeded"));
|
|
setOpen(false);
|
|
await loadData();
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const remove = async (id: number) => {
|
|
await deleteDevice(id);
|
|
message.success(t("devicesExt.operationSucceeded"));
|
|
await loadData();
|
|
};
|
|
|
|
return (
|
|
<div className="app-page devices-page">
|
|
<PageHeader title={t("devices.title")} subtitle={t("devices.subtitle")} />
|
|
|
|
<Card className="devices-table-card app-page__filter-card" styles={{ body: { padding: "16px" } }}>
|
|
<div className="app-page__toolbar" style={{ justifyContent: "space-between", width: "100%" }}>
|
|
<div className="app-page__toolbar">
|
|
<Input
|
|
placeholder={t("devicesExt.searchPlaceholder")}
|
|
prefix={<SearchOutlined aria-hidden="true" />}
|
|
style={{ width: 360 }}
|
|
value={searchText}
|
|
onChange={(event) => setSearchText(event.target.value)}
|
|
allowClear
|
|
aria-label={t("devicesExt.searchLabel")}
|
|
/>
|
|
<Button type="primary" icon={<SearchOutlined aria-hidden="true" />} onClick={handleSearch}>{t("common.search")}</Button>
|
|
<Button onClick={handleResetSearch}>{t("common.reset")}</Button>
|
|
</div>
|
|
{can("device:create") ? (
|
|
<Button type="primary" icon={<PlusOutlined aria-hidden="true" />} onClick={openCreate}>
|
|
{t("common.create")}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="app-page__content-card flex-1 flex flex-col overflow-hidden" styles={{ body: { padding: 0, flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" } }}>
|
|
<Table<DeviceInfo>
|
|
rowKey="deviceId"
|
|
dataSource={filteredData}
|
|
loading={loading}
|
|
size="middle"
|
|
scroll={{ y: "calc(100vh - 350px)" }}
|
|
pagination={getStandardPagination(filteredData.length, 1, 1000)}
|
|
columns={[
|
|
{
|
|
title: t("devicesExt.device"),
|
|
key: "device",
|
|
render: (_value: unknown, record) => (
|
|
<Space>
|
|
<div className="device-icon-placeholder">
|
|
<DesktopOutlined aria-hidden="true" />
|
|
</div>
|
|
<div>
|
|
<div className="device-name font-medium">{record.deviceName || t("devicesExt.unnamedDevice")}</div>
|
|
<div className="device-code text-xs text-gray-400 tabular-nums">{record.deviceCode}</div>
|
|
</div>
|
|
</Space>
|
|
)
|
|
},
|
|
{
|
|
title: t("devices.owner"),
|
|
key: "user",
|
|
render: (_value: unknown, record) => {
|
|
const owner = userMap[record.userId];
|
|
return owner ? (
|
|
<Space>
|
|
<UserOutlined aria-hidden="true" style={{ color: "#8c8c8c" }} />
|
|
<span>{owner.displayName}</span>
|
|
<Text type="secondary" style={{ fontSize: "12px" }} className="tabular-nums">
|
|
({t("devicesExt.ownerId")}: {record.userId})
|
|
</Text>
|
|
</Space>
|
|
) : (
|
|
<span className="tabular-nums">{t("devicesExt.ownerId")}: {record.userId}</span>
|
|
);
|
|
}
|
|
},
|
|
{
|
|
title: t("common.status"),
|
|
dataIndex: "status",
|
|
width: 100,
|
|
render: (status: number) => {
|
|
const item = statusDict.find((dictItem) => dictItem.itemValue === String(status));
|
|
return <Tag color={status === 1 ? "green" : "red"}>{item?.itemLabel || (status === 1 ? t("devicesExt.enabled") : t("devicesExt.disabled"))}</Tag>;
|
|
}
|
|
},
|
|
{
|
|
title: t("devices.updateTime"),
|
|
dataIndex: "updatedAt",
|
|
width: 180,
|
|
render: (text: string) => (
|
|
<Text type="secondary" className="tabular-nums">
|
|
{text?.replace("T", " ").substring(0, 19)}
|
|
</Text>
|
|
)
|
|
},
|
|
{
|
|
title: t("common.action"),
|
|
key: "action",
|
|
width: 120,
|
|
fixed: "right",
|
|
render: (_value: unknown, record) => (
|
|
<Space>
|
|
{can("device:update") ? (
|
|
<Button type="text" icon={<EditOutlined aria-hidden="true" />} onClick={() => openEdit(record)} aria-label={t("devicesExt.editDevice")} />
|
|
) : null}
|
|
{can("device:delete") ? (
|
|
<Popconfirm title={t("devicesExt.deleteDevice")} onConfirm={() => remove(record.deviceId)}>
|
|
<Button type="text" danger icon={<DeleteOutlined aria-hidden="true" />} aria-label={t("common.delete")} />
|
|
</Popconfirm>
|
|
) : null}
|
|
</Space>
|
|
)
|
|
}
|
|
]}
|
|
/>
|
|
</Card>
|
|
|
|
<Drawer
|
|
title={
|
|
<div className="device-drawer-title">
|
|
<DesktopOutlined className="mr-2" aria-hidden="true" />
|
|
{editing ? t("devices.drawerTitleEdit") : t("devicesExt.drawerTitleCreate")}
|
|
</div>
|
|
}
|
|
open={open}
|
|
onClose={() => setOpen(false)}
|
|
width={420}
|
|
destroyOnClose
|
|
footer={
|
|
<div className="app-page__drawer-footer">
|
|
<Button onClick={() => setOpen(false)}>{t("common.cancel")}</Button>
|
|
<Button type="primary" loading={saving} onClick={submit}>
|
|
{t("common.save")}
|
|
</Button>
|
|
</div>
|
|
}
|
|
>
|
|
<Form form={form} layout="vertical">
|
|
<Form.Item label={t("devices.owner")} name="userId" rules={[{ required: true, message: t("devicesExt.selectOwner") }]}>
|
|
<Select
|
|
showSearch
|
|
placeholder={t("devicesExt.searchSelectUser")}
|
|
optionFilterProp="label"
|
|
options={users.map((user) => ({ label: `${user.displayName} (@${user.username})`, value: user.userId }))}
|
|
/>
|
|
</Form.Item>
|
|
<Form.Item label={t("devices.deviceCode")} name="deviceCode" rules={[{ required: true, message: t("devicesExt.deviceCodeRequired") }]}>
|
|
<Input placeholder={t("devicesExt.deviceCodePlaceholder")} />
|
|
</Form.Item>
|
|
<Form.Item label={t("devices.deviceName")} name="deviceName">
|
|
<Input placeholder={t("devicesExt.deviceNamePlaceholder")} />
|
|
</Form.Item>
|
|
<Form.Item label={t("common.status")} name="status" initialValue={1}>
|
|
<Select options={statusDict.map((item) => ({ value: Number(item.itemValue), label: item.itemLabel }))} />
|
|
</Form.Item>
|
|
</Form>
|
|
</Drawer>
|
|
</div>
|
|
);
|
|
} |