You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

885 lines
20 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<!-- pages/queue/index.vue -->
<template>
<view class="page-container bg-white">
<TopDivBar @back="goBack" />
<view class="tools-div">
<view class="tool-input">
<input
v-model="queryForm.name"
class="search-input"
placeholder="请输入姓名(模糊匹配)"
/>
<view class="tool-gap"></view>
<input
v-model="queryForm.idCard"
class="search-input"
placeholder="请输入身份证(模糊匹配)"
/>
<view class="tool-gap"></view>
<input
v-model="queryForm.phone"
class="search-input"
placeholder="请输入手机号(模糊匹配)"
/>
</view>
<view class="tools-btn">
<button
class="btn btn-primary action-btn"
:disabled="loading"
@click="handleSearch"
>
查询
</button>
<view class="tool-gap"></view>
<button
class="btn btn-default action-btn"
:disabled="loading"
@click="handleReset"
>
重置
</button>
</view>
</view>
<view class="table-wrapper">
<uni-table :loading="loading" border stripe emptyText="暂无更多数据">
<uni-tr>
<uni-th width="100" align="center">姓名</uni-th>
<uni-th width="180" align="center">身份证</uni-th>
<uni-th width="130" align="center">手机号</uni-th>
<uni-th width="100" align="center">票号</uni-th>
<uni-th width="200" align="center">核验结果</uni-th>
<uni-th width="170" align="center">核验时间</uni-th>
<uni-th width="100" align="center">查看照片</uni-th>
<uni-th width="200" align="center">操作</uni-th>
</uni-tr>
<uni-tr v-for="item in records" :key="item.uid">
<uni-td align="center">{{ item.name || "-" }}</uni-td>
<uni-td align="center">{{ maskIdCard(item.idCard) }}</uni-td>
<uni-td align="center">{{ maskPhone(item.phone) }}</uni-td>
<uni-td align="center">{{ item.ticketId || "-" }}</uni-td>
<uni-td align="center">
<text class="face-resp">{{ item.baishuiResp || "-" }}</text>
</uni-td>
<uni-td align="center">{{ item.verifyTime || "-" }}</uni-td>
<uni-td align="center">
<button
v-if="hasFaceImg(item.faceImg)"
class="btn btn-link face-photo-btn"
@click="openFaceImgPopup(item)"
>
查看照片
</button>
<text v-else>-</text>
</uni-td>
<uni-td align="center">
<view class="row-actions">
<button
class="btn btn-primary report-btn"
:disabled="!item.idCard || reportLoading"
@click="openHealthReport(item)"
>
健康报告
</button>
<button
class="btn btn-primary report-btn"
:disabled="ticketLoading"
@click="openTakeTicketPopup(item)"
>
取号
</button>
<button
v-if="SHOW_APPOINTMENT_SIGN_IN"
class="btn btn-default report-btn"
@click="openAppointmentPopup(item)"
>
预约签到
</button>
</view>
</uni-td>
</uni-tr>
</uni-table>
</view>
<view class="pagination-div">
<text>
共<span class="page-span">{{ total }}</span
>条数据,每页显示<span class="page-span">{{ size }}</span
>条
</text>
<view class="uni-pagination-box">
<uni-pagination
show-icon
:page-size="size"
:current="page"
:total="total"
@change="handlePageChange"
/>
</view>
</view>
<uni-popup ref="faceImgPopup" type="center" :is-mask-click="false">
<view class="face-img-popup">
<view class="report-header">
<text class="report-title">人脸照片</text>
<view class="report-close" @click="closeFaceImgPopup">×</view>
</view>
<view class="face-img-body">
<view v-if="faceImgLoading" class="report-loading"
>照片加载中...</view
>
<!-- H5 / Web 使用原生 imguni-app image 对 dataUrl/Blob 支持不稳定 -->
<img
v-else-if="faceImgSrc && faceImgUseNative"
:src="faceImgSrc"
class="face-img face-img-native"
alt="人脸照片"
@error="onFaceImgError"
/>
<image
v-else-if="faceImgSrc"
:src="faceImgSrc"
mode="aspectFit"
class="face-img"
@error="onFaceImgError"
/>
<view v-else class="report-empty">暂无照片</view>
</view>
</view>
</uni-popup>
<uni-popup ref="reportPopup" type="center" :is-mask-click="false">
<view class="report-popup">
<view class="report-header">
<text class="report-title">健康报告</text>
<view class="report-close" @click="closeReportPopup">×</view>
</view>
<view class="report-body">
<view v-if="reportLoading" class="report-loading">报告加载中...</view>
<view v-else-if="reportUrl" class="report-frame-wrap">
<iframe
:src="reportUrl"
class="report-frame"
:style="reportFrameStyle"
@load="updateReportFrameScale"
></iframe>
</view>
<view v-else class="report-empty">暂无报告地址</view>
</view>
</view>
</uni-popup>
<TakeTicketPopup
ref="takeTicketPopupRef"
@success="fetchTableData"
@loading="(value) => (ticketLoading = value)"
/>
<uni-popup ref="appointmentPopup" type="center">
<view class="appoint-popup">
<view class="popup-header">
<text class="popup-title">预约信息</text>
<view class="report-close" @click="closeAppointmentPopup">×</view>
</view>
<view class="appoint-content">
<view class="appoint-row">
<text class="appoint-label">姓名</text>
<text class="appoint-value">{{ currentRow?.name || "-" }}</text>
</view>
<view class="appoint-row">
<text class="appoint-label">身份证</text>
<text class="appoint-value">{{
maskIdCard(currentRow?.idCard)
}}</text>
</view>
<view class="appoint-row">
<text class="appoint-label">手机号</text>
<text class="appoint-value">{{
maskPhone(currentRow?.phone)
}}</text>
</view>
<view class="appoint-row">
<text class="appoint-label">预约时间段</text>
<text class="appoint-value">{{
getAppointmentTimeRange(currentRow)
}}</text>
</view>
</view>
<view class="appoint-actions">
<button
class="btn btn-primary report-btn"
@click="confirmAppointmentSignIn"
>
签到
</button>
</view>
</view>
</uni-popup>
</view>
</template>
<script setup>
import { getHealthReportUrl } from "@/api/index.js";
import { getFaceImage, searchTicketVerify } from "@/api/ticket.js";
import TakeTicketPopup from "@/components/TakeTicketPopup/TakeTicketPopup.vue";
import TopDivBar from "@/components/TopDivBar.vue";
import {
extractImageBase64FromResponse,
isWebPlatform,
loadFaceImageDisplaySrc,
} from "@/utils/faceImage.js";
import { onHide, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import { computed, getCurrentInstance, nextTick, reactive, ref } from "vue";
// 健康报告按桌面宽度渲染后再缩放,避免 iframe 内页面走移动端布局
const REPORT_DESKTOP_WIDTH = 1280;
/** 暂时隐藏预约签到入口,恢复时改为 true */
const SHOW_APPOINTMENT_SIGN_IN = false;
const loading = ref(false);
const page = ref(1);
const size = ref(10);
const total = ref(0);
const records = ref([]);
const faceImgPopup = ref(null);
const faceImgSrc = ref("");
const faceImgLoading = ref(false);
const faceImgRevoke = ref(null);
const faceImgUseNative = ref(isWebPlatform());
const reportPopup = ref(null);
const reportUrl = ref("");
const reportLoading = ref(false);
const reportFrameScale = ref(1);
const instance = getCurrentInstance();
const reportFrameStyle = computed(() => {
const scale = reportFrameScale.value || 1;
return {
width: `${REPORT_DESKTOP_WIDTH}px`,
height: `${100 / scale}%`,
transform: `scale(${scale})`,
transformOrigin: "0 0",
};
});
const updateReportFrameScale = () => {
nextTick(() => {
const query = uni.createSelectorQuery();
if (instance?.proxy) {
query.in(instance.proxy);
}
query
.select(".report-body")
.boundingClientRect((rect) => {
if (!rect?.width) return;
reportFrameScale.value = rect.width / REPORT_DESKTOP_WIDTH;
})
.exec();
});
};
const preChecked = 0;
const takeTicketPopupRef = ref(null);
const appointmentPopup = ref(null);
const ticketLoading = ref(false);
const currentRow = ref(null);
const queryForm = reactive({
name: "",
idCard: "",
phone: "",
});
const hasFaceImg = (faceImg) => Boolean(String(faceImg ?? "").trim());
const releaseFaceImgSrc = () => {
if (typeof faceImgRevoke.value === "function") {
faceImgRevoke.value();
}
faceImgRevoke.value = null;
faceImgSrc.value = "";
};
const onFaceImgError = (event) => {
console.log("[face-image] image 组件加载失败:", event);
uni.showToast({
title: "图片渲染失败",
icon: "none",
});
};
const openFaceImgPopup = async (row) => {
const uid = String(row?.uid ?? "").trim();
console.log("[face-image] 表格行 uid:", uid, "faceImg:", row?.faceImg);
if (!uid) {
uni.showToast({
title: "缺少记录 uid",
icon: "none",
});
return;
}
releaseFaceImgSrc();
faceImgLoading.value = true;
faceImgPopup.value?.open();
try {
const result = await getFaceImage(uid);
const base64 = extractImageBase64FromResponse(result);
console.log(
"[face-image] imageBase64 长度:",
base64 ? String(base64).length : 0,
);
if (!base64) {
uni.showToast({
title: "未获取到照片数据",
icon: "none",
});
return;
}
const { src, revoke } = await loadFaceImageDisplaySrc(base64);
faceImgRevoke.value = revoke;
faceImgUseNative.value =
isWebPlatform() || src.startsWith("blob:") || src.startsWith("data:");
await nextTick();
faceImgSrc.value = src;
await nextTick();
console.log(
"[face-image] 展示地址类型:",
src.startsWith("blob:")
? "blob"
: src.startsWith("data:")
? "dataUrl"
: "filePath",
"useNativeImg:",
faceImgUseNative.value,
);
} catch (error) {
console.log("获取人脸照片失败:", error);
uni.showToast({
title: "获取人脸照片失败",
icon: "none",
});
} finally {
faceImgLoading.value = false;
}
};
const closeFaceImgPopup = () => {
faceImgPopup.value?.close();
releaseFaceImgSrc();
faceImgLoading.value = false;
};
const maskIdCard = (value) => {
const text = String(value || "").trim();
if (!text) return "-";
if (text.length <= 8) return text;
return `${text.slice(0, 4)}********${text.slice(-4)}`;
};
const maskPhone = (value) => {
const text = String(value || "").trim();
if (!text) return "-";
if (text.length < 7) return text;
return `${text.slice(0, 3)}****${text.slice(-4)}`;
};
const formatDateYMD = (date) => {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
};
// 固定查询当天至过去三天(含当天共 4 天)
const getVerifySearchDateRange = () => {
const endDate = new Date();
const startDate = new Date();
startDate.setDate(startDate.getDate() - 3);
return {
startDate: formatDateYMD(startDate),
endDate: formatDateYMD(endDate),
};
};
const fetchTableData = async () => {
loading.value = true;
try {
const { startDate, endDate } = getVerifySearchDateRange();
const result = await searchTicketVerify({
name: queryForm.name,
idCard: queryForm.idCard,
phone: queryForm.phone,
preChecked,
startDate,
endDate,
page: page.value,
size: size.value,
});
console.log("[queue-table] 查询参数:", {
name: queryForm.name,
idCard: queryForm.idCard,
phone: queryForm.phone,
preChecked,
startDate,
endDate,
page: page.value,
size: size.value,
});
console.log("[queue-table] 接口原始返回:", result);
records.value = Array.isArray(result.records) ? result.records : [];
total.value = Number(result.total) || 0;
console.log("[queue-table] 表格数据 records:", records.value);
console.log("[queue-table] 总条数 total:", total.value);
} catch (error) {
console.log("实名核验查询失败:", error);
} finally {
loading.value = false;
}
};
const handleSearch = () => {
page.value = 1;
fetchTableData();
};
const handleReset = () => {
queryForm.name = "";
queryForm.idCard = "";
queryForm.phone = "";
page.value = 1;
total.value = 0;
records.value = [];
};
const handlePageChange = (event) => {
page.value = event.current;
fetchTableData();
};
const openHealthReport = async (row) => {
if (!row?.idCard) {
uni.showToast({
title: "身份证号为空",
icon: "none",
});
return;
}
reportLoading.value = true;
reportUrl.value = "";
reportFrameScale.value = 1;
reportPopup.value?.open();
nextTick(() => {
updateReportFrameScale();
});
try {
const result = await getHealthReportUrl({
id: row.idCard,
});
reportUrl.value = result?.url || "";
if (!reportUrl.value) {
uni.showToast({
title: "未获取到报告地址",
icon: "none",
});
} else {
nextTick(() => {
updateReportFrameScale();
});
}
} catch (error) {
console.log("获取健康报告失败:", error);
uni.showToast({
title: "获取健康报告失败",
icon: "none",
});
} finally {
reportLoading.value = false;
}
};
const closeReportPopup = () => {
reportPopup.value?.close();
reportUrl.value = "";
reportFrameScale.value = 1;
};
const openTakeTicketPopup = (row) => {
takeTicketPopupRef.value?.openWithRow(row);
};
const getAppointmentTimeRange = (row) => {
if (!row) return "-";
return (
row.appointmentTimeRange ||
row.appointmentTimeSlot ||
row.appointmentRange ||
"暂无预约时间段"
);
};
const openAppointmentPopup = (row) => {
currentRow.value = row;
appointmentPopup.value?.open();
};
const closeAppointmentPopup = () => {
appointmentPopup.value?.close();
};
const confirmAppointmentSignIn = () => {
if (!currentRow.value) return;
uni.showModal({
title: "确认签到",
content: `确认对 ${currentRow.value.name || "该用户"} 执行预约签到吗?`,
success: (res) => {
if (!res.confirm) return;
uni.showToast({
title: "签到成功",
icon: "success",
});
closeAppointmentPopup();
},
});
};
const goBack = () => {
uni.navigateBack();
};
let refreshTimer = null;
const startRefreshTimer = () => {
stopRefreshTimer();
refreshTimer = setInterval(() => {
fetchTableData();
}, 15000);
};
const stopRefreshTimer = () => {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
};
onLoad(() => {
if (process.env.NODE_ENV === "development") {
queryForm.name = "";
}
});
onShow(() => {
fetchTableData();
startRefreshTimer();
});
onHide(() => {
stopRefreshTimer();
});
onUnload(() => {
stopRefreshTimer();
});
</script>
<style scoped>
.page-container {
min-height: 100vh;
padding-top: calc(6vh + 64px);
}
.bg-white {
background-color: #fff;
}
.tools-div {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 20px;
}
.tool-input {
display: flex;
flex: 1;
min-width: 0;
align-items: center;
}
.tools-btn {
display: flex;
align-items: center;
margin-left: 14rpx;
}
.tool-gap {
width: 12px;
}
.search-input {
flex: 1;
min-width: 0;
border: 1px solid #d1d5db;
border-radius: 6rpx;
background: #fff;
padding: 0 12px;
font-size: 14px;
height: 36px;
box-sizing: border-box;
}
.btn {
min-width: 84px;
height: 32px;
border-radius: 4px;
font-size: 14px;
border: none;
display: flex;
align-items: center;
justify-content: center;
}
.action-btn {
width: 84px;
}
.report-btn {
width: 84px;
min-width: 84px;
height: 28px;
font-size: 12px;
}
.btn-primary {
background: #2563eb;
color: #fff;
}
.btn-default {
background: #e5e7eb;
color: #111827;
}
.table-wrapper {
padding: 0 20px;
}
.pagination-div {
display: flex;
justify-content: space-between;
padding: 10px 20px;
font-size: 14px;
}
.page-span {
color: #0099ff;
margin: 0 4px;
}
.uni-pagination-box {
display: flex;
justify-content: flex-end;
}
.report-popup {
width: 96vw;
max-width: none;
height: 92vh;
background: #fff;
border-radius: 12px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.report-header {
height: 48px;
padding: 0 14px;
border-bottom: 1px solid #eee;
display: flex;
align-items: center;
justify-content: space-between;
}
.report-title {
font-size: 16px;
color: #111827;
font-weight: 600;
}
.report-close {
width: 28px;
height: 28px;
line-height: 28px;
text-align: center;
border-radius: 50%;
background: #f3f4f6;
color: #6b7280;
font-size: 18px;
}
.report-body {
flex: 1;
min-height: 0;
background: #fafafa;
overflow: hidden;
}
.report-frame-wrap {
width: 100%;
height: 100%;
overflow: hidden;
background: #fff;
}
.report-frame {
border: none;
background: #fff;
display: block;
}
.report-loading,
.report-empty {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #6b7280;
font-size: 14px;
}
.face-resp {
font-size: 13px;
color: #374151;
line-height: 1.4;
}
.face-photo-btn {
padding: 0;
min-width: auto;
height: auto;
line-height: 1.2;
font-size: 13px;
color: #2563eb;
background: transparent;
border: none;
flex-shrink: 0;
white-space: nowrap;
}
.face-img-popup {
width: 720rpx;
max-width: 92vw;
background: #fff;
border-radius: 12px;
overflow: hidden;
}
.face-img-body {
padding: 12px;
min-height: 320px;
max-height: 70vh;
display: flex;
align-items: center;
justify-content: center;
background: #f9fafb;
}
.face-img {
width: 100%;
max-height: 65vh;
}
.face-img-native {
display: block;
object-fit: contain;
min-height: 200px;
}
.row-actions {
display: flex;
gap: 6px;
justify-content: center;
}
.appoint-popup {
width: 680rpx;
max-width: 90vw;
background: #fff;
border-radius: 12px;
overflow: hidden;
}
.popup-header {
height: 48px;
padding: 0 14px;
border-bottom: 1px solid #eee;
display: flex;
align-items: center;
justify-content: space-between;
}
.popup-title {
font-size: 15px;
color: #111827;
font-weight: 600;
}
.appoint-content {
padding: 14px;
}
.appoint-row {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid #f1f5f9;
}
.appoint-label {
color: #6b7280;
font-size: 14px;
}
.appoint-value {
color: #111827;
font-size: 14px;
text-align: right;
}
.appoint-actions {
padding: 12px 14px 14px;
display: flex;
justify-content: flex-end;
}
@media (max-width: 900px) {
.tools-div {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.tool-input {
flex-wrap: wrap;
}
.tool-gap {
display: none;
}
.search-input {
flex: none;
width: 100%;
margin-bottom: 8px;
}
.tools-btn {
margin-left: 0;
justify-content: flex-end;
}
}
</style>