feat(hall-audit): improve time conflict detection

- Use PUT method for audit API to match backend
- Detect internal conflicts among selected records only when multiple selected
- Differentiate conflict origin with tags (本次批次 / 已审批)
- Format time with dayjs for backend checkTimeConflict API
- Fix rotate-loading icon class and layout styles
This commit is contained in:
zlwu committed 2025-07-10 08:28:46 +08:00
1 parent d2781d4470
commit 8675ef4581
2 files changed
+453 -73

No files matched your search

+23 -68
View File
@@ -1,84 +1,39 @@
import request from '@/utils/request';
import { AxiosPromise } from 'axios';
import { HallReservationVO, HallReservationForm, HallReservationQuery } from '@/api/hq/hallReservation/types';
import { HallReservationVO, HallReservationQuery } from '../hallReservation/types';
import { HallReservationAuditForm, TimeCheckParams, TimeCheckResult } from './types';
import { TableDataInfo, R } from '@/api/types';
/**
* 查询报告厅预约列表
* @param query
* @returns {*}
*/
export const listHallReservation = (query?: HallReservationQuery): AxiosPromise<HallReservationVO[]> => {
return request({
// 查询报告厅预约列表
export function listHallReservation(query: HallReservationQuery) {
return request<TableDataInfo<HallReservationVO>>({
url: '/hq/hallReservation/list',
method: 'get',
params: query
});
};
}
/**
* 查询报告厅预约详细
* @param id
*/
export const getHallReservation = (id: string | number): AxiosPromise<HallReservationVO> => {
return request({
// 获取报告厅预约详细信息
export function getHallReservation(id: string | number) {
return request<R<HallReservationVO>>({
url: '/hq/hallReservation/' + id,
method: 'get'
});
};
/**
* 新增报告厅预约
* @param data
*/
export const addHallReservation = (data: HallReservationForm) => {
return request({
url: '/hq/hallReservation',
method: 'post',
data: data
});
};
/**
* 修改报告厅预约
* @param data
*/
export const updateHallReservation = (data: HallReservationForm) => {
return request({
url: '/hq/hallReservation',
method: 'put',
data: data
});
};
/**
* 删除报告厅预约
* @param id
*/
export const delHallReservation = (id: string | number | Array<string | number>) => {
return request({
url: '/hq/hallReservation/' + id,
method: 'delete'
});
};
/**
* 审核报告厅预约(批准/拒绝)
* @param data
*/
export interface HallReservationAuditForm {
/** 预约记录ID列表 */
ids: Array<string | number>;
/** 审核状态:1-批准,2-拒绝 */
status: string;
/** 审核备注 */
auditRemark?: string;
}
export const auditHallReservation = (data: HallReservationAuditForm) => {
return request({
// 审核报告厅预约
export function auditHallReservation(data: HallReservationAuditForm) {
return request<R<any>>({
url: '/hq/hallReservation/audit',
method: 'put',
data: data
});
};
}
// 检查报告厅预约时间是否存在冲突
export function checkTimeConflict(data: TimeCheckParams) {
return request<R<TimeCheckResult>>({
url: '/hq/hallReservation/checkTimeConflict',
method: 'post',
data: data
});
}
+430 -5
View File
@@ -268,8 +268,7 @@
<div class="reservation-details">
<div class="detail-row">
<span class="detail-label">申请人:</span>
<span class="detail-value">{{ record.applicantName }}</span>
<span class="detail-phone">({{ record.applicantPhone || '手机号暂无' }})</span>
<span class="detail-value"> {{ record.applicantName }}({{ record.applicantPhone || '手机号暂无' }}) </span>
</div>
<div class="detail-row">
<span class="detail-label">预约时间:</span>
@@ -281,6 +280,46 @@
<span class="detail-label">预约用途:</span>
<span class="detail-value purpose-content">{{ record.reservationPurpose }}</span>
</div>
<div class="detail-row">
<span class="detail-label">审核建议:</span>
<span class="detail-value audit-suggestion-content" :class="getConflictStatusClass(record)">
<template v-if="getConflictCheckStatus(record).isLoading">
<el-icon class="rotate-loading"><Loading /></el-icon> 正在检查时间冲突...
</template>
<template v-else-if="getConflictCheckStatus(record).hasError">
<el-icon class="text-warning"><Warning /></el-icon> 冲突检查失败,请手动核实
</template>
<template v-else-if="hasTimeConflict(record)">
<el-icon class="text-danger"><Warning /></el-icon> 发现时间冲突,建议拒绝
<div class="conflict-details" v-if="getConflictReservations(record).length > 0">
<small>冲突详情:</small>
<ul class="conflict-list">
<li v-for="conflict in getConflictReservations(record)" :key="conflict.id">
<el-tag type="warning" size="small" v-if="conflict.internal">本次批次</el-tag>
<el-tag type="info" size="small" v-else>已审批</el-tag>
{{ conflict.applicantName }} ({{ parseTime(conflict.startTime, '{h}:{i}') }}-{{
parseTime(conflict.endTime, '{h}:{i}')
}})
</li>
</ul>
</div>
</template>
<template v-else-if="getConflictCheckStatus(record).isChecked">
<el-icon class="text-success"><Check /></el-icon> 无时间冲突,可以批准
</template>
<template v-else>
<el-icon class="text-info"><InfoFilled /></el-icon> 未检查时间冲突
<el-button
type="text"
size="small"
@click="manualCheckConflict(record)"
:loading="getConflictCheckStatus(record).isLoading"
>
立即检查
</el-button>
</template>
</span>
</div>
</div>
</div>
</div>
@@ -297,6 +336,22 @@
{{ auditForm.status === '1' ? '批准' : '拒绝' }}
</el-tag>
</div>
<!-- 批准时显示冲突检查统计 -->
<div v-if="auditForm.status === '1'" class="conflict-summary">
<span class="audit-label">冲突检查统计:</span>
<div class="summary-stats">
<el-tag type="info" size="small"> 总计:{{ selectedRecords.length }} 个预约 </el-tag>
<el-tag type="success" size="small"> 无冲突:{{ getNoConflictCount() }} 个 </el-tag>
<el-tag type="danger" size="small" v-if="getConflictCount() > 0"> 有冲突:{{ getConflictCount() }} 个 </el-tag>
<el-tag type="warning" size="small" v-if="getUncheckCount() > 0"> 未检查:{{ getUncheckCount() }} 个 </el-tag>
</div>
<div v-if="getConflictCount() > 0" class="conflict-warning-text">
<el-icon><Warning /></el-icon>
建议先处理时间冲突的预约,或选择拒绝有冲突的申请
</div>
</div>
<div class="audit-remark-section">
<span class="audit-label">审核备注:</span>
<el-input
@@ -323,7 +378,8 @@
</template>
<script setup name="HallAudit" lang="ts">
import { listHallReservation, getHallReservation, auditHallReservation, HallReservationAuditForm } from '@/api/hq/hallAudit';
import { listHallReservation, getHallReservation, auditHallReservation, HallReservationAuditForm, checkTimeConflict } from '@/api/hq/hallAudit';
import dayjs from 'dayjs';
import { listHallInfo } from '@/api/hq/hallInfo';
import { HallReservationVO, HallReservationQuery, HallReservationForm } from '@/api/hq/hallReservation/types';
import { HallInfoVO } from '@/api/hq/hallInfo/types';
@@ -388,6 +444,10 @@ const auditForm = ref({
const selectedRecords = ref<HallReservationVO[]>([]);
const auditLoading = ref(false);
// 审核建议相关状态
const conflictCheckResults = reactive(new Map<string | number, any>());
const conflictCheckLoading = reactive(new Set<string | number>());
const queryParams = ref<HallReservationQuery>({
pageNum: 1,
pageSize: 10,
@@ -683,13 +743,22 @@ const handleReject = async (row?: HallReservationVO) => {
};
/** 打开审核对话框 */
const openAuditDialog = (status: string, ids: Array<string | number>, records: HallReservationVO[], title: string) => {
const openAuditDialog = async (status: string, ids: Array<string | number>, records: HallReservationVO[], title: string) => {
auditForm.value.status = status;
auditForm.value.auditRemark = '';
auditForm.value.ids = ids;
selectedRecords.value = records;
auditDialog.title = title;
auditDialog.visible = true;
// 在打开审核对话框时统一检查时间冲突(无论批准/拒绝)
// 清除选中记录旧的缓存(尤其是 internal 冲突信息)
records.forEach((rec) => conflictCheckResults.delete(rec.id));
if (records.length > 0) {
console.log('审核对话框打开,开始检查时间冲突...');
await batchCheckTimeConflict(records);
}
};
/** 确认审核操作 */
@@ -756,6 +825,242 @@ const handleExport = () => {
);
};
/** 检查单个记录的时间冲突 */
const checkRecordTimeConflict = async (record: HallReservationVO) => {
const recordId = record.id;
// 如果已经在检查中,跳过
if (conflictCheckLoading.has(recordId)) {
return;
}
// 如果已有缓存结果,直接返回
if (conflictCheckResults.has(recordId)) {
return conflictCheckResults.get(recordId);
}
try {
conflictCheckLoading.add(recordId);
const res = await checkTimeConflict({
hallId: record.hallId,
startTime: dayjs(record.startTime).format('YYYY-MM-DD HH:mm:ss'),
endTime: dayjs(record.endTime).format('YYYY-MM-DD HH:mm:ss'),
excludeId: record.id
});
const result = {
hasConflict: res.data.hasConflict,
conflictList: (res.data.conflictList || []).map((item: any) => ({ ...item, internal: false })),
conflictMessage: res.data.conflictMessage || '',
checkTime: new Date().getTime()
};
// 缓存结果
conflictCheckResults.set(recordId, result);
return result;
} catch (error) {
console.error('检查时间冲突失败:', error);
const errorResult = {
hasConflict: false,
conflictList: [],
conflictMessage: '冲突检查失败',
checkTime: new Date().getTime(),
error: true
};
conflictCheckResults.set(recordId, errorResult);
return errorResult;
} finally {
conflictCheckLoading.delete(recordId);
}
};
/** 批量检查选中记录的时间冲突 */
const batchCheckTimeConflict = async (records: HallReservationVO[]) => {
console.log('开始批量检查时间冲突,记录数:', records.length);
const promises = records.map((record) => checkRecordTimeConflict(record));
await Promise.all(promises);
// 前端额外校验勾选记录之间的时间交叉冲突(同一报告厅)
addInternalConflictInfo(records);
console.log('批量时间冲突检查完成');
};
/** 判断两个时间段是否重叠 */
const isTimeOverlap = (startA: any, endA: any, startB: any, endB: any) => {
const sA = dayjs(startA);
const eA = dayjs(endA);
const sB = dayjs(startB);
const eB = dayjs(endB);
return sA.isBefore(eB) && sB.isBefore(eA); // 开始 < 另一结束 且 另一开始 < 结束
};
/** 在选中记录之间标记内部冲突 */
const addInternalConflictInfo = (records: HallReservationVO[]) => {
// 若不足两条记录无需内部冲突检测
if (records.length < 2) return;
const hallGroups: Record<string, HallReservationVO[]> = {};
records.forEach((rec) => {
const key = `${rec.hallId || ''}`;
hallGroups[key] = hallGroups[key] || [];
hallGroups[key].push(rec);
});
Object.values(hallGroups).forEach((list) => {
// 对同一报告厅的申请做两两比较
for (let i = 0; i < list.length; i++) {
for (let j = i + 1; j < list.length; j++) {
const r1 = list[i];
const r2 = list[j];
if (isTimeOverlap(r1.startTime, r1.endTime, r2.startTime, r2.endTime)) {
// r1 冲突信息
const res1 = conflictCheckResults.get(r1.id) || {
hasConflict: false,
conflictList: [],
conflictMessage: '',
checkTime: new Date().getTime()
};
res1.hasConflict = true;
res1.conflictList.push({ ...r2, internal: true });
conflictCheckResults.set(r1.id, res1);
// r2 冲突信息
const res2 = conflictCheckResults.get(r2.id) || {
hasConflict: false,
conflictList: [],
conflictMessage: '',
checkTime: new Date().getTime()
};
res2.hasConflict = true;
res2.conflictList.push({ ...r1, internal: true });
conflictCheckResults.set(r2.id, res2);
}
}
}
});
};
/** 获取记录的冲突检查结果 */
const getConflictResult = (record: HallReservationVO) => {
return (
conflictCheckResults.get(record.id) || {
hasConflict: false,
conflictList: [],
conflictMessage: '未检查',
checkTime: 0
}
);
};
/** 检查是否存在时间冲突(同步方法) */
const hasTimeConflict = (record: HallReservationVO): boolean => {
const result = getConflictResult(record);
return result.hasConflict;
};
/** 获取时间冲突的预约列表(同步方法) */
const getConflictReservations = (record: HallReservationVO) => {
const result = getConflictResult(record);
return result.conflictList || [];
};
/** 获取冲突检查状态 */
const getConflictCheckStatus = (record: HallReservationVO) => {
const isLoading = conflictCheckLoading.has(record.id);
const result = getConflictResult(record);
return {
isLoading,
isChecked: result.checkTime > 0,
hasError: result.error || false,
result
};
};
/** 获取冲突状态对应的CSS类 */
const getConflictStatusClass = (record: HallReservationVO) => {
const status = getConflictCheckStatus(record);
if (status.isLoading) {
return 'checking-conflict';
}
if (status.hasError) {
return 'check-error';
}
if (status.isChecked) {
return hasTimeConflict(record) ? 'has-conflict' : 'no-conflict';
}
return 'not-checked';
};
/** 获取审核建议文本 */
const getAuditSuggestionText = (record: HallReservationVO) => {
const status = getConflictCheckStatus(record);
if (status.isLoading) {
return '正在检查时间冲突...';
}
if (status.hasError) {
return '冲突检查失败,请手动核实';
}
if (status.isChecked) {
return hasTimeConflict(record) ? '发现时间冲突,建议拒绝' : '无时间冲突,可以批准';
}
return '未检查时间冲突';
};
/** 手动触发时间冲突检查 */
const manualCheckConflict = async (record: HallReservationVO) => {
// 清除缓存,强制重新检查
conflictCheckResults.delete(record.id);
await checkRecordTimeConflict(record);
};
/** 获取无冲突的预约数量 */
const getNoConflictCount = () => {
return selectedRecords.value.filter((record) => {
const status = getConflictCheckStatus(record);
return status.isChecked && !hasTimeConflict(record);
}).length;
};
/** 获取有冲突的预约数量 */
const getConflictCount = () => {
return selectedRecords.value.filter((record) => {
const status = getConflictCheckStatus(record);
return status.isChecked && hasTimeConflict(record);
}).length;
};
/** 获取未检查的预约数量 */
const getUncheckCount = () => {
return selectedRecords.value.filter((record) => {
const status = getConflictCheckStatus(record);
return !status.isChecked && !status.isLoading;
}).length;
};
/** 获取预约状态文本 */
const getStatusText = (status: string) => {
const statusMap: { [key: string]: string } = {
'0': '审核中',
'1': '已批准',
'2': '已拒绝'
};
return statusMap[status] || status;
};
/** 页面加载完成后的初始化 */
onMounted(() => {
getList();
getHallList();
@@ -1057,7 +1362,7 @@ onMounted(() => {
.hall-id {
font-size: 14px;
color: #909399;
background: #f0f2f5;
/* background: #f0f2f5; */
padding: 4px 8px;
border-radius: 4px;
}
@@ -1192,6 +1497,126 @@ onMounted(() => {
font-size: 14px;
resize: none !important;
}
/* 审核建议样式 - 增强版 */
.audit-suggestion-content {
line-height: 1.6;
display: inline-flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.audit-suggestion-content .el-icon {
margin-right: 4px;
}
/* 冲突详情在新行展示 */
.audit-suggestion-content .conflict-details {
flex-basis: 100%;
}
/* 不同状态的颜色 */
.audit-suggestion-content.checking-conflict {
color: #409eff; /* 检查中 - 蓝色 */
}
.audit-suggestion-content.check-error {
color: #e6a23c; /* 检查错误 - 橙色 */
}
.audit-suggestion-content.has-conflict {
color: #f56c6c; /* 有冲突 - 红色 */
}
.audit-suggestion-content.no-conflict {
color: #67c23a; /* 无冲突 - 绿色 */
}
.audit-suggestion-content.not-checked {
color: #909399; /* 未检查 - 灰色 */
}
/* 冲突详情样式 */
.conflict-details {
margin-top: 8px;
padding: 8px;
background-color: #fef2f2;
border-left: 3px solid #f56c6c;
border-radius: 4px;
font-size: 12px;
}
.conflict-list {
margin: 4px 0 0 0;
padding-left: 16px;
list-style-type: disc;
}
.conflict-list li {
margin: 2px 0;
color: #666;
}
/* 文本颜色工具类 */
.text-success {
color: #67c23a;
}
.text-warning {
color: #e6a23c;
}
.text-danger {
color: #f56c6c;
}
.text-info {
color: #409eff;
}
/* 加载动画 */
.rotate-loading {
animation: rotate 2s linear infinite;
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* 冲突检查统计样式 */
.conflict-summary {
margin: 0px 0;
padding: 0px;
/* background-color: #f9fafe; */
border-radius: 6px;
/* border: 1px solid #e4e7ed; */
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap; /* 超出时自动换行 */
}
.summary-stats {
display: inline-flex; /* 与标签同行 */
gap: 8px;
margin-top: 0; /* 移除顶部间距,保持同一行 */
flex-wrap: wrap; /* 达到最大宽度时自身也可换行 */
}
.conflict-warning-text {
margin-top: 8px;
color: #e6a23c;
font-size: 14px;
display: flex;
align-items: center;
gap: 4px;
}
</style>
<style>