feat: 实现管理员设备统计页
This commit is contained in:
parent
00085874c9
commit
8a5f231ae2
@ -22,6 +22,11 @@ axiosInstance.interceptors.request.use(
|
|||||||
|
|
||||||
axiosInstance.interceptors.response.use(
|
axiosInstance.interceptors.response.use(
|
||||||
(response) => {
|
(response) => {
|
||||||
|
const config = response.config;
|
||||||
|
// 如果设置了跳过,直接返回原始 response
|
||||||
|
if (config.skipInterceptor) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
const { code, message: msg, data } = response.data;
|
const { code, message: msg, data } = response.data;
|
||||||
|
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
|
40
src/config/datePresetsConfig.js
Normal file
40
src/config/datePresetsConfig.js
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import dayjs from "dayjs";
|
||||||
|
import quarterOfYear from "dayjs/plugin/quarterOfYear";
|
||||||
|
|
||||||
|
dayjs.extend(quarterOfYear);
|
||||||
|
|
||||||
|
export const datePresets = [
|
||||||
|
{
|
||||||
|
label: "本月",
|
||||||
|
value: [dayjs().startOf("month"), dayjs().endOf("month")],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "上月",
|
||||||
|
value: [
|
||||||
|
dayjs().subtract(1, "month").startOf("month"),
|
||||||
|
dayjs().subtract(1, "month").endOf("month"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "本季度",
|
||||||
|
value: [dayjs().startOf("quarter"), dayjs().endOf("quarter")],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "上季度",
|
||||||
|
value: [
|
||||||
|
dayjs().subtract(1, "quarter").startOf("quarter"),
|
||||||
|
dayjs().subtract(1, "quarter").endOf("quarter"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "本年",
|
||||||
|
value: [dayjs().startOf("year"), dayjs().endOf("year")],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "去年",
|
||||||
|
value: [
|
||||||
|
dayjs().subtract(1, "year").startOf("year"),
|
||||||
|
dayjs().subtract(1, "year").endOf("year"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
@ -2,6 +2,7 @@ import {
|
|||||||
DesktopOutlined,
|
DesktopOutlined,
|
||||||
ExperimentOutlined,
|
ExperimentOutlined,
|
||||||
FileDoneOutlined,
|
FileDoneOutlined,
|
||||||
|
PieChartOutlined,
|
||||||
UnorderedListOutlined,
|
UnorderedListOutlined,
|
||||||
UsergroupAddOutlined,
|
UsergroupAddOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
@ -45,6 +46,21 @@ const menuConfig = [
|
|||||||
icon: UsergroupAddOutlined,
|
icon: UsergroupAddOutlined,
|
||||||
roles: ["ADMIN"],
|
roles: ["ADMIN"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "数据统计",
|
||||||
|
icon: PieChartOutlined,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "/admin/stats-device",
|
||||||
|
label: "设备统计",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/admin/stats-user",
|
||||||
|
label: "使用人统计",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
roles: ["ADMIN"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/userdetail",
|
path: "/userdetail",
|
||||||
label: "个人信息",
|
label: "个人信息",
|
||||||
|
@ -44,6 +44,24 @@ export default function CommonLayout() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildMenuItems = (menu) =>
|
||||||
|
menu.map((item) => {
|
||||||
|
if (item.children) {
|
||||||
|
return {
|
||||||
|
key: item.label, // 可改为 item.path 但注意不冲突
|
||||||
|
label: item.label,
|
||||||
|
icon: item.icon ? React.createElement(item.icon) : null,
|
||||||
|
children: buildMenuItems(item.children),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
key: item.path,
|
||||||
|
label: item.label,
|
||||||
|
icon: item.icon ? React.createElement(item.icon) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout style={{ minHeight: "100vh" }}>
|
<Layout style={{ minHeight: "100vh" }}>
|
||||||
<Sider
|
<Sider
|
||||||
@ -56,11 +74,7 @@ export default function CommonLayout() {
|
|||||||
theme="dark"
|
theme="dark"
|
||||||
selectedKeys={[location.pathname]}
|
selectedKeys={[location.pathname]}
|
||||||
mode="inline"
|
mode="inline"
|
||||||
items={menu.map((item) => ({
|
items={buildMenuItems(menu)}
|
||||||
key: item.path,
|
|
||||||
label: item.label,
|
|
||||||
icon: React.createElement(item.icon),
|
|
||||||
}))}
|
|
||||||
onClick={({ key }) => naviagte(key)}
|
onClick={({ key }) => naviagte(key)}
|
||||||
/>
|
/>
|
||||||
</Sider>
|
</Sider>
|
||||||
|
140
src/pages/admin/DeviceStats.jsx
Normal file
140
src/pages/admin/DeviceStats.jsx
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
import { Button, DatePicker, Input, Space, Spin, Table, message } from "antd";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import axiosInstance from "../../api/axios";
|
||||||
|
import { datePresets } from "../../config/datePresetsConfig";
|
||||||
|
|
||||||
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
|
export default function DeviceStats() {
|
||||||
|
const [data, setData] = useState([]);
|
||||||
|
const [filteredData, setFilteredData] = useState([]);
|
||||||
|
const [range, setRange] = useState([
|
||||||
|
dayjs().startOf("month"),
|
||||||
|
dayjs().endOf("month"),
|
||||||
|
]);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await axiosInstance.get("/device/usage-stats", {
|
||||||
|
params: {
|
||||||
|
start: range[0].format("YYYY-MM-DD"),
|
||||||
|
end: range[1].format("YYYY-MM-DD"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setData(res);
|
||||||
|
setFilteredData(res);
|
||||||
|
} catch (e) {
|
||||||
|
message.error("获取数据失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = (value) => {
|
||||||
|
setSearch(value);
|
||||||
|
const filtered = data.filter((item) =>
|
||||||
|
item.deviceName.toLowerCase().includes(value.toLowerCase())
|
||||||
|
);
|
||||||
|
setFilteredData(filtered);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await axiosInstance.get("/device/usage-stats/export", {
|
||||||
|
params: {
|
||||||
|
start: range[0].format("YYYY-MM-DD"),
|
||||||
|
end: range[1].format("YYYY-MM-DD"),
|
||||||
|
},
|
||||||
|
responseType: "blob", // 二进制流
|
||||||
|
skipInterceptor: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 从响应头获取文件名,兼容后端返回
|
||||||
|
const disposition = response.headers["content-disposition"];
|
||||||
|
let fileName = "设备统计数据.xlsx";
|
||||||
|
if (disposition) {
|
||||||
|
const match = disposition.match(/filename="?([^"]+)"?/);
|
||||||
|
if (match && match[1]) fileName = decodeURIComponent(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建blob对象和下载链接
|
||||||
|
const blob = new Blob([response.data], {
|
||||||
|
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
});
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = fileName;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
message.success("导出成功");
|
||||||
|
} catch (error) {
|
||||||
|
message.error("导出失败,请稍后重试");
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [range]);
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: "设备名称",
|
||||||
|
dataIndex: "deviceName",
|
||||||
|
key: "deviceName",
|
||||||
|
sorter: (a, b) => a.deviceName.localeCompare(b.deviceName),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "使用次数",
|
||||||
|
dataIndex: "usageCount",
|
||||||
|
key: "usageCount",
|
||||||
|
sorter: (a, b) => a.usageCount - b.usageCount,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "使用总时长(天)",
|
||||||
|
dataIndex: "totalUsageDays",
|
||||||
|
key: "totalUsageDays",
|
||||||
|
sorter: (a, b) => a.totalUsageDays - b.totalUsageDays,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Spin spinning={loading}>
|
||||||
|
<Space className="p-4">
|
||||||
|
<RangePicker
|
||||||
|
value={range}
|
||||||
|
onChange={(dates) => {
|
||||||
|
if (dates) setRange(dates);
|
||||||
|
}}
|
||||||
|
presets={datePresets}
|
||||||
|
/>
|
||||||
|
<Input.Search
|
||||||
|
allowClear
|
||||||
|
placeholder="搜索设备名称"
|
||||||
|
onSearch={handleSearch}
|
||||||
|
style={{ width: 200 }}
|
||||||
|
/>
|
||||||
|
<Button type="primary" onClick={handleDownload}>
|
||||||
|
导出 Excel
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey={(record) => record.deviceId}
|
||||||
|
dataSource={filteredData}
|
||||||
|
columns={columns}
|
||||||
|
/>
|
||||||
|
</Spin>
|
||||||
|
);
|
||||||
|
}
|
@ -1,10 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
DatePicker,
|
DatePicker,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
|
Divider,
|
||||||
Form,
|
Form,
|
||||||
Image,
|
Image,
|
||||||
|
Input,
|
||||||
message,
|
message,
|
||||||
Modal,
|
Modal,
|
||||||
|
Select,
|
||||||
Space,
|
Space,
|
||||||
} from "antd";
|
} from "antd";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
@ -15,6 +18,35 @@ import axiosInstance, { baseURL } from "../../api/axios";
|
|||||||
|
|
||||||
export default function DeviceDetailModal({ visiable, device, onclose }) {
|
export default function DeviceDetailModal({ visiable, device, onclose }) {
|
||||||
const [unavailableTimes, setUnavailableTims] = useState([]);
|
const [unavailableTimes, setUnavailableTims] = useState([]);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const userId = useSelector((state) => state.auth.userId);
|
||||||
|
const [teams, setTeams] = useState([]);
|
||||||
|
const [initialValues, setInitialValues] = useState();
|
||||||
|
|
||||||
|
const fetchTeams = async () => {
|
||||||
|
const data = await axiosInstance.get("/teams");
|
||||||
|
const teams = data.map((item) => ({
|
||||||
|
label: item.label,
|
||||||
|
value: item.label,
|
||||||
|
}));
|
||||||
|
setTeams(teams);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchUser = async () => {
|
||||||
|
const data = await axiosInstance.get(`/userdetail/${userId}`);
|
||||||
|
const values = {
|
||||||
|
name: data.name,
|
||||||
|
phone: data.phone,
|
||||||
|
team: data.team,
|
||||||
|
};
|
||||||
|
setInitialValues(values);
|
||||||
|
form.setFieldsValue(values);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTeams();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchUnavailableTimes = async (id) => {
|
const fetchUnavailableTimes = async (id) => {
|
||||||
const data = await axiosInstance.get(`/device/unavailable-times/${id}`);
|
const data = await axiosInstance.get(`/device/unavailable-times/${id}`);
|
||||||
@ -23,6 +55,7 @@ export default function DeviceDetailModal({ visiable, device, onclose }) {
|
|||||||
|
|
||||||
if (visiable && device?.deviceId) {
|
if (visiable && device?.deviceId) {
|
||||||
fetchUnavailableTimes(device.deviceId);
|
fetchUnavailableTimes(device.deviceId);
|
||||||
|
fetchUser();
|
||||||
}
|
}
|
||||||
}, [visiable, device?.deviceId]);
|
}, [visiable, device?.deviceId]);
|
||||||
|
|
||||||
@ -43,9 +76,6 @@ export default function DeviceDetailModal({ visiable, device, onclose }) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const [form] = Form.useForm();
|
|
||||||
const userId = useSelector((state) => state.auth.userId);
|
|
||||||
|
|
||||||
const handleOK = async () => {
|
const handleOK = async () => {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
|
|
||||||
@ -56,6 +86,9 @@ export default function DeviceDetailModal({ visiable, device, onclose }) {
|
|||||||
userId,
|
userId,
|
||||||
startTime: startTime.format("YYYY-MM-DD"),
|
startTime: startTime.format("YYYY-MM-DD"),
|
||||||
endTime: endTime.format("YYYY-MM-DD"),
|
endTime: endTime.format("YYYY-MM-DD"),
|
||||||
|
applicantName: values.name,
|
||||||
|
applicantContact: values.phone,
|
||||||
|
applicantTeam: values.team,
|
||||||
};
|
};
|
||||||
|
|
||||||
await axiosInstance.post("/reservation", payload);
|
await axiosInstance.post("/reservation", payload);
|
||||||
@ -87,7 +120,9 @@ export default function DeviceDetailModal({ visiable, device, onclose }) {
|
|||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
|
||||||
<Form form={form} layout="inline" className="mt-14">
|
<Divider />
|
||||||
|
|
||||||
|
<Form form={form} layout="vertical" initialValues={initialValues}>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="date"
|
name="date"
|
||||||
label="预约日期"
|
label="预约日期"
|
||||||
@ -95,6 +130,19 @@ export default function DeviceDetailModal({ visiable, device, onclose }) {
|
|||||||
>
|
>
|
||||||
<RangePicker disabledDate={disabledDate} />
|
<RangePicker disabledDate={disabledDate} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item name="name" label="姓名" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="phone" label="联系电话">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="team"
|
||||||
|
label="所属团队"
|
||||||
|
rules={[{ required: true, message: "请选择所属团队" }]}
|
||||||
|
>
|
||||||
|
<Select options={teams} />
|
||||||
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Space>
|
</Space>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
@ -9,6 +9,7 @@ import Reserve from "../pages/user/Reserve";
|
|||||||
import ProtectedRoute from "./ProtectedRoute";
|
import ProtectedRoute from "./ProtectedRoute";
|
||||||
import DeviceManage from "../pages/deviceAdmin/DeviceManage";
|
import DeviceManage from "../pages/deviceAdmin/DeviceManage";
|
||||||
import UserManage from "../pages/admin/UserManage";
|
import UserManage from "../pages/admin/UserManage";
|
||||||
|
import DeviceStats from "../pages/admin/DeviceStats";
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
@ -68,6 +69,10 @@ const router = createBrowserRouter([
|
|||||||
path: "user-manage",
|
path: "user-manage",
|
||||||
element: <UserManage />,
|
element: <UserManage />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "stats-device",
|
||||||
|
element: <DeviceStats />,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
Loading…
x
Reference in New Issue
Block a user