Merge branch 'dev'

This commit is contained in:
BenjaminNH 2025-07-31 11:38:31 +08:00
commit 002b39da3f
13 changed files with 350 additions and 20 deletions

View File

@ -1,10 +1,10 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React</title>
<title>电科院材料所实验室仪器设备预约系统</title>
</head>
<body>
<div id="root"></div>

View File

@ -1,7 +1,7 @@
{
"name": "equip-reserve-frontend",
"private": true,
"version": "1.0.0",
"version": "1.0.1",
"type": "module",
"scripts": {
"dev": "vite",

View File

@ -1,5 +1,6 @@
import {
ApartmentOutlined,
ContainerOutlined,
DesktopOutlined,
ExperimentOutlined,
FileDoneOutlined,
@ -14,13 +15,15 @@ const menuConfig = [
path: "/user/reserve",
label: "设备预约",
icon: DesktopOutlined,
roles: ["USER"],
roles: ["USER", "LEADER", "DEVICE_ADMIN"],
order: 4,
},
{
path: "/user/my-reservation",
label: "我的预约",
icon: UnorderedListOutlined,
roles: ["USER"],
icon: ContainerOutlined,
roles: ["USER", "LEADER", "DEVICE_ADMIN"],
order: 5,
},
{
@ -28,36 +31,43 @@ const menuConfig = [
label: "预约审批",
icon: FileDoneOutlined,
roles: ["LEADER", "DEVICE_ADMIN"],
order: 1,
},
{
path: "/my-approval",
label: "审批记录",
icon: UnorderedListOutlined,
roles: ["LEADER", "DEVICE_ADMIN"],
order: 2,
},
{
path: "/device-manage",
label: "设备管理",
icon: ExperimentOutlined,
roles: ["DEVICE_ADMIN"],
order: 3,
},
{
path: "/admin/user-manage",
label: "用户管理",
icon: UsergroupAddOutlined,
roles: ["ADMIN"],
order: 1,
},
{
label: "数据统计",
icon: PieChartOutlined,
order: 2,
children: [
{
path: "/admin/stats-device",
label: "设备统计",
order: 1,
},
{
path: "/admin/stats-reservation",
label: "使用人统计",
order: 2,
},
],
roles: ["ADMIN"],
@ -67,12 +77,14 @@ const menuConfig = [
icon: ApartmentOutlined,
label: "团队管理",
roles: ["ADMIN"],
order: 3,
},
{
path: "/userdetail",
label: "个人信息",
icon: UserOutlined,
roles: ["USER", "LEADER", "DEVICE_ADMIN", "ADMIN"],
order: 999,
},
];

View File

@ -44,8 +44,18 @@ export default function CommonLayout() {
}
};
//
const sortMenu = (menu) => {
return [...menu]
.sort((a, b) => (a.order ?? 999) - (b.order ?? 999))
.map((item) => ({
...item,
children: item.children ? sortMenu(item.children) : undefined,
}));
};
const buildMenuItems = (menu) =>
menu.map((item) => {
sortMenu(menu).map((item) => {
if (item.children) {
return {
key: item.label, // item.path

View File

@ -22,7 +22,9 @@ export default function Login() {
return (
<div className="w-screen h-screen flex justify-center items-center flex-col">
<h1 className="m-6 text-gray-800 font-mono text-4xl">xxxx设备预约系统</h1>
<h1 className="m-6 text-gray-800 font-mono text-4xl">
电科院材料所实验室仪器设备预约系统
</h1>
<Form
name="login"
autoComplete="off"

View File

@ -0,0 +1,129 @@
import { message, Modal, Space, Spin, Table } from "antd";
import { useEffect, useState } from "react";
import axiosInstance from "../../api/axios";
export default function DeviceDetailStatsModal({
visible,
record,
range,
onClose,
}) {
const [loading, setLoading] = useState(false);
const [data, setData] = useState([]);
const fetchData = async () => {
setLoading(true);
try {
const res = await axiosInstance.get("/device/detail-stats", {
params: {
deviceId: record.deviceId,
start: range[0].format("YYYY-MM-DD"),
end: range[1].format("YYYY-MM-DD"),
},
});
setData(res);
} catch (e) {
message.error("获取数据失败");
} finally {
setLoading(false);
}
};
const handleExport = async () => {
setLoading(true);
try {
const response = await axiosInstance.get("/device/detail-stats/export", {
params: {
deviceId: record.deviceId,
start: range[0].format("YYYY-MM-DD"),
end: range[1].format("YYYY-MM-DD"),
},
responseType: "blob", //
skipInterceptor: true,
});
//
const fileName = `${record?.deviceName}_${range[0].format(
"YYYY.MM.DD"
)}-${range[1].format("YYYY.MM.DD")}_使用详情.xlsx`;
// 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(() => {
if (visible) {
fetchData();
}
}, [visible, record]);
const columns = [
{
title: "使用人",
dataIndex: "applicantName",
key: "applicantName",
sorter: (a, b) => a.applicantName.localeCompare(b.applicantName),
},
{
title: "所属团队",
dataIndex: "applicantTeam",
key: "applicantTeam",
sorter: (a, b) => a.applicantTeam.localeCompare(b.applicantTeam),
},
{
title: "开始日期",
dataIndex: "startDay",
key: "startDay",
sorter: (a, b) => a.startDay.localeCompare(b.startDay),
},
{
title: "结束日期",
dataIndex: "endDay",
key: "endDay",
sorter: (a, b) => a.endDay.localeCompare(b.endDay),
},
];
return (
<Spin spinning={loading}>
<Modal
open={visible}
title={`${record?.deviceName}_${range[0].format(
"YYYY.MM.DD"
)}-${range[1].format("YYYY.MM.DD")}_使用详情`}
width={"80%"}
cancelText="返回"
onCancel={() => {
onClose();
}}
okText="导出Excel"
onOk={handleExport}
>
<Table
rowKey={(record) => record.deviceId}
dataSource={data}
columns={columns}
className="mt-4"
/>
</Modal>
</Spin>
);
}

View File

@ -3,6 +3,7 @@ import dayjs from "dayjs";
import { useEffect, useState } from "react";
import axiosInstance from "../../api/axios";
import { datePresets } from "../../config/datePresetsConfig";
import DeviceDetailStatsModal from "./DeviceDetailStatsModal";
const { RangePicker } = DatePicker;
@ -15,6 +16,8 @@ export default function DeviceStats() {
]);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(false);
const [visiable, setVisiable] = useState(false);
const [selectedRecord, setSelectedRecord] = useState(null);
const fetchData = async () => {
setLoading(true);
@ -107,6 +110,23 @@ export default function DeviceStats() {
key: "totalUsageDays",
sorter: (a, b) => a.totalUsageDays - b.totalUsageDays,
},
{
title: "操作",
key: "action",
render: (_, record) => {
return (
<Button
size="small"
onClick={() => {
setSelectedRecord(record);
setVisiable(true);
}}
>
查看详情
</Button>
);
},
},
];
return (
@ -135,6 +155,14 @@ export default function DeviceStats() {
dataSource={filteredData}
columns={columns}
/>
<DeviceDetailStatsModal
visible={visiable}
record={selectedRecord}
onClose={() => {
setVisiable(false);
}}
range={range}
/>
</Spin>
);
}

View File

@ -0,0 +1,33 @@
import { List, Modal, Typography } from "antd";
import { useEffect, useState } from "react";
import axiosInstance from "../../api/axios";
export default function TeamDetailModal({ open, team, onclose }) {
const [data, setData] = useState([]);
const fetchData = async () => {
const data = await axiosInstance.get(`/user-team/${team.id}`);
setData(data);
};
useEffect(() => {
if (open) {
fetchData();
}
}, [open, team]);
return (
<Modal
open={open}
onCancel={() => {
onclose();
}}
>
<List
header={<div>{team?.name}</div>}
dataSource={data}
renderItem={(item) => <List.Item>{item}</List.Item>}
/>
</Modal>
);
}

View File

@ -3,6 +3,7 @@ import Column from "antd/es/table/Column";
import { useEffect, useState } from "react";
import axiosInstance from "../../api/axios";
import TeamDeleteButton from "../../components/TeamDeleteButton";
import TeamDetailModal from "./TeamDetailModal";
export default function TeamManage() {
const [teams, setTeams] = useState([]);
@ -11,6 +12,8 @@ export default function TeamManage() {
const [editingId, setEditingId] = useState();
const [editingName, setEditingName] = useState("");
const [newTeamName, setNewTeamName] = useState();
const [open, setOpen] = useState(false);
const [selectedTeam, setSelectedTeam] = useState(null);
const fetchData = async () => {
const data = await axiosInstance.get("/teams");
@ -124,6 +127,15 @@ export default function TeamManage() {
编辑
</Button>
)}
<Button
size="small"
onClick={() => {
setSelectedTeam(record);
setOpen(true);
}}
>
人员列表
</Button>
<TeamDeleteButton
record={record}
onConfirm={() => handleDelete(record)}
@ -133,6 +145,14 @@ export default function TeamManage() {
}}
/>
</Table>
<TeamDetailModal
open={open}
team={selectedTeam}
onclose={() => {
setOpen(false);
setSelectedTeam(null);
}}
/>
</>
);
}

View File

@ -1,10 +1,11 @@
import { Button, Form, Input, Table, Tag } from "antd";
import { Button, DatePicker, Form, Input, message, Table, Tag } from "antd";
import { useForm } from "antd/es/form/Form";
import Column from "antd/es/table/Column";
import { useEffect, useState } from "react";
import { useSelector } from "react-redux";
import axiosInstance from "../../api/axios";
import { selectUserId } from "../../features/auth/authSlice";
import { selectUserId, selectUserRole } from "../../features/auth/authSlice";
import dayjs from "dayjs";
export default function MyApproval() {
const [approvals, setApprovals] = useState([]);
@ -14,8 +15,11 @@ export default function MyApproval() {
pageSize: 10,
total: 0,
});
const [editingRow, setEditingRow] = useState(null);
const [tempEndTime, setTempEndTime] = useState(null);
const userId = useSelector(selectUserId);
const userRole = useSelector(selectUserRole);
const fetchData = async (pagination, searchParam) => {
const data = await axiosInstance.get(`/approval/${userId}`, {
@ -53,6 +57,20 @@ export default function MyApproval() {
await fetchData(newPagination, values);
};
const handleSubmit = async (record) => {
try {
await axiosInstance.post(`/reservation/endTime/${record.reservationId}`, {
endTime: dayjs(tempEndTime).format("YYYY-MM-DD"),
});
const values = await form.validateFields();
await fetchData(pagination, values);
setEditingRow(null);
message.success("修改成功");
} catch (error) {
message.error("修改失败");
}
};
return (
<div className="p-2 pt-4">
<Form form={form} layout="inline" onFinish={handleSearch}>
@ -88,7 +106,57 @@ export default function MyApproval() {
/>
<Column title="预约设备" key="deviceName" dataIndex="deviceName" />
<Column title="开始时间" key="startTime" dataIndex="startTime" />
<Column title="结束时间" key="endTime" dataIndex="endTime" />
<Column
title="结束时间"
key="endTime"
render={(_, record) => {
const isEditable =
record.decision === 1 && userRole.includes("DEVICE_ADMIN");
if (editingRow === record.approvalId) {
return (
<div style={{ display: "flex", gap: 8 }}>
<DatePicker
defaultValue={dayjs(record.endTime)}
onChange={(date) => setTempEndTime(date)}
disabledDate={(current) =>
current &&
current.isBefore(dayjs(record.startTime), "day")
}
size="small"
/>
<Button
type="primary"
size="small"
onClick={() => handleSubmit(record)}
>
提交
</Button>
<Button onClick={() => setEditingRow(null)} size="small">
取消
</Button>
</div>
);
}
return isEditable ? (
<span>
<span className="pr-6">{record.endTime}</span>
<Button
size="small"
onClick={() => {
setEditingRow(record.approvalId);
setTempEndTime(dayjs(record.endTime));
}}
>
修改
</Button>
</span>
) : (
<span>{record.endTime}</span>
);
}}
/>
<Column
title="审批情况"
key="decision"

View File

@ -60,20 +60,27 @@ export default function DeviceDetailModal({ visiable, device, onclose }) {
}, [visiable, device?.deviceId]);
const { RangePicker } = DatePicker;
const disabledDate = (current) => {
const disabledDate = (current, { from } = {}) => {
if (!current) return false;
const today = dayjs().startOf("day");
const currentDay = current.startOf("day");
if (currentDay.isBefore(today)) {
return true;
}
dayjs.extend(isBetween);
return unavailableTimes.some(({ startTime, endTime }) => {
//
const isPastDate = current.isBefore(today);
//
dayjs.extend(isBetween);
const isUnavailable = unavailableTimes.some(({ startTime, endTime }) => {
const start = dayjs(startTime).startOf("day");
const end = dayjs(endTime).endOf("day");
return currentDay.isBetween(start, end, null, "[]");
});
// 7 from
const isExceedingRange = from && Math.abs(current.diff(from, "day")) >= 7;
return isPastDate || isUnavailable || isExceedingRange;
};
const handleOK = async () => {

View File

@ -58,6 +58,16 @@ export default function MyReservation() {
);
}}
/>
<Column
title="团队负责人"
key="deviceLeaderName"
dataIndex="deviceLeaderName"
/>
<Column
title="负责人联系方式"
key="deviceLeaderContact"
dataIndex="deviceLeaderContact"
/>
<Column
title="设备管理员"
key="deviceAdminName"
@ -68,8 +78,17 @@ export default function MyReservation() {
key="deviceAdminContact"
dataIndex="deviceAdminContact"
/>
<Column title="开始使用时间" key="startTime" dataIndex="startTime" />
<Column title="结束使用时间" key="endTime" dataIndex="endTime" />
<Column
title="使用时间"
key="usageTime"
render={(_, record) => {
return (
<span>
{record.startTime} {record.endTime}
</span>
);
}}
/>
<Column
title="预约状态"
key="statusLabel"

View File

@ -28,7 +28,9 @@ const router = createBrowserRouter([
children: [
{
path: "user",
element: <ProtectedRoute allowedRoles={["USER"]} />,
element: (
<ProtectedRoute allowedRoles={["USER", "LEADER", "DEVICE_ADMIN"]} />
),
children: [
{
path: "reserve",