imeeting/frontend/src/pages/devices/index.tsx

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>
);
}