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.

1665 lines
40 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">
<view class="page-toolbar">
<view class="toolbar-left">
<button class="btn btn-ghost back-btn" @click="goBack">
<uni-icons type="arrow-left" size="18" color="#1e3a8a"></uni-icons>
返回
</button>
</view>
<view class="hall-overview">
<view class="hall-stat" @click="goToHallInfo">
<view class="hall-stat-icon hall-stat-wait">
<uni-icons type="person-filled" size="20" color="#fff"></uni-icons>
</view>
<view class="hall-stat-meta">
<text class="hall-stat-label">当前等候人数</text>
<text class="hall-stat-value">{{ waitingCount }}</text>
</view>
</view>
<view class="hall-stat" @click="goToHallInfo">
<view class="hall-stat-icon hall-stat-appoint">
<uni-icons type="calendar" size="20" color="#fff"></uni-icons>
</view>
<view class="hall-stat-meta">
<text class="hall-stat-label">今日预约人数</text>
<text class="hall-stat-value">{{ todayAppointmentCount }}</text>
</view>
</view>
<view class="hall-stat" @click="goToHallInfo">
<view class="hall-stat-icon hall-stat-window">
<uni-icons type="home-filled" size="20" color="#fff"></uni-icons>
</view>
<view class="hall-stat-meta">
<text class="hall-stat-label">空闲窗口</text>
<text class="hall-stat-value">{{ idleWindowCount }}</text>
</view>
</view>
<view class="hall-stat" @click="goToHallInfo">
<view class="hall-stat-icon hall-stat-kiosk">
<uni-icons type="settings-filled" size="20" color="#fff"></uni-icons>
</view>
<view class="hall-stat-meta">
<text class="hall-stat-label">空闲自助机</text>
<text class="hall-stat-value">{{ idleKioskCount }}</text>
</view>
</view>
</view>
<view class="tool-input">
<view class="search-field">
<uni-icons type="search" size="16" color="#94a3b8"></uni-icons>
<input
v-model="queryForm.keyword"
class="search-input"
confirm-type="search"
placeholder="请输入关键字"
@confirm="handleSearch"
/>
</view>
<button
class="btn btn-primary action-btn"
:disabled="loading"
@click="handleSearch"
>
搜索
</button>
<button
class="btn action-btn take-btn"
@click="openNormalTakeTicket"
>
普通取号
</button>
</view>
</view>
<scroll-view class="table-wrapper" scroll-y :style="tableRowStyle">
<view class="table-card">
<uni-table :loading="loading" border stripe emptyText="暂无更多数据">
<uni-tr>
<uni-th width="76" align="center">现场照片</uni-th>
<uni-th width="96" align="center">姓名</uni-th>
<uni-th width="140" align="center">身份证</uni-th>
<uni-th width="100" align="center">手机号</uni-th>
<uni-th width="72" align="center">票号</uni-th>
<uni-th width="130" align="center">核验时间</uni-th>
<uni-th width="240" align="left">一键体检</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">
<view
class="photo-cell"
:class="{ 'photo-cell-click': item._faceSrc && item.uid }"
@click="openFaceImgPopup(item)"
>
<img
v-if="item._faceSrc && useNativeFaceImg"
:src="item._faceSrc"
class="row-face-img"
alt=""
/>
<image
v-else-if="item._faceSrc"
:src="item._faceSrc"
mode="aspectFill"
class="row-face-img"
/>
<text v-else>-</text>
</view>
</uni-td>
<uni-td align="center">
<view class="name-cell">
<text class="name-text">{{ item.name || "-" }}</text>
<view v-if="item._verifyTags?.length" class="name-tags">
<text
v-for="(tag, idx) in item._verifyTags"
:key="idx"
class="name-tag"
:class="'name-tag-' + tag.tone"
>{{ tag.label }}</text>
</view>
</view>
</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">{{ item.verifyTime || "-" }}</uni-td>
<uni-td align="left">
<view class="health-cell" @click="openHealthDetail(item)">
<text v-if="item._health?.loading" class="health-muted">体检生成中...</text>
<text
v-else-if="item._health?.unavailable || item._health?.queryFail"
class="health-muted"
>{{ item._health.reason || "暂无法体检" }}</text>
<view v-else class="health-summary">
<text>{{ item._health?.companyCount || 0 }}户关联企业</text>
<text v-if="item._health?.abnormal" class="health-warn">{{ item._health.abnormal }}户非正常</text>
<text v-if="item._health?.violation" class="health-warn">{{ item._health.violation }}条未处理违法违章</text>
<text v-if="item._health?.undeclare">{{ item._health.undeclare }}条未申报</text>
<text v-if="item._health?.owing" class="health-warn">{{ item._health.owing }}条欠税</text>
</view>
</view>
</uni-td>
<uni-td align="left">
<view class="row-actions">
<button
class="btn btn-primary table-action-btn"
:disabled="ticketLoading"
@click="openTakeTicketPopup(item)"
>
取号
</button>
<button
v-if="needSmzCollect(item)"
class="btn btn-default table-action-btn"
:disabled="smzSaving"
@click="openSmzCollectPopup(item)"
>
采集
</button>
<button
v-if="SHOW_APPOINTMENT_SIGN_IN"
class="btn btn-default table-action-btn"
@click="openAppointmentPopup(item)"
>
预约签到
</button>
</view>
</uni-td>
</uni-tr>
<uni-tr v-for="n in emptyRowCount" :key="'empty-row-' + n">
<uni-td align="center"></uni-td>
<uni-td align="center"></uni-td>
<uni-td align="center"></uni-td>
<uni-td align="center"></uni-td>
<uni-td align="center"></uni-td>
<uni-td align="center"></uni-td>
<uni-td align="center"></uni-td>
<uni-td align="center"></uni-td>
</uni-tr>
</uni-table>
</view>
</scroll-view>
<view class="pagination-div">
<view class="page-info-wrap">
<text class="page-info">
共<span class="page-span">{{ total }}</span>条
</text>
<picker
mode="selector"
:range="sizeOptions"
:value="sizeOptionIndex"
:disabled="loading"
@change="onSizeChange"
>
<view class="size-picker">
<text>每页 {{ size }} 条</text>
<uni-icons type="bottom" size="12" color="#64748b"></uni-icons>
</view>
</picker>
</view>
<view class="page-actions">
<button
class="page-btn"
:disabled="page <= 1 || loading"
@click="handlePrevPage"
>
上一页
</button>
<view class="page-pill">
<text class="page-pill-num">{{ page }}</text>
<text class="page-pill-sep">/</text>
<text>{{ totalPages }}</text>
</view>
<button
class="page-btn page-btn-primary"
:disabled="page >= totalPages || loading"
@click="handleNextPage"
>
下一页
</button>
</view>
</view>
<uni-popup ref="faceImgPopup" type="center" :is-mask-click="true">
<view class="face-img-popup">
<view class="popup-header">
<text class="popup-title">现场照片</text>
<view class="report-close" @click="closeFaceImgPopup">×</view>
</view>
<view class="face-img-body">
<view v-if="faceImgLoading" class="report-loading">照片加载中...</view>
<img
v-else-if="previewFaceSrc && previewUseNative"
:src="previewFaceSrc"
class="face-img-full"
alt=""
/>
<image
v-else-if="previewFaceSrc"
:src="previewFaceSrc"
mode="aspectFit"
class="face-img-full"
/>
<view v-else class="report-empty">暂无照片</view>
</view>
</view>
</uni-popup>
<uni-popup ref="healthPopup" type="center" :is-mask-click="true">
<view class="health-popup">
<view class="popup-header">
<text class="popup-title">一键体检 · {{ healthDetailName || "-" }}</text>
<view class="report-close" @click="closeHealthDetail">×</view>
</view>
<scroll-view class="health-popup-body" scroll-y>
<view v-if="healthDetail?.loading" class="health-popup-empty">体检生成中...</view>
<view
v-else-if="healthDetail?.unavailable || healthDetail?.queryFail"
class="health-popup-empty"
>{{ healthDetail.reason || "暂无法体检" }}</view>
<view v-else>
<view class="health-kv">
<text class="health-kv-k">关联企业</text>
<text class="health-kv-v">{{ healthDetail?.companyCount || 0 }} 户</text>
</view>
<view class="health-kv">
<text class="health-kv-k">非正常户</text>
<text class="health-kv-v" :class="{ 'health-warn': healthDetail?.abnormal }">{{ healthDetail?.abnormal || 0 }} 户</text>
</view>
<view class="health-kv">
<text class="health-kv-k">未处理违法违章</text>
<text class="health-kv-v" :class="{ 'health-warn': healthDetail?.violation }">{{ healthDetail?.violation || 0 }} 条</text>
</view>
<view class="health-kv">
<text class="health-kv-k">未申报</text>
<text class="health-kv-v">{{ healthDetail?.undeclare || 0 }} 条</text>
</view>
<view class="health-kv">
<text class="health-kv-k">欠税</text>
<text class="health-kv-v" :class="{ 'health-warn': healthDetail?.owing }">{{ healthDetail?.owing || 0 }} 条</text>
</view>
<view
v-for="(firm, idx) in healthDetail?.details || []"
:key="firm.djxh || idx"
class="health-firm"
>
<text class="health-firm-name">{{ firm.nsrmc || "-" }}</text>
<text class="health-firm-sub">{{ firm.nsrsbh || "-" }} · {{ firm.nsrztmc || firm.nsrztDm || "-" }}</text>
<text class="health-firm-sub">违法违章 {{ firm.violation || 0 }} · 未申报 {{ firm.undeclare || 0 }} · 欠税 {{ firm.owing || 0 }}</text>
</view>
<view v-if="!(healthDetail?.details || []).length" class="health-popup-empty">无关联企业</view>
</view>
</scroll-view>
</view>
</uni-popup>
<TakeTicketPopup
ref="takeTicketPopupRef"
@success="fetchTableData"
@loading="(value) => (ticketLoading = value)"
@visible-change="onTakeTicketVisibleChange"
/>
<uni-popup ref="smzCollectPopup" type="center" :is-mask-click="false">
<view class="smz-collect-popup">
<view class="popup-header">
<text class="popup-title">实名采集</text>
<view class="report-close" @click="closeSmzCollectPopup">×</view>
</view>
<view class="smz-collect-body">
<view class="smz-field">
<text class="smz-label">手机号码<span class="smz-required">*</span></text>
<input
v-model="smzForm.sjhm"
class="smz-input"
type="number"
maxlength="11"
placeholder="请输入手机号码"
/>
</view>
<view class="smz-field">
<text class="smz-label">电子邮箱</text>
<input
v-model="smzForm.dzyx"
class="smz-input"
placeholder="请输入电子邮箱(可选)"
/>
</view>
<view class="smz-field">
<text class="smz-label">固定电话</text>
<input
v-model="smzForm.lxdh"
class="smz-input"
placeholder="请输入固定电话(可选)"
/>
</view>
<view class="appoint-actions">
<button
class="btn btn-primary report-btn"
:disabled="smzSaving"
@click="submitSmzCollect"
>
保存
</button>
</view>
</view>
</view>
</uni-popup>
<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 { saveSmzcjxx } from "@/api/smz.js";
import { getOverview } from "@/api/system.js";
import { getFaceImage, searchTicketVerify } from "@/api/ticket.js";
import TakeTicketPopup from "@/components/TakeTicketPopup/TakeTicketPopup.vue";
import {
extractImageBase64FromResponse,
fixBase64ForDecode,
getMimeTypeFromBase64,
isWebPlatform,
loadFaceImageDisplaySrc,
normalizeBase64Pure,
} from "@/utils/faceImage.js";
import { getHealthState, loadHealthForRecords } from "@/utils/healthCheck.js";
import { waitSessionReady } from "@/utils/request.js";
import { validatePhoneNumber } from "@/utils/validator.js";
import { onHide, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import { computed, getCurrentInstance, nextTick, reactive, ref } from "vue";
/** 暂时隐藏预约签到入口,恢复时改为 true */
const SHOW_APPOINTMENT_SIGN_IN = false;
const loading = ref(false);
const page = ref(1);
const size = ref(5);
const sizeOptions = Array.from({ length: 13 }, (_, index) => index + 3);
const sizeOptionIndex = computed(() => {
const index = sizeOptions.indexOf(size.value);
return index >= 0 ? index : 2;
});
const total = ref(0);
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / size.value) || 1));
const records = ref([]);
const emptyRowCount = computed(() => {
if (!records.value.length) return 0;
return Math.max(0, size.value - records.value.length);
});
const queueRowHeight = ref(56);
const tableRowStyle = computed(() => ({
"--queue-row-height": `${queueRowHeight.value}px`,
}));
const useNativeFaceImg = isWebPlatform();
const healthPopup = ref(null);
const healthDetailKey = ref("");
const healthDetailName = ref("");
const healthDetail = computed(() => {
const key = String(healthDetailKey.value || "").trim();
if (!key) return { unavailable: true, reason: "无身份证,暂无法体检" };
const row = records.value.find((item) => String(item?.idCard || "").trim() === key);
return row?._health || getHealthState({ idCard: key });
});
const faceImgPopup = ref(null);
const faceImgLoading = ref(false);
const previewFaceSrc = ref("");
const previewUseNative = ref(false);
const faceImgRevoke = ref(null);
const instance = getCurrentInstance();
const smzCollectPopup = ref(null);
const smzSaving = ref(false);
const smzForm = reactive({
sjhm: "",
dzyx: "",
lxdh: "",
});
const preChecked = 0;
const takeTicketPopupRef = ref(null);
const takeTicketPopupOpen = ref(false);
const appointmentPopup = ref(null);
const ticketLoading = ref(false);
const currentRow = ref(null);
const queryForm = reactive({
keyword: "",
});
const waitingCount = ref(0);
const todayAppointmentCount = ref(0);
const idleWindowCount = ref(0);
const idleKioskCount = ref(0);
const loadOverview = async () => {
try {
const res = await getOverview();
waitingCount.value = Number(res?.waitingCount ?? 0);
todayAppointmentCount.value = Number(res?.todayAppointmentCount ?? 0);
idleWindowCount.value = Number(res?.idleWindowCount ?? 0);
idleKioskCount.value = Number(res?.idleKioskCount ?? 0);
} catch (error) {
console.log("获取大厅概况失败", error);
}
};
const goToHallInfo = () => {
uni.navigateTo({
url: "/pages/mod/hallInfo",
});
};
const revokePreviewFace = () => {
if (typeof faceImgRevoke.value === "function") {
faceImgRevoke.value();
}
faceImgRevoke.value = null;
};
const openFaceImgPopup = async (item) => {
const uid = String(item?.uid ?? "").trim();
if (!uid) {
uni.showToast({
title: "缺少记录 uid",
icon: "none",
});
return;
}
revokePreviewFace();
previewFaceSrc.value = "";
previewUseNative.value = useNativeFaceImg;
faceImgLoading.value = true;
faceImgPopup.value?.open();
try {
const result = await getFaceImage(uid);
const base64 = extractImageBase64FromResponse(result);
if (!base64) {
uni.showToast({
title: "暂无照片",
icon: "none",
});
return;
}
const { src, revoke } = await loadFaceImageDisplaySrc(base64);
faceImgRevoke.value = revoke;
previewUseNative.value =
isWebPlatform() || src.startsWith("blob:") || src.startsWith("data:");
previewFaceSrc.value = src;
} catch (error) {
console.log("获取人脸照片失败", error);
uni.showToast({
title: "照片加载失败",
icon: "none",
});
} finally {
faceImgLoading.value = false;
}
};
const closeFaceImgPopup = () => {
faceImgPopup.value?.close();
faceImgLoading.value = false;
previewFaceSrc.value = "";
revokePreviewFace();
};
const matchRowKeyword = (item, keyword) => {
const kw = String(keyword || "").trim().toLowerCase();
if (!kw) return true;
const parts = [
item?.name,
item?.idCard,
maskIdCard(item?.idCard),
item?.phone,
maskPhone(item?.phone),
item?.ticketId,
item?.baishuiResp,
item?.verifyTime,
item?.ticketUid,
item?.uid,
item?.IS_VIP,
item?.is_vip,
item?.isVip,
];
return parts.some((part) => String(part ?? "").toLowerCase().includes(kw));
};
const isVipRecord = (item) => {
const vip = item?.IS_VIP ?? item?.is_vip ?? item?.isVip ?? item?.vip;
return vip === true || vip === 1 || vip === "1" || String(vip).toLowerCase() === "true";
};
const getVerifyTags = (item) => {
const text = String(item?.baishuiResp || "");
const tags = text
? text
.split(/[,;\s]+/)
.map((part) => part.trim())
.filter(Boolean)
.map((label) => ({
label,
tone: label.includes("未") ? "fail" : label.includes("已") ? "pass" : "other",
}))
: [];
if (isVipRecord(item)) {
tags.unshift({ label: "VIP", tone: "vip" });
}
return tags;
};
const formatFaceImgSrc = (item) => {
const raw = String(item?.imageBase64 ?? "").trim();
if (!raw) return "";
if (/^data:image\//i.test(raw)) return raw;
const pure = normalizeBase64Pure(raw);
if (!pure) return "";
const mime = getMimeTypeFromBase64(pure);
return `data:${mime};base64,${fixBase64ForDecode(pure)}`;
};
const attachFaceSrc = (list) =>
(Array.isArray(list) ? list : []).map((item) => ({
...item,
_faceSrc: formatFaceImgSrc(item),
_verifyTags: getVerifyTags(item),
_health: getHealthState(item),
}));
const applyHealthToRecords = () => {
records.value = records.value.map((item) => ({
...item,
_health: getHealthState(item),
}));
};
const syncPageHealth = () => {
loadHealthForRecords(records.value, applyHealthToRecords);
};
const needSmzCollect = (item) => {
if (!item || item._smzCollected) return false;
return String(item.baishuiResp || "").includes("未采集");
};
const getRxsfzBdxh = (row) =>
String(row?.rxsfzBdxh || row?.compareCode || row?.CompareCode || "").trim();
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 keywordFilteredCache = ref([]);
const keywordCacheValue = ref("");
const fetchVerifyPage = async (pageNum, pageSize, silent) => {
const { startDate, endDate } = getVerifySearchDateRange();
return searchTicketVerify(
{
name: "",
idCard: "",
phone: "",
preChecked,
startDate,
endDate,
page: pageNum,
size: pageSize,
},
silent
? {
loading: false,
quiet: true,
}
: {},
);
};
const fetchTableData = async (options = {}) => {
const silent = options.silent === true;
const keyword = String(queryForm.keyword || "").trim();
if (silent && takeTicketPopupOpen.value) {
return;
}
if (silent && keyword) {
return;
}
if (!silent) {
loading.value = true;
}
try {
if (!keyword) {
const result = await fetchVerifyPage(page.value, size.value, silent);
console.log("[queue-table] 查询参数:", {
keyword,
preChecked,
page: page.value,
size: size.value,
});
console.log("[queue-table] 接口原始返回:", result);
records.value = attachFaceSrc(
Array.isArray(result.records) ? result.records : [],
);
total.value = Number(result.total) || 0;
keywordFilteredCache.value = [];
keywordCacheValue.value = "";
console.log("[queue-table] 表格数据 records:", records.value);
console.log("[queue-table] 总条数 total:", total.value);
syncPageHealth();
return;
}
const canReuse =
options.fromPager === true &&
keywordCacheValue.value === keyword &&
Array.isArray(keywordFilteredCache.value);
let filtered = keywordFilteredCache.value;
if (!canReuse) {
const pageSize = 50;
let pageNum = 1;
let all = [];
let serverTotal = 0;
while (pageNum <= 20) {
const result = await fetchVerifyPage(pageNum, pageSize, true);
const list = Array.isArray(result.records) ? result.records : [];
serverTotal = Number(result.total) || 0;
all = all.concat(list);
if (list.length < pageSize || (serverTotal > 0 && all.length >= serverTotal)) {
break;
}
pageNum += 1;
}
filtered = all.filter((item) => matchRowKeyword(item, keyword));
keywordFilteredCache.value = filtered;
keywordCacheValue.value = keyword;
}
total.value = filtered.length;
const start = (page.value - 1) * size.value;
records.value = attachFaceSrc(filtered.slice(start, start + size.value));
console.log("[queue-table] 关键字过滤:", {
keyword,
matched: filtered.length,
page: page.value,
});
syncPageHealth();
} catch (error) {
console.log("实名核验查询失败:", error);
} finally {
if (!silent) {
loading.value = false;
}
updateQueueRowHeight();
}
};
const updateQueueRowHeight = () => {
nextTick(() => {
const query = uni.createSelectorQuery();
if (instance?.proxy) {
query.in(instance.proxy);
}
query.select(".table-wrapper").boundingClientRect();
query.select(".table-wrapper .uni-table-th").boundingClientRect();
query.exec((rects) => {
const wrap = rects?.[0];
const th = rects?.[1];
if (!wrap?.height) return;
const paddingY = 24;
const headerH = th?.height || 44;
const bodyH = wrap.height - paddingY - headerH;
queueRowHeight.value = Math.max(44, Math.floor(bodyH / size.value));
});
});
};
const onSizeChange = (event) => {
const index = Number(event?.detail?.value);
const next = sizeOptions[index];
if (!next || next === size.value) return;
size.value = next;
page.value = 1;
fetchTableData();
};
const handleSearch = () => {
page.value = 1;
fetchTableData();
};
const handlePrevPage = () => {
if (page.value <= 1) return;
page.value -= 1;
fetchTableData({ fromPager: true });
};
const handleNextPage = () => {
if (page.value >= totalPages.value) return;
page.value += 1;
fetchTableData({ fromPager: true });
};
const openHealthDetail = (item) => {
healthDetailKey.value = String(item?.idCard || "").trim();
healthDetailName.value = String(item?.name || "").trim();
healthPopup.value?.open();
};
const closeHealthDetail = () => {
healthPopup.value?.close();
};
const openTakeTicketPopup = (row) => {
takeTicketPopupRef.value?.openWithRow(row);
};
const openNormalTakeTicket = () => {
takeTicketPopupRef.value?.open();
};
const onTakeTicketVisibleChange = (visible) => {
takeTicketPopupOpen.value = Boolean(visible);
if (takeTicketPopupOpen.value) {
stopRefreshTimer();
} else {
startRefreshTimer();
}
};
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 openSmzCollectPopup = (row) => {
currentRow.value = row;
smzForm.sjhm = String(row?.phone || "").trim();
smzForm.dzyx = "";
smzForm.lxdh = "";
smzCollectPopup.value?.open();
};
const closeSmzCollectPopup = () => {
smzCollectPopup.value?.close();
};
const submitSmzCollect = async () => {
if (!validatePhoneNumber(smzForm.sjhm)) {
uni.showToast({ title: "请输入正确手机号", icon: "none" });
return;
}
const rxsfzBdxh = getRxsfzBdxh(currentRow.value);
if (!rxsfzBdxh) {
uni.showToast({ title: "缺少比对序号", icon: "none" });
return;
}
smzSaving.value = true;
try {
await saveSmzcjxx({
rxsfzBdxh,
sjhm: smzForm.sjhm,
dzyx: smzForm.dzyx,
lxdh: smzForm.lxdh,
});
if (currentRow.value) currentRow.value._smzCollected = true;
records.value = records.value.map((item) =>
item?.uid === currentRow.value?.uid ? { ...item, _smzCollected: true } : item,
);
uni.showToast({ title: "采集成功", icon: "success" });
closeSmzCollectPopup();
} catch (error) {
uni.showToast({ title: "采集失败", icon: "none" });
} finally {
smzSaving.value = false;
}
};
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(() => {
loadOverview();
fetchTableData({ silent: true });
}, 15000);
};
const stopRefreshTimer = () => {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
};
onLoad(() => {
if (process.env.NODE_ENV === "development") {
queryForm.keyword = "";
}
});
onShow(async () => {
await waitSessionReady();
loadOverview();
fetchTableData();
startRefreshTimer();
updateQueueRowHeight();
if (typeof uni !== "undefined" && typeof uni.onWindowResize === "function") {
uni.onWindowResize(updateQueueRowHeight);
}
});
onHide(() => {
stopRefreshTimer();
if (typeof uni !== "undefined" && typeof uni.offWindowResize === "function") {
uni.offWindowResize(updateQueueRowHeight);
}
});
onUnload(() => {
stopRefreshTimer();
revokePreviewFace();
if (typeof uni !== "undefined" && typeof uni.offWindowResize === "function") {
uni.offWindowResize(updateQueueRowHeight);
}
});
</script>
<style scoped>
.page-container {
height: var(--pad-h);
overflow: hidden;
display: flex;
flex-direction: column;
background: #eef3f9;
}
.page-toolbar {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 16px;
padding: calc(env(safe-area-inset-top) + 12px) 20px 12px;
background: #fff;
border-bottom: 1px solid #d7e2ee;
box-shadow: 0 8px 24px rgba(30, 58, 138, 0.06);
z-index: 20;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.back-btn {
width: auto;
min-width: 84px;
height: 40px;
padding: 0 14px;
gap: 4px;
}
.hall-overview {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 12px;
}
.hall-stat {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
padding: 4px 8px 4px 4px;
}
.hall-stat-icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.hall-stat-wait {
background: #2563eb;
}
.hall-stat-appoint {
background: #f59e0b;
}
.hall-stat-window {
background: #14b8a6;
}
.hall-stat-kiosk {
background: #0ea5e9;
}
.hall-stat-meta {
display: flex;
flex-direction: column;
min-width: 0;
}
.hall-stat-label {
font-size: 12px;
color: #64748b;
line-height: 1.2;
}
.hall-stat-value {
margin-top: 2px;
font-size: 16px;
font-weight: 700;
color: #0f172a;
line-height: 1.2;
}
.tool-input {
display: flex;
flex: 0 1 480px;
min-width: 320px;
align-items: center;
gap: 10px;
}
.search-field {
flex: 1;
min-width: 0;
height: 42px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
background: #f4f7fb;
border: 1px solid #d7e2ee;
border-radius: 10px;
}
.search-input {
flex: 1;
min-width: 0;
height: 40px;
font-size: 14px;
color: #1e293b;
background: transparent;
}
.btn {
min-width: 84px;
height: 32px;
border-radius: 10px;
font-size: 14px;
border: none;
display: flex;
align-items: center;
justify-content: center;
}
.action-btn {
width: 88px;
height: 42px;
font-weight: 600;
}
.take-btn {
width: auto;
min-width: 96px;
padding: 0 14px;
background: #16a34a !important;
color: #fff !important;
box-shadow: 0 6px 14px rgba(22, 163, 74, 0.22);
}
.report-btn {
width: 84px;
min-width: 84px;
height: 28px;
font-size: 12px;
}
.btn-primary {
background: #2563eb;
color: #fff;
box-shadow: 0 6px 14px rgba(37, 99, 235, 0.22);
}
.btn-default {
background: #e5e7eb;
color: #111827;
}
.btn-ghost {
background: #fff;
color: #1e3a8a;
border: 1px solid #c7d7ea;
}
.table-wrapper {
flex: 1;
height: 0;
min-height: 0;
width: 100%;
padding: 12px 16px;
box-sizing: border-box;
}
.table-card {
height: 100%;
min-height: 100%;
background: #fff;
border: 1px solid #d7e2ee;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 10px 28px rgba(30, 58, 138, 0.05);
}
.table-wrapper :deep(.uni-table) {
min-width: 100%;
height: 100%;
}
.table-wrapper :deep(.uni-table-th) {
background: #f3f6fb;
color: #475569;
font-size: 13px;
font-weight: 600;
}
.table-wrapper :deep(.uni-table-td) {
color: #1e293b;
font-size: 13px;
vertical-align: middle;
height: var(--queue-row-height, 56px);
box-sizing: border-box;
padding-top: 4px;
padding-bottom: 4px;
}
.pagination-div {
flex-shrink: 0;
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 20px calc(env(safe-area-inset-bottom) + 12px);
background: #fff;
border-top: 1px solid #d7e2ee;
z-index: 20;
}
.page-info-wrap {
display: flex;
align-items: center;
gap: 12px;
}
.page-info {
font-size: 14px;
color: #64748b;
}
.page-span {
color: #1d4ed8;
margin: 0 4px;
font-weight: 700;
}
.size-picker {
min-width: 108px;
height: 36px;
padding: 0 12px;
border-radius: 18px;
background: #eef3f9;
border: 1px solid #d7e2ee;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: #1e3a8a;
}
.page-actions {
display: flex;
align-items: center;
gap: 12px;
}
.page-pill {
min-width: 92px;
height: 42px;
padding: 0 16px;
border-radius: 21px;
background: #eef3f9;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: #64748b;
font-weight: 600;
}
.page-pill-num {
color: #1e3a8a;
font-size: 18px;
}
.page-pill-sep {
margin: 0 4px;
color: #94a3b8;
}
.page-btn {
min-width: 112px;
height: 42px;
padding: 0 18px;
border: 1px solid #c7d7ea;
border-radius: 21px;
background: #fff;
color: #1e3a8a;
font-size: 15px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
.page-btn-primary {
background: #2563eb;
border-color: #2563eb;
color: #fff;
box-shadow: 0 6px 14px rgba(37, 99, 235, 0.22);
}
.page-btn[disabled] {
opacity: 0.4;
box-shadow: none;
}
.report-popup {
width: calc(96 * var(--pad-vw));
max-width: none;
height: calc(92 * var(--pad-vh));
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-loading,
.report-empty {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #6b7280;
font-size: 14px;
}
.health-cell {
width: 100%;
text-align: left;
cursor: pointer;
}
.health-summary {
font-size: 12px;
line-height: 1.45;
color: #334155;
}
.health-muted {
font-size: 12px;
color: #94a3b8;
}
.health-warn {
color: #dc2626;
font-weight: 700;
}
.health-popup {
width: 640px;
max-width: 92vw;
max-height: 80vh;
background: #fff;
border-radius: 16px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.health-popup-body {
height: 64vh;
padding: 16px 20px 20px;
box-sizing: border-box;
}
.health-popup-empty {
padding: 24px 0;
text-align: center;
color: #94a3b8;
font-size: 14px;
}
.health-kv {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 8px 0;
border-bottom: 1px solid #f1f5f9;
font-size: 14px;
}
.health-kv-k {
color: #475569;
font-weight: 600;
}
.health-kv-v {
color: #1e293b;
}
.health-firm {
margin-top: 12px;
padding: 10px 12px;
background: #f8fafc;
border-radius: 10px;
display: flex;
flex-direction: column;
gap: 4px;
}
.health-firm-name {
font-size: 14px;
font-weight: 700;
color: #1e3a8a;
}
.health-firm-sub {
font-size: 12px;
color: #64748b;
}
.photo-cell,
.name-cell {
width: 100%;
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
.photo-cell {
flex-direction: row;
}
.photo-cell-click {
cursor: pointer;
}
.name-cell {
gap: 4px;
}
.row-face-img {
width: auto;
height: min(56px, calc(var(--queue-row-height, 56px) - 8px));
aspect-ratio: 3 / 4;
border-radius: 4px;
object-fit: cover;
display: block;
background: #e2e8f0;
}
.face-img-popup {
width: 720rpx;
max-width: calc(92 * var(--pad-vw));
background: #fff;
border-radius: 12px;
overflow: hidden;
}
.face-img-body {
padding: 12px;
min-height: 320px;
max-height: calc(70 * var(--pad-vh));
display: flex;
align-items: center;
justify-content: center;
background: #f9fafb;
}
.face-img-full {
width: 100%;
max-height: calc(65 * var(--pad-vh));
object-fit: contain;
display: block;
}
.name-text {
font-size: 13px;
font-weight: 600;
color: #1e293b;
text-align: center;
}
.name-tags {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 4px;
}
.name-tag {
padding: 1px 6px;
border-radius: 4px;
font-size: 11px;
line-height: 1.4;
color: #fff;
}
.name-tag-pass {
background: #16a34a;
}
.name-tag-fail {
background: #dc2626;
}
.name-tag-vip {
background: #c9a227;
}
.name-tag-other {
background: #64748b;
}
.row-actions {
width: 100%;
display: flex;
flex-wrap: nowrap;
gap: 4px;
justify-content: flex-start;
align-items: center;
}
.table-action-btn {
width: auto;
min-width: 56px;
height: 28px;
padding: 0 10px;
margin: 0;
font-size: 12px;
border-radius: 8px;
flex-shrink: 0;
}
.row-actions :deep(button),
.row-actions :deep(.uni-button) {
margin: 0;
}
.appoint-popup {
width: 680rpx;
max-width: calc(90 * var(--pad-vw));
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;
}
.smz-collect-popup {
width: 680rpx;
max-width: 90vw;
background: #fff;
border-radius: 12px;
overflow: hidden;
}
.smz-collect-body {
padding: 14px;
}
.smz-field {
margin-bottom: 12px;
}
.smz-label {
display: block;
margin-bottom: 6px;
font-size: 13px;
color: #475569;
}
.smz-required {
color: #dc2626;
margin-left: 2px;
}
.smz-input {
width: 100%;
height: 40px;
padding: 0 12px;
border: 1px solid #d7e2ee;
border-radius: 10px;
background: #f8fafc;
font-size: 14px;
}
@media (max-width: 900px) {
.page-toolbar {
flex-wrap: wrap;
align-items: stretch;
gap: 10px;
}
.toolbar-left {
width: 100%;
}
.hall-overview {
width: 100%;
flex-wrap: wrap;
}
.tool-input {
flex-wrap: nowrap;
width: 100%;
flex: none;
max-width: none;
}
.search-field {
flex: 1;
width: auto;
}
.pagination-div {
flex-direction: column;
gap: 10px;
}
}
</style>