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