This commit is contained in:
dzc committed 2025-10-15 11:12:59 +08:00
commit 8dba6a0894
34 files changed
+1934 -501

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
# EditorConfig is awesome: https://EditorConfig.org
# 表示是最顶层的配置文件
root = true
# 对所有文件生效
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
# 对 JS、TS、Vue 文件的配置
[*.{js,jsx,ts,tsx,vue}]
indent_style = tab
indent_size = 2
# 对 JSON、YAML 文件的配置
[*.{json,yml,yaml}]
indent_style = tab
indent_size = 2
# 对 Markdown 文件的配置
[*.md]
trim_trailing_whitespace = false
+37
View File
@@ -0,0 +1,37 @@
# 设置默认行为,以防人们没有设置 core.autocrlf
* text=auto
# 明确声明你想要始终规范化并在检出时转换为原生行结束符的文本文件
*.vue text eol=lf
*.js text eol=lf
*.ts text eol=lf
*.jsx text eol=lf
*.tsx text eol=lf
*.json text eol=lf
*.css text eol=lf
*.scss text eol=lf
*.sass text eol=lf
*.html text eol=lf
*.htm text eol=lf
*.xml text eol=lf
*.md text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
# 明确声明你不想规范化并希望原样保留的文件
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.svg binary
*.eot binary
*.ttf binary
*.woff binary
*.woff2 binary
*.otf binary
+2 -1
View File
@@ -4,5 +4,6 @@
"trailingComma": "all",
"semi": false,
"singleQuote": false,
"printWidth": 140
"printWidth": 140,
"endOfLine": "lf"
}
+9
View File
@@ -39,3 +39,12 @@ export function putObj(obj?: Object) {
})
}
// 获取会议室预约详情页面数据
export function getBookingDetailPage(roomId: string, date: string) {
return request({
url: `integrated/meetingRoom/app/booking-detail-page/${roomId}`,
method: 'get',
params: { date }
})
}
+99
View File
@@ -0,0 +1,99 @@
import request from '/@/utils/request';
// 会议室预约统计查询参数
export interface MeetingStatsQuery {
startDate?: string; // 开始日期,格式:yyyy-MM-dd
endDate?: string; // 结束日期,格式:yyyy-MM-dd
dimension?: string; // 统计维度:dept-按部门统计,assignee-按负责人统计,time-按时间统计
timeGranularity?: string; // 时间粒度:day-按天,week-按周,month-按月
deptId?: number; // 部门ID,用于筛选特定部门
roomId?: number; // 会议室ID,用于筛选特定会议室
}
// 总体统计数据
export interface OverallStats {
totalBookings: number; // 总预约数
approvedBookings: number; // 审核通过数
pendingBookings: number; // 审核中数
rejectedBookings: number; // 审核拒绝数
cancelledBookings: number; // 已取消数
approvalRate: number; // 通过率(百分比)
avgDuration: number; // 平均会议时长(分钟)
totalDuration: number; // 总会议时长(分钟)
}
// 部门统计数据
export interface DeptStats {
deptId: number; // 部门ID
deptName: string; // 部门名称(通过部门微服务获取)
bookingCount: number; // 预约数量
approvedCount: number; // 通过数量
approvalRate: number; // 通过率
totalDuration: number; // 总会议时长
}
// 负责人统计数据
export interface AssigneeStats {
assigneeName: string; // 负责人姓名
bookingCount: number; // 预约数量
approvedCount: number; // 通过数量
approvalRate: number; // 通过率
totalDuration: number; // 总会议时长
}
// 时间统计数据
export interface TimeStats {
timeLabel: string; // 时间标签(格式化后的时间显示)
date?: string; // 日期(仅按天统计时有值)
bookingCount: number; // 预约数量
approvedCount: number; // 通过数量
totalDuration: number; // 总会议时长
}
// 状态统计数据
export interface StatusStats {
status: string; // 状态码(1-审核中,2-审核通过,3-审核拒绝,4-已取消,5-取消,0-已到访)
statusName: string; // 状态名称
count: number; // 数量
percentage: number; // 占比(百分比)
}
// 会议室统计数据
export interface RoomStats {
roomId: number; // 会议室ID
roomName: string; // 会议室名称
bookingCount: number; // 预约数量
approvedCount: number; // 通过数量
usageRate: number; // 使用率
totalDuration: number; // 总会议时长
}
// 统计结果数据
export interface MeetingStatsData {
overallStats: OverallStats; // 总体统计
deptStats: DeptStats[]; // 部门统计
assigneeStats: AssigneeStats[]; // 负责人统计
timeStats: TimeStats[]; // 时间统计
statusStats: StatusStats[]; // 状态统计
roomStats: RoomStats[]; // 会议室统计
}
// 统计结果响应
export interface MeetingStatsResponse {
code: number;
msg: string;
data: MeetingStatsData;
}
/**
* 获取会议室预约统计信息
* @param params 查询参数
* @returns 统计结果
*/
export function getMeetingStats(params?: MeetingStatsQuery): Promise<MeetingStatsResponse> {
return request({
url: '/integrated/meetingBooking/stats',
method: 'get',
params
});
}
@@ -88,3 +88,6 @@ export function exportProfitMarginReport(params?: object) {
responseType: 'blob'
})
}
+2 -2
View File
@@ -1,7 +1,7 @@
<template>
<el-select
style="width: 100%"
v-model="state.selectId"
v-model="selectId"
:placeholder="props.placeholder"
clearable
remote-show-suffix
@@ -54,7 +54,7 @@ const queryForm = reactive<any>({
const state = reactive<any>({
loading: true,
selectId: selectId,
selectId: selectId.value,
options: [],
})
+181 -181
View File
@@ -1,181 +1,181 @@
<template>
<div class="el-tree-select">
<el-select
style="width: 100%"
v-model="valueId"
ref="treeSelect"
:filterable="true"
:clearable="true"
@clear="clearHandle"
:filter-method="selectFilterData"
:placeholder="placeholder"
>
<el-option :value="valueId" :label="valueTitle">
<el-tree
id="tree-option"
ref="selectTree"
:accordion="accordion"
:data="options"
:props="objMap"
:node-key="objMap.value"
:expand-on-click-node="false"
:default-expanded-keys="defaultExpandedKey"
:filter-node-method="filterNode"
@node-click="handleNodeClick"
></el-tree>
</el-option>
</el-select>
</div>
</template>
<script setup>
const { proxy } = getCurrentInstance();
const props = defineProps({
/* 配置项 */
objMap: {
type: Object,
default: () => {
return {
value: 'id', // ID字段名
label: 'label', // 显示名称
children: 'children', // 子级字段名
};
},
},
/* 自动收起 */
accordion: {
type: Boolean,
default: () => {
return false;
},
},
/**当前双向数据绑定的值 */
value: {
type: [String, Number],
default: '',
},
/**当前的数据 */
options: {
type: Array,
default: () => [],
},
/**输入框内部的文字 */
placeholder: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:value']);
const valueId = computed({
get: () => props.value,
set: (val) => {
emit('update:value', val);
},
});
const valueTitle = ref('');
const defaultExpandedKey = ref([]);
/**
* 初始化下拉菜单选择器的默认值,并设置默认选中和默认展开。
*/
function initHandle() {
nextTick(() => {
const selectedValue = valueId.value;
if (selectedValue !== null && typeof selectedValue !== 'undefined') {
const node = proxy.$refs.selectTree.getNode(selectedValue);
if (node) {
valueTitle.value = node.data[props.objMap.label];
proxy.$refs.selectTree.setCurrentKey(selectedValue); // 设置默认选中
defaultExpandedKey.value = [selectedValue]; // 设置默认展开
}
} else {
clearHandle();
}
});
}
/**
* 点击某一节点时触发的事件,更新当前的选中值和展开状态。
* @param {Object} node - 被点击的节点对象
*/
function handleNodeClick(node) {
valueTitle.value = node[props.objMap.label];
valueId.value = node[props.objMap.value];
defaultExpandedKey.value = [];
proxy.$refs.treeSelect.blur();
selectFilterData('');
}
/**
* 搜索过滤函数,根据输入的值来过滤显示的节点。
* @param {String} val - 输入框内的搜索关键字
*/
function selectFilterData(val) {
proxy.$refs.selectTree.filter(val);
}
/**
* 根据输入的值来判断节点是否需要显示。
* @param {String} value - 输入框内的搜索关键字
* @param {Object} data - 当前处理的节点数据
* @returns {Boolean} - 是否需要显示此节点
*/
function filterNode(value, data) {
if (!value) return true;
return data[props.objMap['label']].indexOf(value) !== -1;
}
/**
* 清空当前的选中状态,并重置展开状态。
*/
function clearHandle() {
valueTitle.value = '';
valueId.value = '';
defaultExpandedKey.value = [];
clearSelected();
}
/**
* 删除所有选中状态的节点。
*/
function clearSelected() {
const allNode = document.querySelectorAll('#tree-option .el-tree-node');
allNode.forEach((element) => element.classList.remove('is-current'));
}
onMounted(() => {
initHandle();
});
watch(valueId, () => {
initHandle();
});
</script>
<style lang="scss" scoped>
@import '/@/assets/styles/variables.module.scss';
.el-scrollbar .el-scrollbar__view .el-select-dropdown__item {
padding: 0;
background-color: #fff;
height: auto;
}
.el-select-dropdown__item.selected {
font-weight: normal;
}
ul li .el-tree .el-tree-node__content {
height: auto;
padding: 0 20px;
box-sizing: border-box;
}
:deep(.el-tree-node__content:hover),
:deep(.el-tree-node__content:active),
:deep(.is-current > div:first-child),
:deep(.el-tree-node__content:focus) {
background-color: mix(#fff, $--color-primary, 90%);
color: $--color-primary;
}
</style>
<template>
<div class="el-tree-select">
<el-select
style="width: 100%"
v-model="valueId"
ref="treeSelect"
:filterable="true"
:clearable="true"
@clear="clearHandle"
:filter-method="selectFilterData"
:placeholder="placeholder"
>
<el-option :value="valueId" :label="valueTitle">
<el-tree
id="tree-option"
ref="selectTree"
:accordion="accordion"
:data="options"
:props="objMap"
:node-key="objMap.value"
:expand-on-click-node="false"
:default-expanded-keys="defaultExpandedKey"
:filter-node-method="filterNode"
@node-click="handleNodeClick"
></el-tree>
</el-option>
</el-select>
</div>
</template>
<script setup>
const { proxy } = getCurrentInstance();
const props = defineProps({
/* 配置项 */
objMap: {
type: Object,
default: () => {
return {
value: 'id', // ID字段名
label: 'label', // 显示名称
children: 'children', // 子级字段名
};
},
},
/* 自动收起 */
accordion: {
type: Boolean,
default: () => {
return false;
},
},
/**当前双向数据绑定的值 */
value: {
type: [String, Number],
default: '',
},
/**当前的数据 */
options: {
type: Array,
default: () => [],
},
/**输入框内部的文字 */
placeholder: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:value']);
const valueId = computed({
get: () => props.value,
set: (val) => {
emit('update:value', val);
},
});
const valueTitle = ref('');
const defaultExpandedKey = ref([]);
/**
* 初始化下拉菜单选择器的默认值,并设置默认选中和默认展开。
*/
function initHandle() {
nextTick(() => {
const selectedValue = valueId.value;
if (selectedValue !== null && typeof selectedValue !== 'undefined') {
const node = proxy.$refs.selectTree.getNode(selectedValue);
if (node) {
valueTitle.value = node.data[props.objMap.label];
proxy.$refs.selectTree.setCurrentKey(selectedValue); // 设置默认选中
defaultExpandedKey.value = [selectedValue]; // 设置默认展开
}
} else {
clearHandle();
}
});
}
/**
* 点击某一节点时触发的事件,更新当前的选中值和展开状态。
* @param {Object} node - 被点击的节点对象
*/
function handleNodeClick(node) {
valueTitle.value = node[props.objMap.label];
valueId.value = node[props.objMap.value];
defaultExpandedKey.value = [];
proxy.$refs.treeSelect.blur();
selectFilterData('');
}
/**
* 搜索过滤函数,根据输入的值来过滤显示的节点。
* @param {String} val - 输入框内的搜索关键字
*/
function selectFilterData(val) {
proxy.$refs.selectTree.filter(val);
}
/**
* 根据输入的值来判断节点是否需要显示。
* @param {String} value - 输入框内的搜索关键字
* @param {Object} data - 当前处理的节点数据
* @returns {Boolean} - 是否需要显示此节点
*/
function filterNode(value, data) {
if (!value) return true;
return data[props.objMap['label']].indexOf(value) !== -1;
}
/**
* 清空当前的选中状态,并重置展开状态。
*/
function clearHandle() {
valueTitle.value = '';
valueId.value = '';
defaultExpandedKey.value = [];
clearSelected();
}
/**
* 删除所有选中状态的节点。
*/
function clearSelected() {
const allNode = document.querySelectorAll('#tree-option .el-tree-node');
allNode.forEach((element) => element.classList.remove('is-current'));
}
onMounted(() => {
initHandle();
});
watch(valueId, () => {
initHandle();
});
</script>
<style lang="scss" scoped>
@import '/@/assets/styles/variables.module.scss';
.el-scrollbar .el-scrollbar__view .el-select-dropdown__item {
padding: 0;
background-color: #fff;
height: auto;
}
.el-select-dropdown__item.selected {
font-weight: normal;
}
ul li .el-tree .el-tree-node__content {
height: auto;
padding: 0 20px;
box-sizing: border-box;
}
:deep(.el-tree-node__content:hover),
:deep(.el-tree-node__content:active),
:deep(.is-current > div:first-child),
:deep(.el-tree-node__content:focus) {
background-color: mix(#fff, $--color-primary, 90%);
color: $--color-primary;
}
</style>
+58 -21
View File
@@ -1,7 +1,10 @@
<template>
<div class="layout-padding">
<div class="layout-padding-view">
<div ref="containerRef" class="relative h-full w-full overflow-y-auto bg-gradient-to-br from-[#232e55] to-[#040a1b] p-2">
<div
ref="containerRef"
class="relative h-full w-full overflow-y-scroll scrollbar-hidden bg-gradient-to-br from-[#232e55] to-[#040a1b] py-2"
>
<!-- 背景圆点 -->
<div class="pointer-events-none absolute inset-0 pattern-dots opacity-[0.08]"></div>
@@ -36,27 +39,21 @@
</div>
<!-- 菜品展示区域 -->
<div class="w-full">
<div class="w-full flex flex-wrap items-center justify-center" v-for="(hourType, index) in foodData" :key="index">
<div v-for="dish in hourType" :key="dish.id" class="w-60 p-2">
<div class="relative h-32 overflow-hidden">
<img :src="imageBaseUrl + dish.picUrl" :alt="dish.name" class="h-full w-full object-cover" />
<div class="absolute left-1 top-1">
<star-medal text="午餐" />
</div>
<div class="absolute left-1 top-1">
<star-medal :text="canteenCategoryOption?.find((v: any) => v.id == dish.categoryId)?.name" />
</div>
</div>
<div
class="w-full flex items-center justify-between bg-[#AEBBF9]/40 px-2 pb-4 backdrop-blur-sm rounded-bl-[50%] rounded-br-[50%]"
>
<h3 class="text-2xl font-bold text-[#8ab4ff] drop-shadow-lg line-clamp-1 whitespace-nowrap">
{{ dish.name }}
</h3>
<p class="text-3xl font-bold text-[#4a88ff] drop-shadow-lg whitespace-nowrap">¥{{ displayPrice(dish.price) }}</p>
<div class="w-full flex flex-wrap items-center" v-for="(hourType, index) in foodData" :key="index">
<div v-for="dish in hourType" :key="dish.id" class="p-2" :style="cardStyle">
<div class="relative overflow-hidden rounded-tl rounded-tr" :style="foodCardStyle">
<img :src="imageBaseUrl + dish.picUrl" :alt="dish.name" class="h-full w-full object-cover" />
<div class="ribbon">午餐</div>
<div class="absolute left-1 top-1">
<star-medal :text="canteenCategoryOption?.find((v: any) => v.id == dish.categoryId)?.name" />
</div>
</div>
<div class="w-full flex items-center justify-between bg-[#AEBBF9]/40 px-2 py-2 backdrop-blur-sm rounded-bl rounded-br">
<h3 class="text-2xl font-bold text-[#7cacff] drop-shadow-lg line-clamp-1 whitespace-nowrap">
{{ dish.name }}
</h3>
<p class="text-3xl font-bold text-[#a9c7ff] drop-shadow-lg whitespace-nowrap">¥{{ displayPrice(dish.price) }}</p>
</div>
</div>
</div>
</div>
@@ -65,7 +62,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue"
import { ref, onMounted, onUnmounted, computed } from "vue"
import { selectTimeFoodApi } from "/@/api/canteen/canteenDailyMenu"
import { getAllSimple as getCanteenCategory } from "/@/api/canteen/canteenCategory"
import { displayPrice } from "/@/utils/formatMoney"
@@ -79,6 +76,7 @@ const currentDate = ref(dayjs().format("YYYY/MM/DD"))
const currentTime = ref(dayjs().format("HH:mm:ss"))
const isFullscreen = ref(false)
const imageBaseUrl = import.meta.env.VITE_IMAGE_BASE_PATH
const clientWidth = ref<number>(0)
interface FoodItem {
categoryId: string
@@ -105,6 +103,8 @@ onMounted(async () => {
timer = setInterval(() => (currentTime.value = dayjs().format("HH:mm:ss")), 1000)
await getDishByType()
await getCanteenDishesCategory()
await nextTick()
clientWidth.value = containerRef.value?.clientWidth || 0
// 监听全屏变化事件
const handleFullscreenChange = () => {
@@ -114,6 +114,7 @@ onMounted(async () => {
(document as any).mozFullScreenElement ||
(document as any).msFullscreenElement
)
clientWidth.value = containerRef.value?.clientWidth || 0
}
// 全屏api兼容性
@@ -130,6 +131,19 @@ onMounted(async () => {
})
})
const cardStyle = computed(() => {
const width = clientWidth.value / 5
return {
width: `${width}px`,
}
})
const foodCardStyle = computed(() => {
const width = clientWidth.value / 5
return {
height: `${(width * 9) / 16}px`,
}
})
// 全屏切换
const toggleFullScreen = async () => {
const element = containerRef.value
@@ -210,4 +224,27 @@ const getCanteenDishesCategory = async () => {
background-repeat: no-repeat;
background-position: center;
}
.scrollbar-hidden::-webkit-scrollbar {
display: none;
}
.scrollbar-hidden {
scrollbar-width: none;
-ms-overflow-style: none;
}
::selection {
background: #1879ff;
color: #fff;
}
.ribbon {
width: 80px;
height: 20px;
background: #1879ff99;
position: absolute;
right: -20px;
top: 10px;
transform: rotateZ(45deg);
text-align: center;
color: #fff;
font-size: 24rpx;
}
</style>
+3 -1
View File
@@ -11,7 +11,7 @@
<el-col :span="24" class="mb20">
<el-form-item label="图片" prop="picUrl">
<upload-img v-model="form.picUrl" :disabled="readonly"></upload-img>
<upload-image v-model:imageUrl="form.picUrl" :disabled="readonly" :limit="5" />
</el-form-item>
</el-col>
@@ -59,6 +59,8 @@ import { useDict } from '/@/hooks/dict';
import { useMessage } from "/@/hooks/message";
import { getObj, addObj, putObj } from '/@/api/integrated/feedback'
import { rule } from '/@/utils/validate';
import UploadImage from '/@/components/Upload/Image.vue';
const emit = defineEmits(['refresh']);
// 定义变量内容
+7 -2
View File
@@ -46,7 +46,11 @@
<el-table-column type="selection" width="40" align="center" />
<el-table-column type="index" label="#" width="40" />
<el-table-column prop="title" label="标题" show-overflow-tooltip/>
<el-table-column prop="picUrl" label="图片" show-overflow-tooltip/>
<el-table-column prop="picUrl" label="图片" align="center" width="160px" show-overflow-tooltip>
<template #default="scope">
<preview-image :src="scope.row.picUrl" />
</template>
</el-table-column>
<el-table-column prop="status" label="状态" show-overflow-tooltip>
<template #default="scope">
<dict-tag :options="maintenance_status" :value="scope.row.status"></dict-tag>
@@ -58,7 +62,8 @@
<dict-tag :options="feedback_type" :value="scope.row.type"></dict-tag>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" show-overflow-tooltip/>
<el-table-column prop="createTime" label="创建时间" show-overflow-tooltip min-width="160" />
<!-- <el-table-column prop="sort" label="排序" show-overflow-tooltip/>-->
<el-table-column label="操作" width="200">
<template #default="scope">
<el-button icon="view" text type="primary" v-auth="'integrated_feedback_edit'"
+1 -1
View File
@@ -25,7 +25,7 @@
</el-col>
<el-col :span="12" class="mb20">
<el-form-item label="图片" prop="picUrl">
<upload-img v-model="form.picUrl"></upload-img>
<upload-img v-model:imageUrl="form.picUrl"></upload-img>
</el-form-item>
</el-col>
+8 -3
View File
@@ -34,14 +34,18 @@
@queryTable="getDataList"></right-toolbar>
</div>
</el-row>
<el-table :data="state.dataList" v-loading="state.loading" border
<el-table :data="state.dataList" v-loading="state.loading" border
:cell-style="tableStyle.cellStyle" :header-cell-style="tableStyle.headerCellStyle"
@selection-change="selectionChangHandle"
@sort-change="sortChangeHandle">
<el-table-column type="selection" width="40" align="center" />
<el-table-column type="index" label="#" width="40" />
<el-table-column prop="title" label="标题" show-overflow-tooltip/>
<el-table-column prop="picUrl" label="图片" show-overflow-tooltip/>
<el-table-column prop="picUrl" label="图片" align="center" width="160px" show-overflow-tooltip>
<template #default="scope">
<preview-image :src="scope.row.picUrl" />
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" show-overflow-tooltip/>
<el-table-column prop="status" label="状态" show-overflow-tooltip>
<template #default="scope">
@@ -50,6 +54,7 @@
</el-table-column>
<el-table-column prop="dateFound" label="发现日期" show-overflow-tooltip/>
<el-table-column prop="location" label="发现地点" show-overflow-tooltip/>
<el-table-column prop="createTime" label="创建时间" show-overflow-tooltip min-width="160" />
<el-table-column label="操作" width="150">
<template #default="scope">
<el-button icon="edit-pen" text type="primary" v-auth="'integrated_lostAndFound_edit'"
@@ -138,4 +143,4 @@ const handleDelete = async (ids: string[]) => {
useMessage().error(err.msg);
}
};
</script>
</script>
@@ -17,7 +17,7 @@
</el-col>
<el-col :span="24" class="mb20">
<el-form-item label="图片" prop="picUrl">
<upload-img v-model="form.picUrl" :disabled="readonly"></upload-img>
<upload-img v-model:imageUrl="form.picUrl" :disabled="readonly"></upload-img>
</el-form-item>
</el-col>
@@ -36,6 +36,11 @@
<el-table-column type="selection" width="40" align="center" />
<el-table-column type="index" label="#" width="40" />
<el-table-column prop="title" label="标题" show-overflow-tooltip/>
<el-table-column prop="picUrl" label="图片" align="center" width="160px" show-overflow-tooltip>
<template #default="scope">
<preview-image :src="scope.row.picUrl" />
</template>
</el-table-column>
<el-table-column prop="status" label="状态" show-overflow-tooltip>
<template #default="scope">
<dict-tag :options="maintenance_status" :value="scope.row.status"></dict-tag>
@@ -49,7 +54,8 @@
</template>
</el-table-column>
<el-table-column prop="createBy" label="上报人" show-overflow-tooltip/>
<el-table-column prop="sort" label="排序" show-overflow-tooltip/>
<!-- <el-table-column prop="sort" label="排序" show-overflow-tooltip/>-->
<el-table-column prop="createTime" label="创建时间" show-overflow-tooltip min-width="160" />
<el-table-column label="操作" width="200">
<template #default="scope">
<el-button icon="view" text type="primary" v-auth="'integrated_maintenanceOrder_edit'"
@@ -46,7 +46,7 @@
</el-table-column>
<el-table-column prop="createBy" label="申请人" show-overflow-tooltip/>
<el-table-column prop="assigneeId" label="会议负责人" show-overflow-tooltip/>
<el-table-column prop="bookingDate" label="开始时间" show-overflow-tooltip/>
<el-table-column prop="bookingDate" label="预约日期" show-overflow-tooltip/>
<el-table-column prop="startTime" label="开始时间" show-overflow-tooltip/>
<el-table-column prop="endTime" label="结束时间" show-overflow-tooltip/>
<el-table-column label="操作" width="150">
@@ -1,72 +1,113 @@
<template>
<el-dialog :title="form.id ? '编辑' : '新增'" v-model="visible"
:close-on-click-modal="false" draggable>
<el-form ref="dataFormRef" :model="form" :rules="dataRules" formDialogRef label-width="90px" v-loading="loading">
:close-on-click-modal="false" draggable width="800px">
<el-form ref="dataFormRef" :model="form" :rules="dataRules" formDialogRef label-width="130px" v-loading="loading">
<el-row :gutter="24">
<el-col :span="12" class="mb20">
<el-form-item label="摆放形式名称" prop="layoutName">
<el-input v-model="form.layoutName" placeholder="请输入摆放形式名称"/>
<el-input v-model="form.layoutName" placeholder="请输入摆放形式名称" clearable/>
</el-form-item>
</el-col>
<el-col :span="12" class="mb20">
<el-form-item label="摆放形式编码" prop="layoutCode">
<el-input v-model="form.layoutCode" placeholder="请输入摆放形式编码"/>
<el-input v-model="form.layoutCode" placeholder="请输入摆放形式编码" clearable/>
</el-form-item>
</el-col>
<el-col :span="12" class="mb20">
<el-col :span="24" class="mb20">
<el-form-item label="摆放形式描述" prop="description">
<el-input v-model="form.description" placeholder="请输入摆放形式描述"/>
<el-input
v-model="form.description"
type="textarea"
:rows="3"
placeholder="请输入摆放形式描述"
maxlength="500"
show-word-limit
clearable/>
</el-form-item>
</el-col>
<el-col :span="12" class="mb20">
<el-form-item label="最小容纳人数" prop="capacityMin">
<el-input v-model="form.capacityMin" placeholder="请输入最小容纳人数"/>
<el-input-number
v-model="form.capacityMin"
:min="1"
:max="9999"
placeholder="请输入最小容纳人数"
controls-position="right"
style="width: 100%"/>
</el-form-item>
</el-col>
<el-col :span="12" class="mb20">
<el-form-item label="最大容纳人数" prop="capacityMax">
<el-input v-model="form.capacityMax" placeholder="请输入最大容纳人数"/>
<el-input-number
v-model="form.capacityMax"
:min="1"
:max="9999"
placeholder="请输入最大容纳人数"
controls-position="right"
style="width: 100%"/>
</el-form-item>
</el-col>
<el-col :span="12" class="mb20">
<el-col :span="24" class="mb20">
<el-form-item label="摆放形式示意图" prop="layoutImage">
<el-input v-model="form.layoutImage" placeholder="请输入摆放形式示意图"/>
<upload-image v-model="form.layoutImage" :limit="1" />
<div class="el-form-item-msg">建议尺寸:800x600px,支持jpg、png格式</div>
</el-form-item>
</el-col>
<el-col :span="12" class="mb20">
<el-form-item label="布置所需时间(分钟)" prop="setupTime">
<el-input v-model="form.setupTime" placeholder="请输入布置所需时间(分钟)"/>
<el-form-item label="布置所需时间" prop="setupTime">
<el-input-number
v-model="form.setupTime"
:min="0"
:max="999"
placeholder="请输入布置所需时间"
controls-position="right"
style="width: 100%">
<template #append>分钟</template>
</el-input-number>
</el-form-item>
</el-col>
<el-col :span="12" class="mb20">
<el-form-item label="是否可用" prop="isAvailable">
<el-radio-group v-model="form.isAvailable">
<el-radio value="0">可用</el-radio>
<el-radio value="1">不可用</el-radio>
<el-radio label="0">可用</el-radio>
<el-radio label="1">不可用</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="12" class="mb20">
<el-form-item label="排序" prop="sortOrder">
<el-input v-model="form.sortOrder" placeholder="请输入排序"/>
<el-input-number
v-model="form.sortOrder"
:min="0"
:max="9999"
placeholder="请输入排序"
controls-position="right"
style="width: 100%"/>
</el-form-item>
</el-col>
<el-col :span="12" class="mb20">
<el-col :span="24" class="mb20">
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" placeholder="请输入备注"/>
<el-input
v-model="form.remark"
type="textarea"
:rows="3"
placeholder="请输入备注"
maxlength="500"
show-word-limit
clearable/>
</el-form-item>
</el-col>
</el-row>
</el-row>
</el-form>
<template #footer>
<span class="dialog-footer">
@@ -82,6 +123,8 @@ import { useDict } from '/@/hooks/dict';
import { useMessage } from "/@/hooks/message";
import { getObj, addObj, putObj } from '/@/api/integrated/meetingDeskLayout'
import { rule } from '/@/utils/validate';
import UploadImage from '/@/components/Upload/Image.vue';
const emit = defineEmits(['refresh']);
// 定义变量内容
@@ -97,18 +140,21 @@ const form = reactive({
layoutName: '',
layoutCode: '',
description: '',
capacityMin: '',
capacityMax: '',
capacityMin: null as number | null,
capacityMax: null as number | null,
layoutImage: '',
setupTime: '',
setupTime: null as number | null,
isAvailable: '0', // 默认可用
sortOrder: '',
sortOrder: 0 as number,
remark: '',
});
// 定义校验规则
const dataRules = ref({
layoutName: [{required: true, message: '摆放形式名称不能为空', trigger: 'blur'}],
layoutCode: [{required: true, message: '摆放形式编码不能为空', trigger: 'blur'}],
capacityMin: [{required: true, message: '最小容纳人数不能为空', trigger: 'change'}],
capacityMax: [{required: true, message: '最大容纳人数不能为空', trigger: 'change'}],
})
// 打开弹窗
@@ -125,6 +171,16 @@ const openDialog = (id: string) => {
if (id) {
form.id = id
getmeetingDeskLayoutData(id)
} else {
// 新增时设置默认值
form.isAvailable = '0'; // 默认可用
form.sortOrder = 0;
form.capacityMin = null;
form.capacityMax = null;
form.setupTime = null;
form.layoutImage = '';
form.description = '';
form.remark = '';
}
};
@@ -162,4 +218,23 @@ const getmeetingDeskLayoutData = (id: string) => {
defineExpose({
openDialog
});
</script>
</script>
<style scoped lang="scss">
.el-form-item-msg {
font-size: 12px;
color: #909399;
line-height: 1.5;
margin-top: 4px;
}
:deep(.el-input-number) {
width: 100%;
}
:deep(.el-textarea) {
.el-textarea__inner {
font-family: inherit;
}
}
</style>
@@ -0,0 +1,337 @@
<template>
<el-dialog
:model-value="visible"
@update:model-value="(val) => emit('update:visible', val)"
title="会议室预约详情"
width="80%"
:before-close="handleClose"
>
<div class="booking-detail-container">
<!-- 查询条件 -->
<el-form :model="queryForm" :inline="true" class="mb20">
<el-form-item label="会议室">
<el-input :value="roomName" disabled style="width: 200px;" />
</el-form-item>
<el-form-item label="查询日期">
<el-date-picker
v-model="queryForm.bookingDate"
type="date"
placeholder="请选择查询日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
style="width: 200px"
@change="loadBookingData"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="loadBookingData">查询</el-button>
</el-form-item>
</el-form>
<!-- 时间轴显示 -->
<div class="time-schedule">
<h3>{{ queryForm.bookingDate || '请选择日期' }} 的预约情况</h3>
<!-- 时间轴 -->
<div class="time-axis">
<div class="time-slots">
<div
v-for="timeSlot in timeSlots"
:key="timeSlot.startTime"
class="time-slot"
:class="{
'booked': timeSlot.status === 'booked',
'reviewing': timeSlot.status === 'reviewing',
'available': timeSlot.status === 'available',
'current': timeSlot.isCurrent
}"
@click="handleTimeSlotClick(timeSlot)"
>
<div class="time-label">{{ timeSlot.startTime }} - {{ timeSlot.endTime }}</div>
<div v-if="timeSlot.bookingInfo" class="booking-info">
<div class="booking-title">{{ timeSlot.bookingInfo.title }}</div>
<div class="booking-user">{{ timeSlot.bookingInfo.applicantName }}</div>
<div class="booking-status">
<el-tag :type="timeSlot.status === 'booked' ? 'success' : timeSlot.status === 'reviewing' ? 'warning' : 'info'">
{{ timeSlot.statusText }}
</el-tag>
</div>
</div>
<div v-else class="available">
{{ timeSlot.statusText }}
</div>
</div>
</div>
</div>
<!-- 预约列表 -->
<div class="booking-list" v-if="bookingList.length > 0">
<h4>当日预约列表</h4>
<el-table :data="bookingList" border>
<el-table-column prop="title" label="会议主题" show-overflow-tooltip />
<el-table-column prop="applicantName" label="申请人" show-overflow-tooltip />
<el-table-column prop="startTime" label="开始时间" show-overflow-tooltip />
<el-table-column prop="endTime" label="结束时间" show-overflow-tooltip />
<el-table-column prop="status" label="状态" show-overflow-tooltip>
<template #default="scope">
<el-tag :type="getStatusType(scope.row.status)">
{{ getStatusText(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="description" label="会议说明" show-overflow-tooltip />
</el-table>
</div>
<!-- 无预约提示 -->
<div v-else-if="queryForm.bookingDate" class="no-booking">
<el-empty description="当日暂无预约" />
</div>
</div>
</div>
<template #footer>
<el-button @click="() => emit('update:visible', false)">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { getBookingDetailPage } from "/src/api/integrated/meetingRoom";
// Props
interface Props {
visible: boolean;
roomId: string;
roomName: string;
}
const props = withDefaults(defineProps<Props>(), {
visible: false,
roomId: '',
roomName: ''
});
// Emits
const emit = defineEmits<{
'update:visible': [value: boolean];
}>();
// 响应式数据
const queryForm = reactive({
bookingDate: '',
roomId: ''
});
const bookingList = ref([]);
const loading = ref(false);
// 时间槽位数据 - 直接使用API返回的数据
const timeSlots = ref([]);
// 状态类型映射
const getStatusType = (status: string) => {
const statusMap = {
'1': 'warning', // 审核中
'2': 'success', // 审核通过
'3': 'danger', // 审核拒绝
'4': 'info' // 已取消
};
return statusMap[status] || 'info';
};
// 状态文本映射
const getStatusText = (status: string) => {
const statusMap = {
'1': '审核中',
'2': '审核通过',
'3': '审核拒绝',
'4': '已取消'
};
return statusMap[status] || '未知状态';
};
// 加载预约数据
const loadBookingData = async () => {
if (!queryForm.bookingDate) {
ElMessage.warning('请选择查询日期');
return;
}
try {
loading.value = true;
// 调用新的API接口获取预约详情页面数据
const response = await getBookingDetailPage(queryForm.roomId, queryForm.bookingDate);
// 更新时间段数据 - API返回的数据在data字段中
timeSlots.value = response.data?.timeSlots || [];
// 更新预约列表数据
bookingList.value = response.data?.dailyBookings || [];
// 更新房间名称(如果API返回了的话)
if (response.data?.roomName) {
// 可以通过emit通知父组件更新房间名称,或者直接更新props
// emit('update:roomName', response.data.roomName);
}
ElMessage.success('数据加载成功');
} catch (error) {
console.error('加载预约数据失败:', error);
ElMessage.error('加载数据失败');
// 如果API调用失败,使用模拟数据作为备选
const mockData = [
{
id: '1',
title: '项目讨论会',
applicantName: '张三',
startTime: '09:00',
endTime: '11:00',
status: '2',
statusText: '审核通过',
description: '讨论项目进度'
},
{
id: '2',
title: '技术评审',
applicantName: '李四',
startTime: '14:00',
endTime: '16:00',
status: '1',
statusText: '审核中',
description: '技术方案评审'
}
];
bookingList.value = mockData;
} finally {
loading.value = false;
}
};
// 时间槽点击事件
const handleTimeSlotClick = (timeSlot: any) => {
if (timeSlot.status === 'booked') {
ElMessage.info(`该时间段已被预约: ${timeSlot.bookingInfo?.title || '未知会议'}`);
} else if (timeSlot.status === 'reviewing') {
ElMessage.info(`该时间段正在审核中: ${timeSlot.bookingInfo?.title || '未知会议'}`);
} else {
ElMessage.info(`该时间段可预约: ${timeSlot.startTime} - ${timeSlot.endTime}`);
}
};
// 关闭对话框
const handleClose = () => {
emit('update:visible', false);
};
// 监听visible变化,初始化数据
watch(() => props.visible, (newVal) => {
if (newVal) {
queryForm.roomId = props.roomId;
queryForm.bookingDate = new Date().toISOString().split('T')[0]; // 默认今天
loadBookingData();
}
});
</script>
<style scoped>
.booking-detail-container {
padding: 20px;
}
.time-schedule {
margin-top: 20px;
}
.time-axis {
margin: 20px 0;
}
.time-slots {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 8px;
}
.time-slot {
border: 1px solid #e4e7ed;
border-radius: 8px;
padding: 15px;
cursor: pointer;
transition: all 0.3s;
min-height: 80px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.time-slot:hover {
border-color: #409eff;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.2);
}
.time-slot.booked {
background-color: #f0f9ff;
border-color: #409eff;
}
.time-slot.reviewing {
background-color: #fefce8;
border-color: #eab308;
}
.time-slot.available {
background-color: #f0fdf4;
border-color: #22c55e;
}
.time-slot.current {
background-color: #fff7e6;
border-color: #ff9500;
}
.time-label {
font-weight: bold;
color: #606266;
margin-bottom: 8px;
}
.booking-info {
text-align: center;
}
.booking-title {
font-weight: bold;
color: #303133;
margin-bottom: 4px;
}
.booking-user {
color: #606266;
font-size: 12px;
margin-bottom: 4px;
}
.available {
color: #67c23a;
font-weight: bold;
}
.booking-list {
margin-top: 30px;
}
.no-booking {
text-align: center;
padding: 40px;
}
.mb20 {
margin-bottom: 20px;
}
</style>
@@ -32,27 +32,27 @@
</el-form-item>
</el-col>
<el-col :span="12" class="mb20">
<el-form-item label="默认管理员" prop="adminId">
<el-select
v-model="form.adminId"
placeholder="请选择默认管理员"
clearable
filterable
remote
:remote-method="handleAdminUserSearch"
:loading="adminUserLoading"
@visible-change="handleAdminUserVisibleChange"
>
<el-option
v-for="item in adminUserOptions"
:key="item.id"
:label="item.nameWithMobile"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-col>
<!-- <el-col :span="12" class="mb20">-->
<!-- <el-form-item label="默认管理员" prop="adminId">-->
<!-- <el-select -->
<!-- v-model="form.adminId" -->
<!-- placeholder="请选择默认管理员" -->
<!-- clearable-->
<!-- filterable-->
<!-- remote-->
<!-- :remote-method="handleAdminUserSearch"-->
<!-- :loading="adminUserLoading"-->
<!-- @visible-change="handleAdminUserVisibleChange"-->
<!-- >-->
<!-- <el-option-->
<!-- v-for="item in adminUserOptions"-->
<!-- :key="item.id"-->
<!-- :label="item.nameWithMobile"-->
<!-- :value="item.id"-->
<!-- />-->
<!-- </el-select>-->
<!-- </el-form-item>-->
<!-- </el-col>-->
<el-col :span="12" class="mb20">
<el-form-item label="容纳人数" prop="capacity">
@@ -86,11 +86,11 @@
</el-form-item>
</el-col>
<el-col :span="12" class="mb20">
<el-form-item label="可用时间段" prop="availableTime">
<el-input v-model="form.availableTime" placeholder="请输入可用时间段"/>
</el-form-item>
</el-col>
<!-- <el-col :span="12" class="mb20">-->
<!-- <el-form-item label="可用时间段" prop="availableTime">-->
<!-- <el-input v-model="form.availableTime" placeholder="请输入可用时间段"/>-->
<!-- </el-form-item>-->
<!-- </el-col>-->
<el-col :span="24" class="mb20">
<el-form-item label="位置" prop="location">
@@ -340,9 +340,9 @@ const openDialog = (id: string) => {
// 获取用户下拉数据(保洁和会务人员)
getUserOptions()
// 获取管理员用户数据
getAdminUserOptions()
// getAdminUserOptions()
// 获取meetingRoom信息
if (id) {
@@ -356,7 +356,7 @@ const getUserOptions = async () => {
try {
const res = await dropdown()
const allUsers = Array.isArray(res.data) ? res.data : []
// 根据type字段分别赋值(type为字符串类型)
cleanUserOptions.value = allUsers.filter(user => user.type === '1') // type: '1' 为保洁
serviceUserOptions.value = allUsers.filter(user => user.type === '2') // type: '2' 为会务人员
@@ -370,23 +370,23 @@ const getAdminUserOptions = async (searchKeyword?: string) => {
try {
adminUserLoading.value = true
const params: any = {}
// 如果有搜索关键词,添加到参数中
if (searchKeyword) {
params.name = searchKeyword
params.mobile = searchKeyword
}
const res = await getSimpleUserAll(params)
let allUsers = Array.isArray(res.data) ? res.data : []
// 如果接口不支持搜索参数,在前端进行过滤
if (searchKeyword && allUsers.length > 0) {
allUsers = allUsers.filter(user =>
allUsers = allUsers.filter(user =>
user.nameWithMobile.toLowerCase().includes(searchKeyword.toLowerCase())
)
}
adminUserOptions.value = allUsers
} catch (err: any) {
console.error('获取管理员用户列表失败:', err)
@@ -31,14 +31,17 @@
</template>
</el-table-column>
<!-- <el-table-column prop="deptId" label="部门id" show-overflow-tooltip/>-->
<el-table-column prop="adminId" label="默认管理员" show-overflow-tooltip/>
<!-- <el-table-column prop="adminId" label="默认管理员" show-overflow-tooltip/>-->
<el-table-column prop="location" label="位置" show-overflow-tooltip/>
<el-table-column prop="capacity" label="容纳人数" show-overflow-tooltip/>
<el-table-column prop="availableTime" label="可用时间段" show-overflow-tooltip/>
<el-table-column prop="cleanUserId" label="保洁人员" show-overflow-tooltip/>
<el-table-column prop="serviceUserId" label="会务人员" show-overflow-tooltip/>
<el-table-column label="操作" width="150">
<!-- <el-table-column prop="availableTime" label="可用时间段" show-overflow-tooltip/>-->
<!-- <el-table-column prop="cleanUserId" label="保洁人员" show-overflow-tooltip/>-->
<!-- <el-table-column prop="serviceUserId" label="会务人员" show-overflow-tooltip/>-->
<el-table-column label="操作" width="200">
<template #default="scope">
<el-button icon="view" text type="primary" @click="viewBookingDetail(scope.row)">
预约详情
</el-button>
<el-button icon="edit-pen" text type="primary" v-auth="'integrated_meetingRoom_edit'"
@click="formDialogRef.openDialog(scope.row.id)">编辑</el-button>
<el-button icon="delete" text type="primary" v-auth="'integrated_meetingRoom_del'" @click="handleDelete([scope.row.id])">删除</el-button>
@@ -51,6 +54,13 @@
<!-- 编辑、新增 -->
<form-dialog ref="formDialogRef" @refresh="getDataList(false)" />
<!-- 预约详情 -->
<BookingDetail
v-model:visible="bookingDetailVisible"
:room-id="selectedRoomId"
:room-name="selectedRoomName"
/>
</div>
</template>
@@ -62,11 +72,17 @@ import { useDict } from '/src/hooks/dict';
// 引入组件
const FormDialog = defineAsyncComponent(() => import('./form.vue'));
const BookingDetail = defineAsyncComponent(() => import('./bookingDetail.vue'));
// 定义查询字典
const { meeting_status } = useDict('meeting_status')
// 定义变量内容
const formDialogRef = ref()
// 预约详情相关
const bookingDetailVisible = ref(false);
const selectedRoomId = ref('');
const selectedRoomName = ref('');
// 搜索变量
const queryRef = ref()
const showSearch = ref(true)
@@ -109,6 +125,13 @@ const selectionChangHandle = (objs: { id: string }[]) => {
multiple.value = !objs.length;
};
// 查看预约详情
const viewBookingDetail = (row: any) => {
selectedRoomId.value = row.id;
selectedRoomName.value = row.roomName;
bookingDetailVisible.value = true;
};
// 删除操作
const handleDelete = async (ids: string[]) => {
try {
@@ -0,0 +1,575 @@
<template>
<div class="layout-padding">
<div class="layout-padding-auto layout-padding-view">
<!-- 查询表单 -->
<el-row v-show="showSearch">
<el-form :model="queryForm" ref="queryRef" :inline="true" @keyup.enter="loadData">
<el-form-item label="开始日期" prop="startDate">
<el-date-picker
v-model="queryForm.startDate"
type="date"
placeholder="请选择开始日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
style="width: 160px"
/>
</el-form-item>
<el-form-item label="结束日期" prop="endDate">
<el-date-picker
v-model="queryForm.endDate"
type="date"
placeholder="请选择结束日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
style="width: 160px"
/>
</el-form-item>
<!-- <el-form-item label="统计维度" prop="dimension">
<el-select v-model="queryForm.dimension" placeholder="请选择统计维度" style="width: 140px">
<el-option label="部门统计" value="dept" />
<el-option label="负责人统计" value="assignee" />
<el-option label="时间统计" value="time" />
</el-select>
</el-form-item> -->
<!-- <el-form-item label="时间粒度" prop="timeGranularity">
<el-select v-model="queryForm.timeGranularity" placeholder="请选择时间粒度" style="width: 120px">
<el-option label="按天" value="day" />
<el-option label="按周" value="week" />
<el-option label="按月" value="month" />
</el-select>
</el-form-item> -->
<el-form-item>
<el-button icon="search" type="primary" @click="loadData" :loading="loading">
查询
</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</el-row>
<!-- 操作按钮 -->
<el-row>
<div class="mb8" style="width: 100%">
<right-toolbar v-model:showSearch="showSearch" class="ml10 mr20" style="float: right;" />
</div>
</el-row>
<!-- 总体统计卡片 -->
<el-row :gutter="20" class="mb20">
<el-col :span="6">
<el-card class="stat-card">
<div class="stat-content">
<div class="stat-value">{{ statsData.overallStats?.totalBookings || 0 }}</div>
<div class="stat-label">总预约数</div>
</div>
<div class="stat-icon">
<el-icon><Calendar /></el-icon>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card">
<div class="stat-content">
<div class="stat-value">{{ statsData.overallStats?.approvedBookings || 0 }}</div>
<div class="stat-label">审核通过</div>
</div>
<div class="stat-icon">
<el-icon><Check /></el-icon>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card">
<div class="stat-content">
<div class="stat-value">{{ (statsData.overallStats?.approvalRate || 0).toFixed(1) }}%</div>
<div class="stat-label">通过率</div>
</div>
<div class="stat-icon">
<el-icon><TrendCharts /></el-icon>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card">
<div class="stat-content">
<div class="stat-value">{{ getAvgDuration() }}</div>
<div class="stat-label">平均时长(分钟)</div>
</div>
<div class="stat-icon">
<el-icon><Timer /></el-icon>
</div>
</el-card>
</el-col>
</el-row>
<!-- 状态分布卡片 -->
<el-row :gutter="20" class="mb20">
<el-col :span="6">
<el-card class="stat-card">
<div class="stat-content">
<div class="stat-value">{{ statsData.overallStats?.pendingBookings || 0 }}</div>
<div class="stat-label">审核中</div>
</div>
<div class="stat-icon">
<el-icon><Clock /></el-icon>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card">
<div class="stat-content">
<div class="stat-value">{{ statsData.overallStats?.rejectedBookings || 0 }}</div>
<div class="stat-label">审核拒绝</div>
</div>
<div class="stat-icon">
<el-icon><Close /></el-icon>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card">
<div class="stat-content">
<div class="stat-value">{{ statsData.overallStats?.cancelledBookings || 0 }}</div>
<div class="stat-label">已取消</div>
</div>
<div class="stat-icon">
<el-icon><CircleClose /></el-icon>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card">
<div class="stat-content">
<div class="stat-value">{{ Math.round(parseInt(statsData.overallStats?.totalDuration || '0') / 60) }}</div>
<div class="stat-label">总时长(小时)</div>
</div>
<div class="stat-icon">
<el-icon><Clock /></el-icon>
</div>
</el-card>
</el-col>
</el-row>
<!-- 图表展示 -->
<el-row :gutter="20" class="mb20">
<el-col :span="12">
<el-card>
<template #header>
<div class="card-header">
<span>状态分布</span>
</div>
</template>
<div ref="statusChartRef" style="height: 300px;"></div>
</el-card>
</el-col>
<el-col :span="12">
<el-card>
<template #header>
<div class="card-header">
<span>时间趋势</span>
</div>
</template>
<div ref="timeChartRef" style="height: 300px;"></div>
</el-card>
</el-col>
</el-row>
<!-- 数据表格 - 使用普通div替代el-card -->
<div style="background: white; border: 1px solid #dcdfe6; border-radius: 4px; min-height: 400px; width: 100%; overflow-x: auto;">
<!-- 卡片头部 -->
<div style="padding: 18px 20px; border-bottom: 1px solid #ebeef5; background: #f5f7fa; width: 100%;">
<div class="card-header">
<span>详细统计</span>
<el-radio-group v-model="currentTab" @change="handleTabChange">
<el-radio-button label="dept">部门统计</el-radio-button>
<!-- <el-radio-button label="assignee">负责人统计</el-radio-button> -->
<el-radio-button label="time">时间统计</el-radio-button>
<el-radio-button label="room">会议室统计</el-radio-button>
</el-radio-group>
</div>
</div>
<!-- 卡片内容区域 -->
<div style="padding: 20px; background: white; width: 100%; overflow-x: auto;">
<!-- 部门统计表格 -->
<div v-show="currentTab === 'dept'">
<el-table :data="statsData.deptStats" v-loading="loading" border>
<el-table-column prop="deptName" label="部门名称" show-overflow-tooltip />
<el-table-column prop="bookingCount" label="预约数量" show-overflow-tooltip />
<el-table-column prop="approvedCount" label="通过数量" show-overflow-tooltip />
<el-table-column prop="approvalRate" label="通过率" show-overflow-tooltip>
<template #default="scope">
{{ formatPercentage(scope.row.approvalRate) }}%
</template>
</el-table-column>
<el-table-column prop="totalDuration" label="总时长(分钟)" show-overflow-tooltip />
</el-table>
</div>
<!-- 负责人统计表格 -->
<!-- <div v-show="currentTab === 'assignee'">
<el-table :data="statsData.assigneeStats" v-loading="loading" border>
<el-table-column prop="assigneeName" label="负责人姓名" show-overflow-tooltip />
<el-table-column prop="bookingCount" label="预约数量" show-overflow-tooltip />
<el-table-column prop="approvedCount" label="通过数量" show-overflow-tooltip />
<el-table-column prop="approvalRate" label="通过率" show-overflow-tooltip>
<template #default="scope">
{{ typeof scope.row.approvalRate === 'number' ? scope.row.approvalRate.toFixed(1) : parseFloat(scope.row.approvalRate || 0).toFixed(1) }}%
</template>
</el-table-column>
<el-table-column prop="totalDuration" label="总时长(分钟)" show-overflow-tooltip />
</el-table>
</div> -->
<!-- 时间统计表格 -->
<div v-show="currentTab === 'time'">
<el-table :data="statsData.timeStats" v-loading="loading" border>
<el-table-column prop="timeLabel" label="时间" show-overflow-tooltip />
<el-table-column prop="bookingCount" label="预约数量" show-overflow-tooltip />
<el-table-column prop="approvedCount" label="通过数量" show-overflow-tooltip />
<el-table-column prop="totalDuration" label="总时长(分钟)" show-overflow-tooltip />
</el-table>
</div>
<!-- 会议室统计表格 -->
<div v-show="currentTab === 'room'">
<el-table :data="statsData.roomStats" v-loading="loading" border>
<el-table-column prop="roomName" label="会议室名称" show-overflow-tooltip />
<el-table-column prop="bookingCount" label="预约数量" show-overflow-tooltip />
<el-table-column prop="approvedCount" label="通过数量" show-overflow-tooltip />
<el-table-column prop="usageRate" label="使用率" show-overflow-tooltip>
<template #default="scope">
{{ formatPercentage(scope.row.usageRate) }}%
</template>
</el-table-column>
<el-table-column prop="totalDuration" label="总时长(分钟)" show-overflow-tooltip />
</el-table>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts" name="MeetingSummary">
import { ref, reactive, onMounted, nextTick } from 'vue';
import { ElMessage } from 'element-plus';
import { Calendar, Check, TrendCharts, Timer, Clock, Close, CircleClose } from '@element-plus/icons-vue';
import * as echarts from 'echarts';
import { getMeetingStats, type MeetingStatsQuery, type MeetingStatsData } from '/@/api/integrated/meetingStats';
// 响应式数据
const loading = ref(false);
const showSearch = ref(true);
const queryRef = ref();
const currentTab = ref('dept');
// 查询表单
const queryForm = reactive<MeetingStatsQuery>({
startDate: '',
endDate: '',
dimension: 'dept',
timeGranularity: 'day'
});
// 统计数据
const statsData = ref<MeetingStatsData>({
overallStats: {
totalBookings: 0,
approvedBookings: 0,
pendingBookings: 0,
rejectedBookings: 0,
cancelledBookings: 0,
approvalRate: 0,
avgDuration: 0,
totalDuration: 0
},
deptStats: [],
assigneeStats: [],
timeStats: [],
statusStats: [],
roomStats: []
});
// 表格刷新key
const tableKey = ref(0);
// 图表引用
const statusChartRef = ref();
const timeChartRef = ref();
let statusChart: echarts.ECharts | null = null;
let timeChart: echarts.ECharts | null = null;
// 加载数据
const loadData = async () => {
try {
loading.value = true;
const response = await getMeetingStats(queryForm);
if (response.code === 0) {
// 直接使用API返回的数据,不做复杂处理
statsData.value = response.data;
// 强制刷新表格
tableKey.value++;
// 强制触发响应式更新
await nextTick();
initCharts();
} else {
ElMessage.error(response.msg || '获取统计数据失败');
}
} catch (error: any) {
console.error('加载统计数据失败:', error);
ElMessage.error(error.message || '加载统计数据失败');
} finally {
loading.value = false;
}
};
// 重置查询
const resetQuery = () => {
queryRef.value?.resetFields();
queryForm.startDate = '';
queryForm.endDate = '';
queryForm.dimension = 'dept';
queryForm.timeGranularity = 'day';
loadData();
};
// 切换标签页
const handleTabChange = (value: string) => {
currentTab.value = value;
};
// 格式化百分比
const formatPercentage = (value: any) => {
if (value === null || value === undefined || value === '') {
return '0.0';
}
if (typeof value === 'number') {
return value.toFixed(1);
}
if (typeof value === 'string') {
const num = parseFloat(value);
return isNaN(num) ? '0.0' : num.toFixed(1);
}
return '0.0';
};
// 获取平均时长
const getAvgDuration = () => {
const avgDuration = statsData.value.overallStats?.avgDuration;
// 处理 null 或 undefined 的情况
if (!avgDuration) {
return 0;
}
// 处理对象类型
if (typeof avgDuration === 'object' && avgDuration !== null && avgDuration.parsedValue) {
return Math.round(avgDuration.parsedValue);
}
// 处理字符串类型
if (typeof avgDuration === 'string') {
return Math.round(parseFloat(avgDuration) || 0);
}
// 处理数字类型
if (typeof avgDuration === 'number') {
return Math.round(avgDuration);
}
// 默认返回0
return 0;
};
// 初始化图表
const initCharts = () => {
initStatusChart();
initTimeChart();
};
// 初始化状态分布饼图
const initStatusChart = () => {
if (!statusChartRef.value) return;
if (statusChart) {
statusChart.dispose();
}
statusChart = echarts.init(statusChartRef.value);
const option = {
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c} ({d}%)'
},
legend: {
orient: 'vertical',
left: 'left'
},
series: [
{
name: '预约状态',
type: 'pie',
radius: '50%',
data: statsData.value.statusStats.map(item => ({
value: parseInt(item.count || 0) || 0,
name: item.statusName || '未知状态'
})),
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
}
}
]
};
statusChart.setOption(option);
};
// 初始化时间趋势折线图
const initTimeChart = () => {
if (!timeChartRef.value) return;
if (timeChart) {
timeChart.dispose();
}
timeChart = echarts.init(timeChartRef.value);
const timeData = statsData.value.timeStats || [];
const dates = timeData.map(item => item.timeLabel || '未知日期');
const bookingCounts = timeData.map(item => parseInt(item.bookingCount || 0) || 0);
const approvedCounts = timeData.map(item => parseInt(item.approvedCount || 0) || 0);
const option = {
tooltip: {
trigger: 'axis'
},
legend: {
data: ['预约数量', '通过数量']
},
xAxis: {
type: 'category',
data: dates
},
yAxis: {
type: 'value'
},
series: [
{
name: '预约数量',
type: 'line',
data: bookingCounts,
smooth: true
},
{
name: '通过数量',
type: 'line',
data: approvedCounts,
smooth: true
}
]
};
timeChart.setOption(option);
};
// 组件挂载时加载数据
onMounted(() => {
loadData();
});
</script>
<style scoped>
.stat-card {
position: relative;
overflow: hidden;
}
.stat-card :deep(.el-card__body) {
padding: 20px;
display: flex;
align-items: center;
justify-content: space-between;
}
.stat-content {
flex: 1;
}
.stat-value {
font-size: 28px;
font-weight: bold;
color: #409eff;
line-height: 1;
margin-bottom: 8px;
}
.stat-label {
font-size: 14px;
color: #666;
}
.stat-icon {
font-size: 40px;
color: #409eff;
opacity: 0.3;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.mb20 {
margin-bottom: 20px;
}
.mb8 {
margin-bottom: 8px;
}
.ml10 {
margin-left: 10px;
}
.mr20 {
margin-right: 20px;
}
/* 确保页面可以滚动 */
.layout-padding {
overflow-x: auto !important;
overflow-y: auto !important;
max-width: 100% !important;
width: 100% !important;
}
.layout-padding-auto {
overflow-x: auto !important;
overflow-y: auto !important;
max-width: 100% !important;
width: 100% !important;
}
.layout-padding-view {
overflow-x: auto !important;
overflow-y: auto !important;
max-width: 100% !important;
width: 100% !important;
}
</style>
+3 -19
View File
@@ -2,11 +2,9 @@
<el-dialog :title="form.id ? '编辑' : '新增'" v-model="visible" :close-on-click-modal="false" draggable>
<el-form ref="dataFormRef" :model="form" :rules="dataRules" formDialogRef label-width="90px" v-loading="loading">
<el-row :gutter="24">
<el-col :span="12" class="mb20">
<el-form-item label="商户" prop="merchantId" required>
<el-select v-model="form.merchantId" placeholder="请选择商户" clearable>
<el-option v-for="merchant in merchantOptions" :key="merchant.value" :label="merchant.label" :value="merchant.value" />
</el-select>
<el-col :span="14" class="mb20">
<el-form-item label="商户" prop="merchantId">
<select-mch v-model="form.merchantId" :apiParams="{ types: '5' }" placeholder="请选择商户" />
</el-form-item>
</el-col>
@@ -58,8 +56,6 @@
import { useDict } from "/@/hooks/dict"
import { useMessage } from "/@/hooks/message"
import { getObj, addObj, putObj, fetchList } from "/@/api/supermarket/mallCategory"
import { selectOption as fetchMerchantSelect } from "/@/api/supermarket/mallMerchant"
import { rule } from "/@/utils/validate"
import { ref, reactive, nextTick, onMounted } from "vue"
const emit = defineEmits(["refresh"])
@@ -90,9 +86,6 @@ const dataRules = ref({
// 分类选项
const categoryOptions = ref<any[]>([])
// 商户选项
const merchantOptions = ref<any[]>([])
// 获取所有分类(排除自身)
const loadCategories = async () => {
const res = await fetchList({ size: 9999 })
@@ -102,15 +95,8 @@ const loadCategories = async () => {
}
}
// 加载商户选项
const loadMerchantOptions = async () => {
const res = await fetchMerchantSelect()
merchantOptions.value = res.data || []
}
onMounted(() => {
loadCategories()
loadMerchantOptions()
})
// 打开弹窗
@@ -132,7 +118,6 @@ const openDialog = (id: string) => {
} else {
// 新增时直接加载分类和商户
loadCategories()
loadMerchantOptions()
}
}
@@ -163,7 +148,6 @@ const getmallCategoryData = (id: string) => {
Object.assign(form, res.data)
// 编辑时加载分类和商户并排除自己
loadCategories()
loadMerchantOptions()
})
.finally(() => {
loading.value = false
+1 -1
View File
@@ -55,7 +55,7 @@
@sort-change="sortChangeHandle"
>
<el-table-column type="selection" width="50" align="center" />
<el-table-column type="index" label="#" width="40" />
<el-table-column type="index" label="#" width="50" />
<el-table-column prop="name" label="分类名称" show-overflow-tooltip />
<el-table-column prop="sort" label="排序" show-overflow-tooltip />
<el-table-column prop="status" label="状态" show-overflow-tooltip>
+14 -30
View File
@@ -6,10 +6,8 @@
<el-tab-pane label="基本信息" name="basic">
<el-row :gutter="24">
<el-col :span="12" class="mb20">
<el-form-item label="商户" prop="merchantId" required>
<el-select v-model="form.merchantId" placeholder="请选择商户" clearable @change="onMerchantChange">
<el-option v-for="merchant in merchantOptions" :key="merchant.value" :label="merchant.label" :value="merchant.value" />
</el-select>
<el-form-item label="商户" prop="merchantId">
<select-mch v-model="form.merchantId" :apiParams="{ types: '5' }" placeholder="请选择商户" />
</el-form-item>
</el-col>
@@ -232,12 +230,12 @@
</template>
<script setup lang="ts" name="MallProductDialog">
import { watch } from "vue"
import { useDict } from "/@/hooks/dict"
import { useMessage } from "/@/hooks/message"
import { getObj, addObj, putObj } from "/@/api/supermarket/mallProduct"
import { Plus } from "@element-plus/icons-vue"
import { tree as fetchCategoryTree } from "/@/api/supermarket/mallCategory"
import { selectOption as fetchMerchantSelect } from "/@/api/supermarket/mallMerchant"
const emit = defineEmits(["refresh"])
@@ -253,9 +251,6 @@ const { spu_status } = useDict("spu_status")
// 分类选项
const categoryOptions = ref<any[]>([])
// 商户选项
const merchantOptions = ref<any[]>([])
// 加载分类树
const loadCategoryTree = async (merchantId?: string) => {
if (!merchantId) {
@@ -266,20 +261,6 @@ const loadCategoryTree = async (merchantId?: string) => {
categoryOptions.value = res.data || []
}
// 加载商户选项
const loadMerchantOptions = async () => {
const res = await fetchMerchantSelect()
merchantOptions.value = res.data || []
}
// 商户选择变化处理
const onMerchantChange = (merchantId: string) => {
// 清空分类选择
form.categoryId = ""
// 根据商户ID加载对应的分类树
loadCategoryTree(merchantId)
}
// 提交表单数据
const form = reactive({
id: "",
@@ -329,6 +310,17 @@ const dataRules = ref({
status: [{ required: true, message: "状态不能为空", trigger: "change" }],
})
// 商户选择变化处理
watch(
() => form.merchantId,
(merchantId: string) => {
// 清空分类选择
form.categoryId = ""
// 根据商户ID加载对应的分类树
loadCategoryTree(merchantId)
},
)
// 添加SKU
const addSku = () => {
form.skuList.push({
@@ -406,9 +398,6 @@ const openDialog = (id: string) => {
addSku()
})
// 重新加载商户选项
loadMerchantOptions()
// 获取mallProduct信息
if (id) {
form.id = id
@@ -548,11 +537,6 @@ function findCategoryPath(options: any[], targetId: any, path: any[] = []): any[
return []
}
// 组件挂载时加载商户选项
onMounted(() => {
loadMerchantOptions()
})
// 暴露变量
defineExpose({
openDialog,
+6 -10
View File
@@ -209,16 +209,12 @@
import { BasicTableProps, useTable } from "/@/hooks/table"
import { fetchList, delObjs } from "/@/api/supermarket/mallProduct"
import { useMessage, useMessageBox } from "/@/hooks/message"
import { useDict } from "/@/hooks/dict"
import { tree as fetchCategoryTree } from "/@/api/supermarket/mallCategory"
import { selectOption as fetchMerchantSelect } from "/@/api/supermarket/mallMerchant"
import { select } from "/@/api/wallet/mch-manage"
// 引入组件
const FormDialog = defineAsyncComponent(() => import("./form.vue"))
// 定义查询字典
const { spu_status } = useDict("spu_status")
// 分类选项
const categoryOptions = ref<any[]>([])
@@ -233,14 +229,14 @@ const loadCategoryTree = async () => {
// 加载商户选项
const loadMerchantOptions = async () => {
const res = await fetchMerchantSelect()
merchantOptions.value = res.data || []
const res = await select({ current: 1, size: 999999, type: 5 })
merchantOptions.value = res.data.records || []
}
// 定义变量内容
const formDialogRef = ref()
const skuDetailVisible = ref(false)
const currentProduct = ref(null)
const currentProduct = ref<any>(null)
// 搜索变量
const queryRef = ref()
@@ -297,8 +293,8 @@ const getCategoryName = (categoryId: any) => {
// 根据商户ID查找商户名称
const getMerchantName = (merchantId: any) => {
if (!merchantId || !merchantOptions.value.length) return "-"
const merchant = merchantOptions.value.find((item) => item.value === merchantId)
return merchant ? merchant.label : "-"
const merchant = merchantOptions.value.find((item) => item.id === merchantId)
return merchant ? merchant.name : "-"
}
// 显示SKU详情
@@ -338,3 +338,10 @@ const handleCurrentChange = (page: number) => {
}
</style>
@@ -190,3 +190,8 @@ defineExpose({
}
</style>
@@ -202,3 +202,12 @@ const trendClass = computed(() => {
}
</style>
@@ -6,3 +6,13 @@ export { default as SalesChart } from './SalesChart.vue';
export { default as SummaryCard } from './SummaryCard.vue';
export { default as ProductRankingTable } from './ProductRankingTable.vue';
@@ -206,3 +206,13 @@ export const SUCCESS_MESSAGES = {
CACHE_REFRESHED: '缓存刷新成功',
};
+328 -160
View File
@@ -254,7 +254,7 @@
<template #header>
<div class="card-header">
<span>畅销品TOP10</span>
<!-- <el-button size="small" @click="viewAllTopProducts">查看全部</el-button> -->
<el-button size="small" @click="viewAllTopProducts">查看全部</el-button>
</div>
</template>
<el-table :data="topSellingProducts" stripe v-loading="loading">
@@ -299,7 +299,7 @@
<template #header>
<div class="card-header">
<span>滞销品TOP10</span>
<!-- <el-button size="small" @click="viewAllSlowProducts">查看全部</el-button> -->
<el-button size="small" @click="viewAllSlowProducts">查看全部</el-button>
</div>
</template>
<el-table :data="slowSellingProducts" stripe v-loading="loading">
@@ -415,20 +415,11 @@
</el-table-column>
</el-table>
</el-card>
<!-- 商品排名详情弹窗 -->
<!-- <ProductRankingDialog
:visible="dialogVisible"
:type="dialogType"
:query-params="queryForm"
@close="handleDialogClose"
@view-detail="handleViewProductDetail"
/> -->
</div>
</template>
<script lang="ts" setup name="RealtimeSalesReport">
import { ref, reactive, onMounted, nextTick, watch } from 'vue';
import { ref, reactive, onMounted, nextTick } from 'vue';
import { ElMessage } from 'element-plus';
import {
Refresh,
@@ -451,21 +442,15 @@ import {
getCustomerAnalysis,
refreshRealtimeData,
} from '/@/api/supermarketerp/realtimeSalesReport';
// import ProductRankingDialog from './components/ProductRankingDialog.vue';
// 响应式数据
const refreshing = ref(false);
const loading = ref(false);
const isInitialized = ref(false);
const debounceTimer = ref<number | null>(null);
const debounceTimer = ref<NodeJS.Timeout | null>(null);
const salesTrendChart = ref<HTMLElement>();
const customerAnalysisChart = ref<HTMLElement>();
// 弹窗相关数据(已注释)
// const dialogVisible = ref(false);
// const dialogType = ref<'top' | 'slow'>('top');
// const selectedProduct = ref(null);
// 查询表单
const queryForm = reactive({
queryType: 'day',
@@ -479,16 +464,16 @@ const queryForm = reactive({
});
// 高级选项显示状态
const advancedOptionsVisible = ref<string[]>([]);
const advancedOptionsVisible = ref([]);
// 数据
const salesSummary = ref<any>();
const topSellingProducts = ref<any[]>([]);
const slowSellingProducts = ref<any[]>([]);
const timePeriodSales = ref<any[]>([]);
const customerAnalysis = ref<any>();
const salesTrend = ref<any[]>([]);
const expandedRows = ref<string[]>([]);
const salesSummary = ref();
const topSellingProducts = ref([]);
const slowSellingProducts = ref([]);
const timePeriodSales = ref([]);
const customerAnalysis = ref();
const salesTrend = ref([]);
const expandedRows = ref([]);
// 图表实例
let salesTrendChartInstance: echarts.ECharts | null = null;
@@ -507,11 +492,9 @@ const initQueryForm = () => {
queryForm.startDate = today.toISOString().slice(0, 10);
queryForm.endDate = now.toISOString().slice(0, 10);
} else if (queryForm.queryType === 'month') {
// 确保按月查询时设置正确的默认值
const thisMonth = now.toISOString().slice(0, 7);
queryForm.startDate = thisMonth;
queryForm.endDate = thisMonth;
console.log('初始化按月查询默认值:', { startDate: queryForm.startDate, endDate: queryForm.endDate });
}
};
@@ -613,99 +596,20 @@ watch(
{ deep: true }
);
// 处理查询参数,确保日期格式正确
const processQueryParams = (params: any) => {
const processedParams = { ...params };
// 如果是按月查询,需要将YYYY-MM格式转换为完整的日期范围
if (params.queryType === 'month') {
if (params.startDate) {
try {
// 确保日期格式正确,处理可能的格式问题
let dateStr = params.startDate;
if (typeof dateStr === 'string' && dateStr.length === 7 && dateStr.includes('-')) {
// 已经是 YYYY-MM 格式
const startDate = new Date(dateStr + '-01');
if (isNaN(startDate.getTime())) {
throw new Error('Invalid start date');
}
processedParams.startDate = startDate.toISOString().slice(0, 10);
console.log(`按月查询 - 开始日期转换: ${params.startDate} -> ${processedParams.startDate}`);
} else {
console.warn('按月查询 - 开始日期格式不正确:', params.startDate);
// 如果格式不正确,尝试使用当前月份
const now = new Date();
const currentMonth = now.toISOString().slice(0, 7);
const fallbackDate = new Date(currentMonth + '-01');
processedParams.startDate = fallbackDate.toISOString().slice(0, 10);
console.log(`按月查询 - 使用默认开始日期: ${processedParams.startDate}`);
}
} catch (error) {
console.error('按月查询 - 开始日期转换失败:', error);
// 使用当前月份作为默认值
const now = new Date();
const currentMonth = now.toISOString().slice(0, 7);
const fallbackDate = new Date(currentMonth + '-01');
processedParams.startDate = fallbackDate.toISOString().slice(0, 10);
}
}
if (params.endDate) {
try {
// 确保日期格式正确,处理可能的格式问题
let dateStr = params.endDate;
if (typeof dateStr === 'string' && dateStr.length === 7 && dateStr.includes('-')) {
// 已经是 YYYY-MM 格式
const endDate = new Date(dateStr + '-01');
if (isNaN(endDate.getTime())) {
throw new Error('Invalid end date');
}
endDate.setMonth(endDate.getMonth() + 1, 0); // 设置为下个月的第0天,即当前月的最后一天
processedParams.endDate = endDate.toISOString().slice(0, 10);
console.log(`按月查询 - 结束日期转换: ${params.endDate} -> ${processedParams.endDate}`);
} else {
console.warn('按月查询 - 结束日期格式不正确:', params.endDate);
// 如果格式不正确,尝试使用当前月份
const now = new Date();
const currentMonth = now.toISOString().slice(0, 7);
const fallbackDate = new Date(currentMonth + '-01');
fallbackDate.setMonth(fallbackDate.getMonth() + 1, 0);
processedParams.endDate = fallbackDate.toISOString().slice(0, 10);
console.log(`按月查询 - 使用默认结束日期: ${processedParams.endDate}`);
}
} catch (error) {
console.error('按月查询 - 结束日期转换失败:', error);
// 使用当前月份作为默认值
const now = new Date();
const currentMonth = now.toISOString().slice(0, 7);
const fallbackDate = new Date(currentMonth + '-01');
fallbackDate.setMonth(fallbackDate.getMonth() + 1, 0);
processedParams.endDate = fallbackDate.toISOString().slice(0, 10);
}
}
}
console.log('处理后的查询参数:', processedParams);
return processedParams;
};
// 加载数据
const loadData = async () => {
loading.value = true;
try {
// 处理查询参数
const processedParams = processQueryParams(queryForm);
// 加载完整报表数据
const reportData = await getRealtimeSalesReport(processedParams);
const reportData = await getRealtimeSalesReport(queryForm);
if (reportData.code === 0 && reportData.data) {
// 使用后端返回的数据
salesSummary.value = reportData.data.salesSummary || {};
salesSummary.value = reportData.data.salesSummary || getMockSalesSummary();
topSellingProducts.value = reportData.data.topSellingProducts || [];
slowSellingProducts.value = reportData.data.slowSellingProducts || [];
timePeriodSales.value = reportData.data.timePeriodSales || [];
customerAnalysis.value = reportData.data.customerAnalysis || {};
customerAnalysis.value = reportData.data.customerAnalysis || getMockCustomerAnalysis();
salesTrend.value = reportData.data.salesTrend || [];
ElMessage.success('数据加载成功');
@@ -718,31 +622,245 @@ const loadData = async () => {
renderCharts();
} catch (error) {
console.error('加载数据失败:', error);
// 清空数据
salesSummary.value = {};
topSellingProducts.value = [];
slowSellingProducts.value = [];
timePeriodSales.value = [];
customerAnalysis.value = {};
salesTrend.value = [];
// 使用模拟数据
salesSummary.value = getMockSalesSummary();
topSellingProducts.value = getMockTopProducts();
slowSellingProducts.value = getMockSlowProducts();
timePeriodSales.value = getMockTimePeriodSales();
customerAnalysis.value = getMockCustomerAnalysis();
salesTrend.value = []; // 空数组,不显示模拟数据
await nextTick();
renderCharts();
// 根据错误类型显示不同的提示信息
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes('权限')) {
if (error.message && error.message.includes('权限')) {
ElMessage.error('权限不足,无法访问即时销售报表');
} else if (errorMessage.includes('网络')) {
} else if (error.message && error.message.includes('网络')) {
ElMessage.error('网络连接异常,请检查网络设置');
} else {
ElMessage.error('数据加载失败,请稍后重试');
ElMessage.warning('数据加载失败,使用模拟数据展示');
}
} finally {
loading.value = false;
}
};
// 模拟数据生成函数 - 根据后端API文档调整
const getMockSalesSummary = () => ({
totalSalesAmount: 12500.50,
totalSalesCount: 45,
averageCustomerValue: 277.79,
productVarietyCount: 23,
totalSalesQuantity: 156.5,
salesGrowthRate: 15.5,
salesGrowthRateMonth: 8.2,
realtimeStatus: 'MEDIUM',
});
const getMockTopProducts = () => [
{
productId: 1001,
productName: '苹果',
productCode: 'P001',
salesAmount: 1250.00,
salesQuantity: 50.0,
salesCount: 12,
averagePrice: 25.00,
ranking: 1,
stockQuantity: 200.0,
stockStatus: 'SUFFICIENT',
growthRate: 12.5
},
{
productId: 1002,
productName: '牛奶',
productCode: 'M002',
salesAmount: 980.00,
salesQuantity: 49.0,
salesCount: 10,
averagePrice: 20.00,
ranking: 2,
stockQuantity: 150.0,
stockStatus: 'SUFFICIENT',
growthRate: 8.3
},
{
productId: 1003,
productName: '面包',
productCode: 'B003',
salesAmount: 850.00,
salesQuantity: 34.0,
salesCount: 8,
averagePrice: 25.00,
ranking: 3,
stockQuantity: 5.0,
stockStatus: 'LOW',
growthRate: 15.2
},
{
productId: 1004,
productName: '矿泉水',
productCode: 'W004',
salesAmount: 720.00,
salesQuantity: 36.0,
salesCount: 7,
averagePrice: 20.00,
ranking: 4,
stockQuantity: 0.0,
stockStatus: 'OUT',
growthRate: 6.7
},
{
productId: 1005,
productName: '鸡蛋',
productCode: 'E005',
salesAmount: 680.00,
salesQuantity: 68.0,
salesCount: 6,
averagePrice: 10.00,
ranking: 5,
stockQuantity: 100.0,
stockStatus: 'SUFFICIENT',
growthRate: 4.2
},
];
const getMockSlowProducts = () => [
{
productId: 2001,
productName: '高级红酒',
productCode: 'W001',
salesAmount: 120.00,
salesQuantity: 1.2,
salesCount: 1,
averagePrice: 100.00,
ranking: 1,
stockQuantity: 50.0,
stockStatus: 'SUFFICIENT',
growthRate: -15.3
},
{
productId: 2002,
productName: '进口巧克力',
productCode: 'C002',
salesAmount: 80.00,
salesQuantity: 2.0,
salesCount: 2,
averagePrice: 40.00,
ranking: 2,
stockQuantity: 8.0,
stockStatus: 'LOW',
growthRate: -8.7
},
{
productId: 2003,
productName: '有机蔬菜',
productCode: 'V003',
salesAmount: 60.00,
salesQuantity: 3.0,
salesCount: 1,
averagePrice: 20.00,
ranking: 3,
stockQuantity: 15.0,
stockStatus: 'SUFFICIENT',
growthRate: -12.1
},
{
productId: 2004,
productName: '高端茶叶',
productCode: 'T004',
salesAmount: 40.00,
salesQuantity: 0.8,
salesCount: 1,
averagePrice: 50.00,
ranking: 4,
stockQuantity: 0.0,
stockStatus: 'OUT',
growthRate: -20.5
},
{
productId: 2005,
productName: '进口咖啡',
productCode: 'C005',
salesAmount: 30.00,
salesQuantity: 0.6,
salesCount: 1,
averagePrice: 50.00,
ranking: 5,
stockQuantity: 2.0,
stockStatus: 'LOW',
growthRate: -25.8
},
];
const getMockTimePeriodSales = () => {
const data = [];
const products = [
{ name: '苹果', code: 'P001', category: '水果', unit: 'kg', unitName: 'kg' },
{ name: '牛奶', code: 'M002', category: '乳制品', unit: '瓶', unitName: '瓶' },
{ name: '面包', code: 'B003', category: '烘焙', unit: '个', unitName: '个' },
{ name: '矿泉水', code: 'W004', category: '饮料', unit: '瓶', unitName: '瓶' },
{ name: '鸡蛋', code: 'E005', category: '蛋类', unit: '个', unitName: '个' },
];
for (let i = 0; i < 24; i++) {
const productCount = Math.floor(Math.random() * 3) + 1;
const productDetails = [];
let totalAmount = 0;
let totalQuantity = 0;
for (let j = 0; j < productCount; j++) {
const product = products[Math.floor(Math.random() * products.length)];
const quantity = Math.floor(Math.random() * 5) + 1;
const unitPrice = Math.floor(Math.random() * 50) + 10;
const amount = quantity * unitPrice;
productDetails.push({
productId: `mock_${i}_${j}`,
productName: product.name,
productCode: product.code,
categoryId: `cat_${j}`,
categoryName: product.category,
unit: product.unit,
unitName: product.unitName,
quantity: quantity,
unitPrice: unitPrice,
totalAmount: amount,
salesCount: 1,
});
totalAmount += amount;
totalQuantity += quantity;
}
data.push({
timeLabel: `${i.toString().padStart(2, '0')}:00`,
salesAmount: totalAmount,
salesCount: productCount,
customerValue: totalAmount / productCount,
salesQuantity: totalQuantity,
growthRate: Math.floor(Math.random() * 20) - 10,
productDetails: productDetails,
});
}
return data;
};
const getMockCustomerAnalysis = () => ({
averageCustomerValue: 277.79,
maxCustomerValue: 850.00,
minCustomerValue: 15.50,
medianCustomerValue: 250.00,
highValueOrderRatio: 25.5,
valueRanges: [
{ rangeLabel: '0-50元', orderCount: 8, percentage: 17.78 },
{ rangeLabel: '50-100元', orderCount: 12, percentage: 26.67 },
{ rangeLabel: '100-200元', orderCount: 15, percentage: 33.33 },
{ rangeLabel: '200-500元', orderCount: 8, percentage: 17.78 },
{ rangeLabel: '500+元', orderCount: 2, percentage: 4.44 },
],
});
// 刷新数据
@@ -774,48 +892,30 @@ const refreshCharts = async () => {
// 刷新时段数据
const refreshTimePeriodData = async () => {
try {
const processedParams = processQueryParams(queryForm);
const timePeriodData = await getTimePeriodSales({
...processedParams,
...queryForm,
current: 1,
size: 50,
});
timePeriodSales.value = timePeriodData.data?.records || [];
timePeriodSales.value = timePeriodData.data?.records || getMockTimePeriodSales();
ElMessage.success('时段数据刷新成功');
} catch (error) {
console.error('刷新时段数据失败:', error);
timePeriodSales.value = [];
ElMessage.error('时段数据刷新失败');
timePeriodSales.value = getMockTimePeriodSales();
ElMessage.warning('使用模拟数据');
}
};
// 查看全部畅销品(已注释)
// const viewAllTopProducts = () => {
// dialogType.value = 'top';
// dialogVisible.value = true;
// };
// 查看全部滞销品(已注释)
// const viewAllSlowProducts = () => {
// dialogType.value = 'slow';
// dialogVisible.value = true;
// };
// 关闭弹窗(已注释)
// const handleDialogClose = () => {
// dialogVisible.value = false;
// selectedProduct.value = null;
// };
// 查看商品详情(已注释)
// const handleViewProductDetail = (product: any) => {
// selectedProduct.value = product;
// ElMessage.info(`查看商品详情: ${product.productName}`);
// // 这里可以打开商品详情弹窗或跳转到详情页面
// console.log('商品详情:', product);
// };
// 查看全部畅销品
const viewAllTopProducts = () => {
ElMessage.info('查看全部畅销品功能开发中...');
};
// 查看全部滞销品
const viewAllSlowProducts = () => {
ElMessage.info('查看全部滞销品功能开发中...');
};
// 渲染销售趋势图表
const renderSalesTrendChart = () => {
@@ -826,9 +926,9 @@ const renderSalesTrendChart = () => {
}
// 使用后端返回的销售趋势数据
let xAxisData: string[] = [];
let salesData: number[] = [];
let countData: number[] = [];
let xAxisData = [];
let salesData = [];
let countData = [];
if (salesTrend.value && salesTrend.value.length > 0) {
// 使用后端返回的真实数据
@@ -920,22 +1020,28 @@ const renderCustomerAnalysisChart = () => {
}
// 使用后端数据结构
let data: any[] = [];
let data = [];
if (customerAnalysis.value && customerAnalysis.value.valueRanges) {
data = customerAnalysis.value.valueRanges.map((range: any) => ({
data = customerAnalysis.value.valueRanges.map(range => ({
name: range.rangeLabel,
value: range.orderCount,
percentage: range.percentage
}));
} else {
// 没有数据时显示空数组
data = [];
// 模拟数据
data = [
{ name: '0-50元', value: 25, percentage: 15.5 },
{ name: '50-100元', value: 35, percentage: 21.7 },
{ name: '100-200元', value: 20, percentage: 12.4 },
{ name: '200-500元', value: 15, percentage: 9.3 },
{ name: '500+元', value: 5, percentage: 3.1 },
];
}
const option = {
tooltip: {
trigger: 'item',
formatter: function(params: any) {
formatter: function(params) {
return `${params.name}<br/>订单数: ${params.value}<br/>占比: ${params.data.percentage}%`;
},
},
@@ -1065,6 +1171,68 @@ const getNumericValue = (value: any) => {
};
// 根据类型生成模拟时段数据
const getMockTimePeriodSalesByType = (type: string) => {
const data = [];
if (type === 'hour') {
// 按小时生成24小时数据
for (let i = 0; i < 24; i++) {
data.push({
timePeriod: `${i}:00`,
timePeriodName: `${i}:00-${i + 1}:00`,
salesAmount: Math.floor(Math.random() * 5000) + 1000,
salesCount: Math.floor(Math.random() * 50) + 10,
salesQuantity: Math.floor(Math.random() * 100) + 20,
averageCustomerPrice: Math.floor(Math.random() * 50) + 50,
customerCount: Math.floor(Math.random() * 20) + 5,
productVarietyCount: Math.floor(Math.random() * 30) + 10,
yearOverYearSalesAmount: Math.floor(Math.random() * 1000) - 500,
monthOverMonthSalesAmount: Math.floor(Math.random() * 500) - 250,
});
}
} else if (type === 'day') {
// 按天生成7天数据
for (let i = 6; i >= 0; i--) {
const date = new Date();
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().slice(0, 10);
data.push({
timePeriod: dateStr,
timePeriodName: dateStr,
salesAmount: Math.floor(Math.random() * 20000) + 5000,
salesCount: Math.floor(Math.random() * 200) + 50,
salesQuantity: Math.floor(Math.random() * 500) + 100,
averageCustomerPrice: Math.floor(Math.random() * 100) + 50,
customerCount: Math.floor(Math.random() * 50) + 20,
productVarietyCount: Math.floor(Math.random() * 50) + 30,
yearOverYearSalesAmount: Math.floor(Math.random() * 5000) - 2500,
monthOverMonthSalesAmount: Math.floor(Math.random() * 2000) - 1000,
});
}
} else if (type === 'month') {
// 按月生成12个月数据
for (let i = 11; i >= 0; i--) {
const date = new Date();
date.setMonth(date.getMonth() - i);
const monthStr = date.toISOString().slice(0, 7);
data.push({
timePeriod: monthStr,
timePeriodName: monthStr,
salesAmount: Math.floor(Math.random() * 100000) + 50000,
salesCount: Math.floor(Math.random() * 1000) + 500,
salesQuantity: Math.floor(Math.random() * 2000) + 1000,
averageCustomerPrice: Math.floor(Math.random() * 200) + 100,
customerCount: Math.floor(Math.random() * 200) + 100,
productVarietyCount: Math.floor(Math.random() * 100) + 50,
yearOverYearSalesAmount: Math.floor(Math.random() * 20000) - 10000,
monthOverMonthSalesAmount: Math.floor(Math.random() * 10000) - 5000,
});
}
}
return data;
};
// 组件挂载
onMounted(async () => {
@@ -135,3 +135,6 @@ export function formatTimePeriodDataForExport(data: any[]) {
增长率: `${item.growthRate || 0}%`
}));
}
@@ -111,12 +111,12 @@ export const generateRandomData = (min: number, max: number): number => {
export const generateTimeSeries = (startTime: Date, endTime: Date, interval: number = 60): string[] => {
const times: string[] = [];
const current = new Date(startTime);
while (current <= endTime) {
times.push(formatDateTime(current, 'HH:mm'));
current.setMinutes(current.getMinutes() + interval);
}
return times;
};
@@ -128,7 +128,7 @@ export const generateTimeSeries = (startTime: Date, endTime: Date, interval: num
export const getDefaultTimeRange = (type: 'hour' | 'day' | 'month' = 'day') => {
const now = new Date();
const startTime = new Date(now);
switch (type) {
case 'hour':
startTime.setHours(now.getHours() - 24);
@@ -140,7 +140,7 @@ export const getDefaultTimeRange = (type: 'hour' | 'day' | 'month' = 'day') => {
startTime.setMonth(now.getMonth() - 6);
break;
}
return {
startTime: formatDateTime(startTime),
endTime: formatDateTime(now),
@@ -195,3 +195,13 @@ export const TABLE_CONFIG = {
showOverflowTooltip: true,
};
@@ -365,3 +365,6 @@ class ProfitMarginReportServiceTest {
7. **测试覆盖**: 完善单元测试和集成测试
这样重构后的代码将更加清晰、可维护,并且性能更好。