Просмотр исходного кода

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
zlwu 1 год назад
Родитель
Сommit
8675ef4581
2 измененных файлов с 451 добавлено и 71 удалено
  1. 21 66
      web/src/api/hq/hallAudit/index.ts
  2. 430 5
      web/src/views/hq/hallAudit/index.vue

+ 21 - 66
web/src/api/hq/hallAudit/index.ts

@@ -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',
+// 审核报告厅预约
+export function auditHallReservation(data: HallReservationAuditForm) {
+  return request<R<any>>({
+    url: '/hq/hallReservation/audit',
     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({
-    url: '/hq/hallReservation/audit',
-    method: 'put',
+// 检查报告厅预约时间是否存在冲突
+export function checkTimeConflict(data: TimeCheckParams) {
+  return request<R<TimeCheckResult>>({
+    url: '/hq/hallReservation/checkTimeConflict',
+    method: 'post',
     data: data
   });
-};
+}

+ 430 - 5
web/src/views/hq/hallAudit/index.vue

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