| .gitignore | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| api/visitorConfig.js | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| components/StepProgress.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| components/admin/AdminRecordsPanel.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| package-lock.json | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| package.json | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| pages.json | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| pages/admin-home/index.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| pages/admin-record-detail/index.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| pages/appointment-scan/index.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| pages/commitment/index.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| pages/home/index.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| static/logo.png | 补丁 | 查看 | 原始文档 | blame | 历史 | |
| utils/hikvisionQrcode.js | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| utils/request.js | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 |
.gitignore
@@ -1,3 +1,4 @@ unpackage/ .DS_Store .hbuilderx/ node_modules/ api/visitorConfig.js
New file @@ -0,0 +1,85 @@ import { request } from '../utils/request'; import { saveOfflineConfig, getOfflineConfig } from '../utils/db'; /** * 获取全局系统配置 * @param {Object} data * @returns {Promise} */ export const getSystemConfigAPI = (data = {}) => { return request({ url: '/api/visitorconfig/detail', method: 'POST', data, }); }; /** * 获取入园承诺配置 * @param {Object} data * @returns {Promise} */ export const getCommitmentConfigAPI = (data = {}) => { return request({ // TODO: 这里暂用 /api/admission/detail,如果真实接口不同请替换 url: '/api/admission/detail', method: 'POST', data, }); }; /** * 同步全局系统配置到本地数据库 */ export const syncSystemConfig = async () => { try { const res = await getSystemConfigAPI(); if (res && res.code === 200 && res.data) { await saveOfflineConfig('system_config', res.data); uni.setStorageSync('system_config', res.data); return res.data; } } catch (error) { console.error('同步全局系统配置失败:', error); } return null; }; /** * 获取本地缓存的全局系统配置 */ export const getLocalSystemConfig = async () => { const offlineConfig = await getOfflineConfig('system_config'); if (offlineConfig) { return offlineConfig; } return uni.getStorageSync('system_config') || {}; }; /** * 同步入园承诺配置到本地数据库 */ export const syncCommitmentConfig = async () => { try { const res = await getCommitmentConfigAPI(); if (res && res.code === 200 && res.data) { await saveOfflineConfig('commitment_config', res.data); uni.setStorageSync('commitment_config', res.data); return res.data; } } catch (error) { console.error('同步入园承诺配置失败:', error); } return null; }; /** * 获取本地缓存的入园承诺配置 */ export const getLocalCommitmentConfig = async () => { const offlineConfig = await getOfflineConfig('commitment_config'); if (offlineConfig) { return offlineConfig; } return uni.getStorageSync('commitment_config') || {}; }; components/StepProgress.vue
@@ -45,10 +45,18 @@ mode: { type: String, default: 'appointment' // 'appointment' 或 'online' }, steps: { type: Array, default: () => [] } }); const stepsList = computed(() => { if (Array.isArray(props.steps) && props.steps.length > 0) { return props.steps; } if (props.mode === 'online') { return [ { text: '身份证验证', icon: 'idcard' }, components/admin/AdminRecordsPanel.vue
@@ -466,48 +466,19 @@ validEndTime: formatFilterDateTime(appliedFilters.endDate, true), }; let isOnline = true; uni.showLoading({ title: '加载中...' }); const res = await getVisitorRecordPage(payload); let onlineList = []; try { const res = await getVisitorRecordPage(payload); if (res && res.code === 200 && res.data) { // 兼容不同的后端返回结构:有的后端返回 list,有的返回 records onlineList = res.data.records || res.data.list || []; // 只有无筛选的全量查询结果才回写本地缓存,避免把筛选结果覆盖全量历史 if (!hasActiveFilters.value) { await saveHistoryRecords(onlineList); } } } catch (networkError) { console.log('无法获取线上历史记录,降级使用本地缓存', networkError); isOnline = false; } // 无论线上成功与否,都读取一次本地数据;有筛选时优先使用接口结果,并补充本地待同步记录 const combinedLocalList = await getCombinedHistoryRecords(); let filteredList = []; if (isOnline && hasActiveFilters.value) { const localPendingList = combinedLocalList.filter(item => item._source === 'local_pending'); filteredList = [...applyClientFilters(localPendingList), ...onlineList]; if (res && res.code === 200 && res.data) { onlineList = res.data.records || res.data.list || []; } else { let combinedList = combinedLocalList; // 如果是在 H5/浏览器环境下预览,本地数据库会被 mock 返回空数组 // 因此如果 combinedList 为空且在线请求成功,直接使用在线数据进行渲染兜底 // #ifndef APP-PLUS if (isOnline && combinedList.length === 0) { combinedList = onlineList; } // #endif filteredList = applyClientFilters(combinedList); throw new Error(res?.msg || '获取线上记录失败'); } totalRecords.value = filteredList.length; totalRecords.value = onlineList.length; currentPage.value = 1; records.value = filteredList.map((item) => { records.value = onlineList.map((item) => { const statusInfo = getStatusInfo(item.status); const name = item.visitorName || '未知'; const detailSnapshot = buildDetailSnapshot(item); @@ -529,54 +500,34 @@ detailSnapshot, }; }); if (!isOnline) { uni.showToast({ title: '已加载离线记录', icon: 'none' }); } } catch (error) { console.error('加载访客记录失败', error); uni.showToast({ title: '加载失败', icon: 'none' }); records.value = []; totalRecords.value = 0; uni.showToast({ title: '网络异常,获取失败', icon: 'none' }); } finally { isLoading.value = false; uni.hideLoading(); } }; const loadVisitAreas = async () => { let rawData = []; try { const networkType = await new Promise(resolve => { uni.getNetworkType({ success: (res) => resolve(res.networkType) const res = await getVisitAreaTree(); if (res && res.code === 200 && Array.isArray(res.data)) { const rawData = res.data; areaTree.value = rawData.map(item => { const name = item.name || item.areaName || item.label || item.areaId || item.id; const rawChildren = (item.children && item.children.length > 0) ? item.children : [item]; const children = rawChildren.map(child => child.name || child.areaName || child.label || child.areaId || child.id); return { name, children }; }); }); if (networkType !== 'none') { const res = await getVisitAreaTree(); if (res && res.code === 200 && Array.isArray(res.data)) { rawData = res.data; if (!tempAreaParent.value) { tempAreaParent.value = areaTree.value[0]?.name || ''; } } } catch (err) { console.error('获取在线区域树失败:', err); } if (!rawData.length) { const cachedData = await getOfflineConfig('visit_area_tree'); if (cachedData && Array.isArray(cachedData)) { rawData = cachedData; } } if (rawData.length) { areaTree.value = rawData.map(item => { const name = item.name || item.areaName || item.label || item.areaId || item.id; const rawChildren = (item.children && item.children.length > 0) ? item.children : [item]; const children = rawChildren.map(child => child.name || child.areaName || child.label || child.areaId || child.id); return { name, children }; }); if (!tempAreaParent.value) { tempAreaParent.value = areaTree.value[0]?.name || ''; } console.error('获取区域树失败:', err); } }; @@ -808,13 +759,6 @@ const openDetail = (record) => { const recordId = record?.id || ''; if (recordId && record?.detailSnapshot) { uni.setStorageSync(`${DETAIL_SNAPSHOT_STORAGE_PREFIX}${recordId}`, { value: record.detailSnapshot, updateTime: Date.now(), }); } uni.navigateTo({ url: `/pages/admin-record-detail/index?id=${recordId}`, }); package-lock.json
New file @@ -0,0 +1,28 @@ { "name": "visitor-kiosk-uniapp", "version": "1.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "visitor-kiosk-uniapp", "version": "1.0.0", "license": "ISC", "dependencies": { "crypto-js": "^4.2.0" } }, "node_modules/crypto-js": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" } }, "dependencies": { "crypto-js": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" } } } package.json
New file @@ -0,0 +1,22 @@ { "name": "visitor-kiosk-uniapp", "version": "1.0.0", "description": "## 项目简介", "main": "main.js", "directories": { "doc": "docs" }, "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "repository": { "type": "git", "url": "http://pyt@120.76.84.145:10101/gitblit/r/H5/fangkeji.git" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { "crypto-js": "^4.2.0" } } pages.json
@@ -87,6 +87,14 @@ "disableScroll": true, "backgroundColor": "#2550D9" } }, { "path": "pages/commitment/index", "style": { "navigationStyle": "custom", "disableScroll": true, "backgroundColor": "#FFFFFF" } } ], "globalStyle": { pages/admin-home/index.vue
@@ -308,7 +308,7 @@ import { computed, ref } from 'vue'; import { onLoad } from '@dcloudio/uni-app'; import { getDashboardOverview } from '../../api/dashboard'; import { getOfflineConfigWithMeta, saveOfflineConfig } from '../../utils/db'; import { getLocalSystemConfig } from '../../api/visitorConfig'; import AdminShell from '../../components/admin/AdminShell.vue'; import AdminTabBar from '../../components/admin/AdminTabBar.vue'; import AdminRecordsPanel from '../../components/admin/AdminRecordsPanel.vue'; @@ -530,7 +530,6 @@ }; const fetchDashboardData = async () => { const cacheKey = buildDashboardCacheKey(); try { const payload = {}; if (appliedPeriod.value === 'custom') { @@ -550,51 +549,71 @@ if (res && res.code === 200 && res.data) { applyDashboardResponseData(res.data); await saveOfflineConfig(cacheKey, res.data); return; } throw new Error(res?.msg || '获取看板数据失败'); } catch (err) { console.error('获取看板数据失败', err); const cachedData = await getOfflineConfigWithMeta(cacheKey); if (cachedData?.value) { applyDashboardResponseData(cachedData.value); uni.showToast({ title: '已加载离线统计数据', icon: 'none' }); } else { dashboardStats.value = { onlineAppointments: 0, onlineCars: 0, onsiteAppointments: 0, onsiteCars: 0, stayingVisitors: 0, checkedInVisitors: 0, overdueVisitors: 0, }; trendChartData.value = []; uni.showToast({ title: '数据加载失败', icon: 'none' }); } dashboardStats.value = { onlineAppointments: 0, onlineCars: 0, onsiteAppointments: 0, onsiteCars: 0, stayingVisitors: 0, checkedInVisitors: 0, overdueVisitors: 0, }; trendChartData.value = []; uni.showToast({ title: '网络异常,获取失败', icon: 'none' }); } finally { uni.hideLoading(); } }; const deviceInfo = ref([ { icon: versionIcon, text: '当前版本号:v1.0.0' }, { icon: vendorIcon, text: '服务厂家:华南光电' }, { icon: supportIcon, text: '技术支持:400-882-6688' }, { icon: terminalIcon, text: '终端编号:K1024-8823' }, { id: 'version', icon: versionIcon, text: '当前版本号:获取中...' }, { id: 'vendor', icon: vendorIcon, text: '服务厂家:获取中...' }, { id: 'support', icon: supportIcon, text: '技术支持:获取中...' }, { id: 'terminal', icon: terminalIcon, text: '终端编号:获取中...' }, ]); const fetchSystemFooterConfig = async () => { try { const config = await getLocalSystemConfig(); const versionIndex = deviceInfo.value.findIndex(item => item.id === 'version'); const vendorIndex = deviceInfo.value.findIndex(item => item.id === 'vendor'); const supportIndex = deviceInfo.value.findIndex(item => item.id === 'support'); if (versionIndex > -1) { deviceInfo.value[versionIndex].text = `当前版本号:${config.version || config.systemVersion || 'v2.1.0'}`; } if (vendorIndex > -1) { deviceInfo.value[vendorIndex].text = `服务厂家:${config.serviceProvider || config.manufacturer || '未知'}`; } if (supportIndex > -1) { deviceInfo.value[supportIndex].text = `技术支持:${config.technicalSupport || config.supportPhone || '未知'}`; } } catch (error) { console.error('获取系统底部配置失败:', error); } }; const fetchDeviceFingerprint = async () => { try { const sysInfo = uni.getSystemInfoSync(); // 优先尝试获取 deviceId,其次用 model 作为兜底 const deviceId = sysInfo.deviceId || sysInfo.model || '未知设备'; deviceInfo.value[3].text = `终端编号:${deviceId}`; const terminalIndex = deviceInfo.value.findIndex(item => item.id === 'terminal'); if (terminalIndex > -1) { deviceInfo.value[terminalIndex].text = `终端编号:${deviceId}`; } } catch (err) { console.error('获取设备编号失败', err); deviceInfo.value[3].text = '终端编号:获取失败'; const terminalIndex = deviceInfo.value.findIndex(item => item.id === 'terminal'); if (terminalIndex > -1) { deviceInfo.value[terminalIndex].text = '终端编号:获取失败'; } } }; @@ -652,6 +671,7 @@ fetchDashboardData(); fetchDeviceFingerprint(); fetchSystemFooterConfig(); }); </script> pages/admin-record-detail/index.vue
@@ -243,8 +243,6 @@ return typeStr || '未知'; }; const getDetailSnapshotStorageKey = (recordId) => `${DETAIL_SNAPSHOT_STORAGE_PREFIX}${recordId}`; const normalizeDetailData = (data = {}) => { const statusInfo = getStatusInfo(data.status); const areaTags = Array.isArray(data.visitAreaNameList) @@ -275,57 +273,18 @@ }; }; const applyDetailSnapshot = (snapshot) => { if (!snapshot) { return false; } detail.value = { ...createDefaultDetail(), ...snapshot, }; return true; }; const loadLocalDetail = async (recordId) => { const snapshotStorage = uni.getStorageSync(getDetailSnapshotStorageKey(recordId)); const snapshotValue = snapshotStorage?.value || snapshotStorage; if (snapshotValue) { return applyDetailSnapshot(snapshotValue); } const cachedRecord = await getHistoryRecordByRecordId(recordId); if (!cachedRecord) { return false; } const normalizedSnapshot = normalizeDetailData(cachedRecord); uni.setStorageSync(getDetailSnapshotStorageKey(recordId), { value: normalizedSnapshot, updateTime: Number(cachedRecord?._cacheTime || 0) || Date.now(), }); return applyDetailSnapshot(normalizedSnapshot); }; const fetchDetail = async (recordId) => { const loadDetail = async (recordId) => { uni.showLoading({ title: '加载中...' }); try { uni.showLoading({ title: '加载中...' }); const res = await getVisitorRecordDetail({ recordId }); if (res && res.code === 200 && res.data) { const normalizedSnapshot = normalizeDetailData(res.data); detail.value = normalizedSnapshot; uni.setStorageSync(getDetailSnapshotStorageKey(recordId), { value: normalizedSnapshot, updateTime: Date.now(), }); return; } else if (res && res.msg) { throw new Error(res.msg); detail.value = normalizeDetailData(res.data); } else { throw new Error(res?.msg || '获取详情失败'); } } catch (error) { console.error('获取详情失败', error); const hasLocalDetail = await loadLocalDetail(recordId); uni.showToast({ title: hasLocalDetail ? '已加载离线详情' : '获取详情失败', icon: 'none' }); } catch (err) { console.error('获取在线详情失败:', err); uni.showToast({ title: '网络异常,获取详情失败', icon: 'none' }); } finally { uni.hideLoading(); } @@ -334,8 +293,7 @@ onLoad((options) => { id.value = options?.id || ''; if (id.value) { loadLocalDetail(id.value); fetchDetail(id.value); loadDetail(id.value); } }); pages/appointment-scan/index.vue
@@ -14,7 +14,7 @@ </view> <!-- 步骤卡片组件 --> <StepProgress :currentStep="currentStep" :mode="mode" /> <StepProgress :currentStep="currentStep" :mode="mode" :steps="stepProgressSteps" /> <!-- 步骤: 预约码验证 --> <template v-if="currentStepName === 'qr'"> @@ -89,7 +89,7 @@ <text class="help-content">请使用二代身份证原件放至读卡区位置</text> </view> <view class="action-card"> <view class="action-card" v-if="allowManualIdCard"> <view class="highlight-bar"></view> <view class="card-title-row"> <view class="icon-base"> @@ -253,7 +253,7 @@ </view> <text class="card-title">识别提示</text> </view> <text class="help-content">请确保现场光线适中,面部无遮挡;\n识别成功后系统将自动进入下一步接待人验证</text> <text class="help-content">{{ faceVerifyHelpText }}</text> </view> <view class="action-card"> @@ -595,6 +595,7 @@ import { saveImageToLocal, deleteLocalImage } from '../../utils/fs'; import { getVisitAreaTree } from '../../api/visitArea'; import { insertOfflineRecord, findEntryRecordByIdCard, markEntryRecordSignedOut, getOfflineConfig, saveOfflineConfig } from '../../utils/db'; import { getLocalSystemConfig } from '../../api/visitorConfig'; import { startSyncQueue } from '../../utils/syncEngine'; import { parseHikvisionQrcodePayload, @@ -703,6 +704,130 @@ const currentStep = ref(1); const mode = ref('appointment'); const systemFlowConfig = ref({}); const allowManualIdCard = computed(() => { return systemFlowConfig.value.manualIdCardEnabled !== false; }); const registerReceiverConfirmEnabled = computed(() => { return systemFlowConfig.value.registerReceiverConfirmEnabled !== false; }); const signReceiverConfirmEnabled = computed(() => { return systemFlowConfig.value.signReceiverConfirmEnabled !== false; }); const shouldShowHostStep = computed(() => { if (mode.value === 'signout') { return signReceiverConfirmEnabled.value; } return registerReceiverConfirmEnabled.value; }); const receiverVerifyMethodValue = computed(() => { return shouldShowHostStep.value ? '2' : ''; }); const stepProgressSteps = computed(() => { if (mode.value === 'appointment') { const steps = [ { text: '预约码验证', icon: 'qr' }, { text: '身份证验证', icon: 'idcard' }, { text: '人脸验证', icon: 'face' }, ]; if (shouldShowHostStep.value) { steps.push({ text: '接待人验证', icon: 'user' }); } steps.push({ text: '打印凭条', icon: 'print' }); return steps; } if (mode.value === 'online') { const steps = [ { text: '身份证验证', icon: 'idcard' }, { text: '人脸验证', icon: 'face' }, ]; if (shouldShowHostStep.value) { steps.push({ text: '接待人验证', icon: 'user' }); } steps.push({ text: '完善登记信息', icon: 'form' }); steps.push({ text: '打印凭条', icon: 'print' }); return steps; } const steps = [ { text: '身份证验证', icon: 'idcard' }, { text: '人脸验证', icon: 'face' }, ]; if (shouldShowHostStep.value) { steps.push({ text: '接待人验证', icon: 'user' }); } return steps; }); const flowStepNames = computed(() => { if (mode.value === 'appointment') { const steps = ['qr', 'idcard', 'face']; if (shouldShowHostStep.value) { steps.push('host'); } steps.push('print'); return steps; } if (mode.value === 'online') { const steps = ['idcard', 'face']; if (shouldShowHostStep.value) { steps.push('host'); } steps.push('form'); steps.push('print'); return steps; } const steps = ['idcard', 'face']; if (shouldShowHostStep.value) { steps.push('host'); } return steps; }); const faceVerifyHelpText = computed(() => { if (mode.value === 'signout') { return shouldShowHostStep.value ? '请确保现场光线适中,面部无遮挡;\n识别成功后系统将自动进入下一步接待人验证' : '请确保现场光线适中,面部无遮挡;\n识别成功后系统将自动完成签离流程'; } if (mode.value === 'online') { return shouldShowHostStep.value ? '请确保现场光线适中,面部无遮挡;\n识别成功后系统将自动进入下一步接待人验证' : '请确保现场光线适中,面部无遮挡;\n识别成功后系统将自动进入完善登记信息'; } return shouldShowHostStep.value ? '请确保现场光线适中,面部无遮挡;\n识别成功后系统将自动进入下一步接待人验证' : '请确保现场光线适中,面部无遮挡;\n识别成功后系统将自动进入打印凭条'; }); const configuredPrintFieldOptions = computed(() => { if (Array.isArray(systemFlowConfig.value?.printFieldOptions) && systemFlowConfig.value.printFieldOptions.length > 0) { return systemFlowConfig.value.printFieldOptions; } return [ { fieldCode: 'visitorName', fieldName: '访客姓名' }, { fieldCode: 'idCard', fieldName: '身份证号' }, { fieldCode: 'appointmentTimeRange', fieldName: '预约时间范围' }, { fieldCode: 'interviewee', fieldName: '被访者' }, { fieldCode: 'visitorFaceImage', fieldName: '访客人脸图片' }, { fieldCode: 'registerNo', fieldName: '登记编号' }, { fieldCode: 'plateNo', fieldName: '车牌号' }, { fieldCode: 'visitReason', fieldName: '来访事由' }, { fieldCode: 'visitArea', fieldName: '访问区域' }, ]; }); // 手动录入状态 const isManualInput = ref(false); @@ -710,6 +835,19 @@ const manualName = ref(''); const showMissingEntryHint = ref(false); const isNameInputFocused = ref(false); // 记录姓名输入框是否处于聚焦状态 const loadSystemFlowConfig = async () => { try { const config = await getLocalSystemConfig(); systemFlowConfig.value = config || {}; if (!allowManualIdCard.value) { isManualInput.value = false; } } catch (error) { console.error('读取登记流程系统配置失败:', error); systemFlowConfig.value = {}; } }; const showCustomKeyboard = computed(() => { if (mode.value === 'online') { @@ -1214,8 +1352,9 @@ }; // ---------------------------------------------------- onMounted(() => { loadVisitAreas(); onMounted(async () => { await loadSystemFlowConfig(); await loadVisitAreas(); clearAppointmentQrPayload(); clearAppointmentIdCardPayload(); @@ -1257,14 +1396,13 @@ }); const currentStepName = computed(() => { if (mode.value === 'appointment') { return ['qr', 'idcard', 'face', 'host', 'print'][currentStep.value - 1]; } else if (mode.value === 'online') { return ['idcard', 'face', 'host', 'form', 'print'][currentStep.value - 1]; } else if (mode.value === 'signout') { return ['idcard', 'face', 'host'][currentStep.value - 1]; return flowStepNames.value[currentStep.value - 1] || ''; }); watch(flowStepNames, (newFlowStepNames) => { if (currentStep.value > newFlowStepNames.length) { currentStep.value = newFlowStepNames.length || 1; } return ''; }); // 监听步骤变化,动态启停硬件 @@ -1329,6 +1467,11 @@ if (!hasEntry) return; } if (mode.value === 'online') { const hasEntry = await verifyOnsiteEntry(cardData?.licId); if (hasEntry) return; } // 保存身份证识别结果,后续可用于身份校验和人脸照片比对 appointmentIdCardPayload.value = cardData; saveAppointmentIdCardPayload(cardData); @@ -1391,7 +1534,7 @@ } } let maxStep = mode.value === 'signout' ? 3 : 5; const maxStep = flowStepNames.value.length; if (currentStep.value < maxStep) { currentStep.value++; } else if (currentStepName.value !== 'print') { @@ -1426,6 +1569,23 @@ return true; }; const verifyOnsiteEntry = async (idCard) => { if (mode.value !== 'online') return false; if (!idCard) { return false; } const hasEntry = await findEntryRecordByIdCard(idCard); if (hasEntry) { openNoticeDialog('该身份已进场', '您已进场,请签离'); return true; } return false; }; const confirmManualInput = async () => { if (idNumber.value.length > 0) { if (mode.value === 'online' && !manualName.value.trim()) { @@ -1437,6 +1597,13 @@ const hasEntry = await verifySignoutEntry(idNumber.value); if (!hasEntry) { showMissingEntryHint.value = true; return; } } if (mode.value === 'online') { const hasEntry = await verifyOnsiteEntry(idNumber.value); if (hasEntry) { return; } } @@ -1477,7 +1644,7 @@ } const intervieweeName = appointmentQrcodePayload.value?.beVisitorName || ''; const plateNo = plateNumber.value || ''; const receiverVerifyMethod = '2'; // 接待人验证方式:默认人脸 const receiverVerifyMethod = receiverVerifyMethodValue.value; let validEndTime = ''; if (mode.value === 'online') { @@ -1500,7 +1667,7 @@ intervieweeName, receiverVerifyMethod, signImageUrl: '', // 由 syncEngine 负责上传并回填 signReceiverName: intervieweeName || appointmentHostName.value, signReceiverName: shouldShowHostStep.value ? (intervieweeName || appointmentHostName.value) : '', signVerifyMethod: receiverVerifyMethod, signTime: nowFormat // 增加签离时间字段供离线接口使用 }; @@ -1643,6 +1810,97 @@ }; }; const getPrintVisitorName = () => { let printVisitorName = appointmentIdCardPayload.value?.name || appointmentIdCardPayload.value?.visitorName || appointmentQrcodePayload.value?.visitorName || '无'; if (mode.value === 'online' && manualName.value.trim()) { printVisitorName = manualName.value.trim(); } return printVisitorName || '无'; }; const getPrintAppointmentTimeRange = () => { if (mode.value === 'online') { return `${visitEndDateText.value} ${selectedTime.value}`; } if (appointmentQrcodePayload.value) { return `${appointmentQrcodePayload.value.startTime || ''} 至 ${appointmentQrcodePayload.value.endTime || ''}` || '无'; } return '无'; }; const getPrintFieldValueMap = () => { const hostName = appointmentQrcodePayload.value?.beVisitorName || '无'; const areas = mode.value === 'online' ? getSelectedAreaNames() : (appointmentQrcodePayload.value?.areas || '无'); const photoPath = liveFacePhotoPath.value || appointmentIdCardPayload.value?.photoAddress || ''; return { visitorName: { type: 'text', value: getPrintVisitorName(), }, idCard: { type: 'text', value: idNumber.value || appointmentIdCardPayload.value?.licId || '无', }, appointmentTimeRange: { type: 'text', value: getPrintAppointmentTimeRange(), }, interviewee: { type: 'text', value: hostName, }, visitorFaceImage: { type: 'image', value: photoPath, emptyText: '无', }, registerNo: { type: 'text', value: appointmentQrcodePayload.value?.registerNo || appointmentQrcodePayload.value?.appointmentNo || appointmentQrcodePayload.value?.reservationNo || '无', }, plateNo: { type: 'text', value: plateNumber.value || '无', }, visitReason: { type: 'text', value: appointmentQrcodePayload.value?.visitPurpose || '无', }, visitArea: { type: 'text', value: areas || '无', }, }; }; const pushPrintFieldByOption = (list, fieldOption, fieldValueMap) => { const fieldCode = fieldOption?.fieldCode; const fieldName = fieldOption?.fieldName || fieldCode || '未知字段'; const fieldConfig = fieldValueMap[fieldCode]; if (!fieldConfig) { return; } if (fieldConfig.type === 'image') { list.push(getPrintPair(fieldName, fieldConfig.value ? '已采集' : (fieldConfig.emptyText || '无'))); if (fieldConfig.value) { const imageLine = createEmptyPrintLine(); imageLine.path = fieldConfig.value; imageLine.alignment = 2; imageLine.imageWidth = 240; imageLine.feedLine = 1; list.push(imageLine); } return; } list.push(getPrintPair(fieldName, fieldConfig.value || '无')); }; const startPrintTicket = async () => { try { const list = []; @@ -1667,54 +1925,10 @@ // 空行 list.push(createEmptyPrintLine()); // 登记时间 const now = new Date(); const registerTime = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`; list.push(getPrintPair("登记时间", registerTime)); let printVisitorName = appointmentIdCardPayload.value?.name || appointmentIdCardPayload.value?.visitorName || appointmentQrcodePayload.value?.visitorName || '未知'; if (mode.value === 'online' && manualName.value.trim()) { printVisitorName = manualName.value.trim(); } list.push(getPrintPair("访客", printVisitorName)); list.push(getPrintPair("车牌号", plateNumber.value || '无')); const hostName = appointmentQrcodePayload.value?.beVisitorName || '无'; list.push(getPrintPair("被访人", hostName)); list.push(getPrintPair("接待人", hostName)); const areas = mode.value === 'online' ? getSelectedAreaNames() : (appointmentQrcodePayload.value?.areas || '无'); list.push(getPrintPair("访问区域", areas)); let visitTimeStr = ''; if (mode.value === 'online') { visitTimeStr = `${visitEndDateText.value} ${selectedTime.value}`; } else { visitTimeStr = appointmentQrcodePayload.value ? `${appointmentQrcodePayload.value.startTime || ''} 至 ${appointmentQrcodePayload.value.endTime || ''}` : '无'; } list.push(getPrintPair("访问时间", visitTimeStr)); const idCardNumber = idNumber.value || appointmentIdCardPayload.value?.licId || ''; list.push(getPrintPair("身份证号码", idCardNumber)); // 人像标题 const headerLabel = createEmptyPrintLine(); headerLabel.text = "人像"; headerLabel.alignment = 0; headerLabel.textBold = 1; headerLabel.feedLine = 1; list.push(headerLabel); // 人像图片 const photoPath = liveFacePhotoPath.value || appointmentIdCardPayload.value?.photoAddress; if (photoPath) { const headerImage = createEmptyPrintLine(); headerImage.path = photoPath; headerImage.alignment = 2; headerImage.imageWidth = 240; headerImage.feedLine = 1; list.push(headerImage); } const fieldValueMap = getPrintFieldValueMap(); configuredPrintFieldOptions.value.forEach((fieldOption) => { pushPrintFieldByOption(list, fieldOption, fieldValueMap); }); console.log('开始打印凭条,参数:', list); const data = JSON.stringify(list); pages/commitment/index.vue
New file @@ -0,0 +1,222 @@ <template> <view class="page-container" @touchstart="resetIdleTimer"> <view class="header-area"> <view class="left-action" @click="goBack"> <view class="back-btn"> <image src="../../static/admin-detail/Ay837.png" class="back-icon" mode="aspectFit"></image> </view> </view> </view> <view class="content-wrapper"> <text class="page-title">{{ title }}</text> <scroll-view class="scroll-area" scroll-y @scroll="resetIdleTimer"> <view class="paragraphs"> <!-- 这里使用 text 组件包裹文本,并保留换行符支持 --> <text class="commitment-text">{{ commitmentText }}</text> </view> </scroll-view> <view class="footer-area"> <view class="confirm-btn" :class="{ disabled: countdown > 0 }" @click="handleConfirm"> <text class="btn-text">我已知晓{{ countdown > 0 ? `(${countdown}s)` : '' }}</text> </view> </view> </view> </view> </template> <script setup> import { ref, onMounted, onUnmounted } from 'vue'; import { getLocalCommitmentConfig, syncCommitmentConfig } from '../../api/visitorConfig'; const countdown = ref(5); const idleTime = ref(60); const commitmentText = ref(''); const title = ref(''); let countdownTimer = null; let idleTimer = null; const loadCommitmentData = async () => { try { uni.showLoading({ title: '加载中...' }); // 先尝试从在线接口拉取并同步到本地 await syncCommitmentConfig(); // 读取本地的入园承诺配置 const config = await getLocalCommitmentConfig(); if (config && config.protocolContent) { title.value = config.protocolName || '入园承诺'; commitmentText.value = config.protocolContent; } } catch (err) { console.error('获取入园承诺配置失败:', err); } finally { uni.hideLoading(); } }; const goBack = () => { uni.navigateBack({ fail: () => uni.reLaunch({ url: '/pages/home/index' }) }); }; const handleConfirm = () => { if (countdown.value > 0) return; uni.redirectTo({ url: '/pages/appointment-scan/index?mode=online&step=1', }); }; const resetIdleTimer = () => { idleTime.value = 60; }; const startTimers = () => { countdownTimer = setInterval(() => { if (countdown.value > 0) { countdown.value--; } else { clearInterval(countdownTimer); } }, 1000); idleTimer = setInterval(() => { idleTime.value--; if (idleTime.value <= 0) { uni.reLaunch({ url: '/pages/home/index' }); } }, 1000); }; const clearTimers = () => { if (countdownTimer) clearInterval(countdownTimer); if (idleTimer) clearInterval(idleTimer); }; onMounted(() => { loadCommitmentData(); startTimers(); }); onUnmounted(() => { clearTimers(); }); </script> <style scoped> .page-container { width: 100vw; height: 100vh; background-color: #FFFFFF; display: flex; flex-direction: column; box-sizing: border-box; } .header-area { padding: 60px 40px 0; display: flex; align-items: center; } .left-action { display: flex; align-items: center; gap: 16px; cursor: pointer; } .back-btn { width: 44px; height: 44px; border-radius: 22px; border: 1px solid #E1EAF8; background: #F5F8FD; box-shadow: 0 6px 14px rgba(11, 23, 48, 0.03); display: flex; align-items: center; justify-content: center; } .back-icon { width: 20px; height: 20px; } .content-wrapper { flex: 1; display: flex; flex-direction: column; align-items: center; padding: 20px 80px 0; box-sizing: border-box; overflow: hidden; } .page-title { font-family: Inter, sans-serif; font-size: 36px; font-weight: 700; color: #1F2A44; margin-bottom: 50px; flex-shrink: 0; } .scroll-area { flex: 1; height: 0; width: 100%; box-sizing: border-box; margin-bottom: 40px; } .paragraphs { display: flex; flex-direction: column; padding-bottom: 20px; } .commitment-text { font-family: Inter, sans-serif; font-size: 16px; font-weight: 500; line-height: 1.6; color: #1F2A44; white-space: pre-wrap; word-break: break-all; } .footer-area { flex-shrink: 0; width: 100%; display: flex; justify-content: center; padding-bottom: 60px; } .confirm-btn { width: 600px; height: 88px; border-radius: 44px; background: #2F6BFF; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.3s; } .confirm-btn.disabled { background: #A0ACC4; cursor: not-allowed; } .btn-text { font-size: 30px; font-weight: 700; color: #FFFFFF; } </style> pages/home/index.vue
@@ -1,7 +1,13 @@ <script setup> import { onMounted, ref } from 'vue'; import { onShow, onHide } from '@dcloudio/uni-app'; import { getOfflineConfig } from '../../utils/db'; import { buildBaseUrl, getRequestServerConfig } from '../../utils/request'; import { HardwareAPI } from '../../utils/visitorDevice'; import { parseHikvisionQrcodePayload, validateAppointmentQrcodePayload } from '../../utils/hikvisionQrcode'; import { saveAppointmentQrPayload } from '../../utils/appointmentIdentityStore'; import { syncSystemConfig } from '../../api/visitorConfig'; import CommonNoticeDialog from '../../components/CommonNoticeDialog.vue'; import adminIcon from '../../static/home/avatar.png'; import operationImage from '../../static/home/operation.png'; import appointmentIcon from '../../static/home/appointment.png'; @@ -14,6 +20,9 @@ // 模拟后台配置:是否显示管理员入口,默认为 true const showAdminEntry = ref(true); const showOnsiteEntry = ref(true); const showAppointmentEntry = ref(true); const companyName = ref('华南光电'); const logoImage = ref(defaultLogoImage); const screenStyle = { @@ -60,18 +69,36 @@ ); }; const serviceProviderName = ref(''); const loadHomeLogo = async () => { try { const offlineSystemConfig = await getOfflineConfig('system_config'); const storageSystemConfig = uni.getStorageSync('system_config'); const storageHomeConfig = uni.getStorageSync('home_config'); const configuredLogo = resolveConfigLogoUrl(offlineSystemConfig) || resolveConfigLogoUrl(storageSystemConfig) || resolveConfigLogoUrl(storageHomeConfig); logoImage.value = configuredLogo || defaultLogoImage; // 合并配置,优先使用在线接口最新拉取的 system_config const mergedConfig = Object.assign({}, storageHomeConfig, offlineSystemConfig, storageSystemConfig); const configuredLogo = resolveConfigLogoUrl(mergedConfig); // logoImage.value = configuredLogo || defaultLogoImage; logoImage.value = defaultLogoImage; // 解析新的开关和文案配置 if (mergedConfig.hasOwnProperty('visitorMachineEntryEnabled')) { showAdminEntry.value = Boolean(mergedConfig.visitorMachineEntryEnabled); } if (mergedConfig.hasOwnProperty('onsiteRegistrationEnabled')) { showOnsiteEntry.value = Boolean(mergedConfig.onsiteRegistrationEnabled); } if (mergedConfig.hasOwnProperty('appointmentRegistrationEnabled')) { showAppointmentEntry.value = Boolean(mergedConfig.appointmentRegistrationEnabled); } if (mergedConfig.systemName) { companyName.value = mergedConfig.serviceProvider; } } catch (error) { console.error('加载首页Logo失败:', error); console.error('加载首页Logo和配置失败:', error); logoImage.value = defaultLogoImage; } }; @@ -105,7 +132,7 @@ const goOnline = () => { uni.navigateTo({ url: '/pages/appointment-scan/index?mode=online&step=1', url: '/pages/commitment/index', }) } @@ -115,13 +142,87 @@ }) } const noticeDialog = ref({ visible: false, title: '', description: '', }); const openNoticeDialog = (title, description) => { noticeDialog.value = { visible: true, title, description, }; }; const closeNoticeDialog = () => { noticeDialog.value.visible = false; }; const initHomeScanner = () => { HardwareAPI.startScan(async (scanData) => { try { const decryptedResult = await parseHikvisionQrcodePayload(scanData); const payload = decryptedResult.payload; const validation = validateAppointmentQrcodePayload(payload); if (!validation.valid) { if (validation.reason === 'out_of_time_range') { openNoticeDialog('预约码不在进场时间内,请联系工作人员', '请检查您的预约时间'); } else if (validation.reason === 'already_entered') { openNoticeDialog('该预约码已进场', '您已进场,请签离'); } else { openNoticeDialog('无效的预约二维码', '请联系工作人员'); } return; } saveAppointmentQrPayload(payload); // 扫码成功,直接进入预约登记的第二步(身份证阅读) uni.navigateTo({ url: '/pages/appointment-scan/index?mode=appointment&step=2' }); } catch (error) { console.error('首页扫码解析失败:', error); openNoticeDialog('未识别二维码,请联系工作人员', ''); } }); }; const initHomeData = async () => { // 先加载本地缓存的 Logo 以求快速显示 await loadHomeLogo(); // 异步拉取并同步全局系统配置到本地 await syncSystemConfig(); // 再次加载,如果接口配置有更新,则实时应用最新配置 await loadHomeLogo(); }; onMounted(() => { loadHomeLogo(); initHomeData(); }); onShow(() => { initHomeScanner(); }); onHide(() => { HardwareAPI.stopScan(); }); </script> <template> <view class="screen" :style="screenStyle"> <CommonNoticeDialog v-if="noticeDialog.visible" :title="noticeDialog.title" :description="noticeDialog.description" @close="closeNoticeDialog" /> <view class="top-bar"> <image class="logo" :src="logoImage" mode="widthFix" /> <view @@ -144,7 +245,7 @@ <view class="welcome-badge" :style="welcomeBadgeStyle"> <view class="welcome-text">欢迎到访</view> </view> <view class="company-name">华南光电</view> <view class="company-name">{{ companyName }}</view> </view> <view class="hero-figure"> @@ -153,12 +254,12 @@ </view> <view class="entry-panel"> <view class="entry-card blue" @click="goAppointment"> <view class="entry-card blue" v-if="showAppointmentEntry" @click="goAppointment"> <text class="entry-title">预约登记</text> <image class="entry-icon" :src="appointmentIcon" mode="aspectFit" /> </view> <view class="entry-card purple" @click="goOnline"> <view class="entry-card purple" v-if="showOnsiteEntry" @click="goOnline"> <text class="entry-title">现场登记</text> <image class="entry-icon" :src="registerIcon" mode="aspectFit" /> </view> @@ -167,6 +268,10 @@ <text class="entry-title">签离</text> <image class="entry-icon" :src="signOutIcon" mode="aspectFit" /> </view> </view> <view class="provider-footer" v-if="serviceProviderName"> <text class="provider-text">{{ serviceProviderName }}</text> </view> </view> </template> @@ -342,4 +447,16 @@ width: 320rpx; height: 320rpx; } .provider-footer { position: absolute; bottom: 40rpx; width: 100%; text-align: center; } .provider-text { font-size: 28rpx; color: rgba(255, 255, 255, 0.6); } </style> static/logo.pngBinary files differ
utils/hikvisionQrcode.js
@@ -1,53 +1,9 @@ import CryptoJS from 'crypto-js'; const DEFAULT_HIKVISION_QRCODE_CONFIG = { encryptEnabled: true, aesKey: 'WwXxYyZz1234!@#$', aesIv: 'AaBbCcDd1234!@#$', }; const textEncoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null; const textDecoder = typeof TextDecoder !== 'undefined' ? new TextDecoder() : null; const getCryptoInstance = () => { if (typeof globalThis !== 'undefined' && globalThis.crypto?.subtle) { return globalThis.crypto; } throw new Error('当前环境不支持 Web Crypto,无法解密预约二维码'); }; const stringToUint8Array = (value) => { if (!textEncoder) { throw new Error('当前环境不支持 TextEncoder'); } return textEncoder.encode(String(value)); }; const decodeBase64ToUint8Array = (base64Text) => { if (typeof uni !== 'undefined' && typeof uni.base64ToArrayBuffer === 'function') { return new Uint8Array(uni.base64ToArrayBuffer(base64Text)); } if (typeof atob === 'function') { const binary = atob(base64Text); const bytes = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index += 1) { bytes[index] = binary.charCodeAt(index); } return bytes; } throw new Error('当前环境不支持 Base64 解码'); }; const uint8ArrayToString = (bytes) => { if (!textDecoder) { throw new Error('当前环境不支持 TextDecoder'); } return textDecoder.decode(bytes); }; const tryParseJson = (text) => { @@ -97,24 +53,24 @@ return rawText; } const crypto = getCryptoInstance(); const secretKey = await crypto.subtle.importKey( 'raw', stringToUint8Array(config.aesKey), { name: 'AES-CBC' }, false, ['decrypt'] ); const decryptedBuffer = await crypto.subtle.decrypt( { name: 'AES-CBC', iv: stringToUint8Array(config.aesIv), }, secretKey, decodeBase64ToUint8Array(rawText) ); const key = CryptoJS.enc.Utf8.parse(config.aesKey); const iv = CryptoJS.enc.Utf8.parse(config.aesIv); return uint8ArrayToString(new Uint8Array(decryptedBuffer)); try { const decrypted = CryptoJS.AES.decrypt(rawText, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); const plainText = decrypted.toString(CryptoJS.enc.Utf8); if (!plainText) { throw new Error('解密结果为空,可能密钥不匹配'); } return plainText; } catch (e) { throw new Error('二维码解密失败,请检查加密配置或数据格式: ' + e.message); } }; export const parseHikvisionQrcodePayload = async (scanData, customConfig = {}) => { utils/request.js
@@ -11,6 +11,7 @@ }; export const buildBaseUrl = ({ ip, port }) => { return 'http://e9925a9c.natappfree.cc' const normalizedIp = normalizeIp(ip); const normalizedPort = normalizePort(port);