Compare commits

...

2 Commits

Author SHA1 Message Date
8a5f231ae2 feat: 实现管理员设备统计页 2025-07-02 22:15:25 +08:00
00085874c9 feat: 实现管理员用户管理页面 2025-07-02 17:06:52 +08:00
12 changed files with 599 additions and 21 deletions

View File

@ -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) {

View 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"),
],
},
];

View File

@ -2,7 +2,9 @@ import {
DesktopOutlined, DesktopOutlined,
ExperimentOutlined, ExperimentOutlined,
FileDoneOutlined, FileDoneOutlined,
PieChartOutlined,
UnorderedListOutlined, UnorderedListOutlined,
UsergroupAddOutlined,
UserOutlined, UserOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
@ -38,11 +40,32 @@ const menuConfig = [
icon: ExperimentOutlined, icon: ExperimentOutlined,
roles: ["DEVICE_ADMIN"], roles: ["DEVICE_ADMIN"],
}, },
{
path: "/admin/user-manage",
label: "用户管理",
icon: UsergroupAddOutlined,
roles: ["ADMIN"],
},
{
label: "数据统计",
icon: PieChartOutlined,
children: [
{
path: "/admin/stats-device",
label: "设备统计",
},
{
path: "/admin/stats-user",
label: "使用人统计",
},
],
roles: ["ADMIN"],
},
{ {
path: "/userdetail", path: "/userdetail",
label: "个人信息", label: "个人信息",
icon: UserOutlined, icon: UserOutlined,
roles: ["USER", "LEADER", "DEVICE_ADMIN", "ADMIN"], roles: ["USER", "LEADER", "DEVICE_ADMIN"],
}, },
]; ];

View File

@ -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>

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

View File

@ -0,0 +1,132 @@
import { Form, Input, message, Modal, Select } from "antd";
import { useEffect, useState } from "react";
import axiosInstance from "../../api/axios";
import Password from "antd/es/input/Password";
export default function UserDetailModal({
visiable,
mode = "create",
user,
onclose,
onSuccess,
}) {
const [form] = Form.useForm();
const [initialValues, setInitialValues] = useState();
const [teams, setTeams] = useState([]);
const [roles, setRoles] = useState([]);
const fetchTeams = async () => {
const data = await axiosInstance.get("/teams");
setTeams(data);
};
const fetchRoles = async () => {
const data = await axiosInstance.get("/role");
setRoles(data);
};
useEffect(() => {
fetchTeams();
fetchRoles();
}, []);
useEffect(() => {
if (visiable) {
if (mode === "edit") {
const values = {
username: user.username,
name: user.name,
phone: user.phone,
teamId: user.teamId,
roleId: user.roleId,
};
setInitialValues(values);
form.setFieldsValue(values);
} else {
const values = {
username: undefined,
password: undefined,
name: undefined,
phone: undefined,
teamId: undefined,
roleId: undefined,
};
setInitialValues(values);
form.setFieldsValue(values);
}
}
}, [visiable, mode, user, form]);
const handleOk = async () => {
const values = await form.validateFields();
const data = {};
Object.keys(initialValues).forEach((key) => {
if (values[key] !== initialValues[key]) {
data[key] = values[key];
}
});
if (Object.keys(data).length > 0) {
if (mode === "edit") {
await axiosInstance.put(`/user/${user.userId}`, data);
message.success("编辑成功");
} else {
await axiosInstance.post(`/user`, data);
message.success("添加成功");
}
}
onSuccess();
onclose();
};
return (
<Modal
title={mode === "edit" ? "编辑用户" : "添加用户"}
open={visiable}
onCancel={() => {
onclose();
}}
onOk={handleOk}
okText="保存"
>
<Form form={form} layout="vertical" initialValues={initialValues}>
<Form.Item
name="username"
label="用户账号"
rules={[{ required: true }]}
>
<Input />
</Form.Item>
{mode === "create" && (
<Form.Item
name="password"
label="初始密码"
rules={[{ required: true }]}
>
<Input.Password />
</Form.Item>
)}
<Form.Item name="name" label="姓名" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="phone" label="联系电话">
<Input />
</Form.Item>
<Form.Item
name="teamId"
label="所属团队"
rules={[{ required: true, message: "请选择所属团队" }]}
>
<Select options={teams} />
</Form.Item>
<Form.Item
name="roleId"
label="用户角色"
rules={[{ required: true, message: "请选择用户角色" }]}
>
<Select options={roles} />
</Form.Item>
</Form>
</Modal>
);
}

View File

@ -0,0 +1,147 @@
import { Button, Flex, Input, Popconfirm, Space, Table } from "antd";
import Column from "antd/es/table/Column";
import { useEffect, useState } from "react";
import axiosInstance from "../../api/axios";
import DeviceDetailModal from "../deviceAdmin/DeviceDetailModal";
import UserDetailModal from "./UserDetailModal";
export default function UserManage() {
const [users, setUsers] = useState([]);
const [teams, setTeams] = useState([]);
const [modalMode, setModalMode] = useState();
const [selectedUser, setSelectedUser] = useState();
const [modalOpen, setModalOpen] = useState(false);
const [pagination, setPagination] = useState({
current: 1,
pageSize: 10,
total: 0,
});
const fetchData = async (pagination, name) => {
const data = await axiosInstance.get("/user", {
params: {
page: pagination.current,
size: pagination.pageSize,
name: name,
},
});
setUsers(data.records);
setPagination({
...pagination,
total: data.total,
});
};
const fetchTeams = async () => {
const data = await axiosInstance.get("/teams");
setTeams(data);
};
useEffect(() => {
// fetchTeams();
fetchData({
current: 1,
pageSize: 10,
});
}, []);
const handlePageChange = async (pagination) => {
await fetchData(pagination);
};
const handleSearch = async (value) => {
await fetchData(
{
...pagination,
current: 1,
},
value
);
};
const handleDelete = async (record) => {
await axiosInstance.delete(`/user/${record.userId}`);
fetchData({
current: 1,
pageSize: 10,
});
};
//
return (
<>
<Flex justify="space-between">
<Input.Search
placeholder="请输入用户姓名"
onSearch={handleSearch}
style={{ width: "200px" }}
className="m-4"
/>
<Button
className="m-4"
type="primary"
onClick={() => {
setModalMode("create");
setSelectedUser(null);
setModalOpen(true);
}}
>
添加用户
</Button>
</Flex>
<Table
rowKey="userId"
dataSource={users}
pagination={pagination}
onChange={handlePageChange}
>
<Column title="姓名" key="name" dataIndex="name" />
<Column title="账号" key="username" dataIndex="username" />
<Column title="联系电话" key="phone" dataIndex="phone" />
<Column title="所属团队" key="team" dataIndex="team" />
<Column
title="操作"
render={(_, record) => {
return (
<Space>
<Button
size="small"
onClick={() => {
setSelectedUser(record);
setModalOpen(true);
setModalMode("edit");
}}
>
编辑
</Button>
<Popconfirm
title="删除用户"
description="确认要删除该用户吗?"
onConfirm={() => handleDelete(record)}
>
<Button danger size="small">
删除
</Button>
</Popconfirm>
</Space>
);
}}
/>
</Table>
<UserDetailModal
visiable={modalOpen}
mode={modalMode}
user={selectedUser}
onclose={() => {
setModalOpen(false);
setSelectedUser(null);
}}
onSuccess={async () => {
await fetchData({
...pagination,
current: 1,
});
}}
/>
</>
);
}

View File

@ -68,7 +68,6 @@ export default function DeviceDetailModal({
message.success("编辑成功"); message.success("编辑成功");
} else { } else {
device = await axiosInstance.post(`/device/${userId}`, data); device = await axiosInstance.post(`/device/${userId}`, data);
console.log(device);
message.success("添加成功"); message.success("添加成功");
} }

View File

@ -1,4 +1,13 @@
import { Button, Flex, Input, message, Space, Table, Tag } from "antd"; import {
Button,
Flex,
Input,
message,
Popconfirm,
Space,
Table,
Tag,
} from "antd";
import Column from "antd/es/table/Column"; import Column from "antd/es/table/Column";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useSelector } from "react-redux"; import { useSelector } from "react-redux";
@ -132,14 +141,15 @@ export default function DeviceManage() {
> >
编辑 编辑
</Button> </Button>
<Button <Popconfirm
color="red" title="删除设备"
variant="solid" description="确认要删除该设备吗?"
size="small" onConfirm={() => handleDelete(record.deviceId)}
onClick={() => handleDelete(record.deviceId)}
> >
删除 <Button color="red" variant="solid" size="small">
</Button> 删除
</Button>
</Popconfirm>
</Space> </Space>
); );
}} }}
@ -154,7 +164,10 @@ export default function DeviceManage() {
setSelectedDevice(null); setSelectedDevice(null);
}} }}
onSuccess={async () => { onSuccess={async () => {
await fetchData(pagination); await fetchData({
...pagination,
current: 1,
});
}} }}
/> />
</> </>

View File

@ -9,6 +9,7 @@ export default function UserDetail() {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [user, setUser] = useState({ const [user, setUser] = useState({
userId: "",
username: "", username: "",
team: "", team: "",
name: "", name: "",
@ -43,7 +44,7 @@ export default function UserDetail() {
} }
delete changedFields.confirmPassword; delete changedFields.confirmPassword;
const newUser = await axiosInstance.patch(`/user/${userId}`, changedFields); const newUser = await axiosInstance.put(`/user/${userId}`, changedFields);
setUser(newUser); setUser(newUser);
form.setFieldsValue(newUser); form.setFieldsValue(newUser);
message.success("修改成功"); message.success("修改成功");

View File

@ -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>

View File

@ -8,6 +8,8 @@ import MyReservation from "../pages/user/MyReservation";
import Reserve from "../pages/user/Reserve"; 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 DeviceStats from "../pages/admin/DeviceStats";
const router = createBrowserRouter([ const router = createBrowserRouter([
{ {
@ -59,6 +61,20 @@ const router = createBrowserRouter([
}, },
], ],
}, },
{
path: "admin",
element: <ProtectedRoute allowedRoles={["ADMIN"]} />,
children: [
{
path: "user-manage",
element: <UserManage />,
},
{
path: "stats-device",
element: <DeviceStats />,
},
],
},
{ {
path: "userdetail", path: "userdetail",
element: <UserDetail />, element: <UserDetail />,