app2的扫码 #170

Merged
hnu202326010125 merged 3 commits from luoyuehang_branch into develop 1 week ago

File diff suppressed because it is too large Load Diff

@ -10,14 +10,19 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test:unit": "vitest"
"test:unit": "vitest",
"cap:sync": "npx cap sync android",
"cap:open": "npx cap open android"
},
"dependencies": {
"@amap/amap-jsapi-loader": "^1.0.1",
"@capacitor/android": "^8.0.0",
"@capacitor/barcode-scanner": "^3.0.0",
"@capacitor/cli": "^8.0.0",
"@capacitor/core": "^8.0.0",
"@capacitor/android": "8.0.0",
"@capacitor/barcode-scanner": "3.0.0",
"@capacitor/camera": "8.0.0",
"@capacitor/cli": "8.0.0",
"@capacitor/core": "8.0.0",
"axios": "^1.7.9",
"jsqr": "^1.4.0",
"pinia": "^3.0.4",
"vue": "^3.5.25",
"vue-router": "^4.6.3"
@ -30,4 +35,4 @@
"vite-plugin-vue-devtools": "^8.0.5",
"vitest": "^4.0.14"
}
}
}

@ -1,4 +1,4 @@
<!-- ScanPage.vue - 修复样式版本 -->
<!-- ScanPage.vue - JavaScript 扫码版本 -->
<template>
<div class="scan-page">
<!-- 顶部标题栏 -->
@ -12,28 +12,45 @@
<!-- 扫描区域 -->
<div class="scan-section" v-if="!deviceInfo">
<div class="scan-area">
<!-- 扫描框 -->
<div class="scan-frame">
<div class="scan-lines">
<div class="scan-line"></div>
</div>
<div class="scan-corners">
<div class="corner top-left"></div>
<div class="corner top-right"></div>
<div class="corner bottom-left"></div>
<div class="corner bottom-right"></div>
</div>
<!-- 扫码状态提示 -->
<div v-if="scanStatus" class="scan-status" :class="scanStatus.type">
{{ scanStatus.message }}
</div>
<div class="scan-instruction">
请扫描设备二维码
<!-- 扫码按钮 -->
<div class="scan-button-container">
<button class="scan-btn" @click="startScan" :disabled="isScanning">
<span class="scan-icon">📷</span>
<span class="scan-text">
{{ isScanning ? '正在扫码...' : '点击扫码' }}
</span>
</button>
</div>
<div class="scan-hint">
将二维码放入框内即可自动扫描
<!-- 扫描框样式 -->
<div class="scan-frame-container">
<div class="scan-frame">
<div class="scan-lines">
<div class="scan-line"></div>
</div>
<div class="scan-corners">
<div class="corner top-left"></div>
<div class="corner top-right"></div>
<div class="corner bottom-left"></div>
<div class="corner bottom-right"></div>
</div>
</div>
<div class="scan-instruction">
请扫描设备二维码
</div>
<div class="scan-hint">
点击上方按钮开启摄像头扫码
</div>
</div>
<!-- 开发调试按钮更简洁的样式 -->
<!-- 开发调试按钮 -->
<div class="dev-options" v-if="isDevelopment">
<div class="dev-buttons">
<button class="dev-btn" @click="simulateScan('TERM001')">TERM001</button>
@ -67,7 +84,7 @@
</div>
</div>
<!-- 用户信息简洁样式 -->
<!-- 用户信息 -->
<div class="user-info" v-if="userInfo">
<div class="user-label">当前用户:</div>
<div class="user-name">{{ userInfo.username }}</div>
@ -95,7 +112,7 @@
</div>
</div>
<!-- 自定义输入精简样式 -->
<!-- 自定义输入 -->
<div class="custom-amount">
<input
type="number"
@ -197,7 +214,7 @@
</div>
</div>
<!-- 手动输入弹窗保持原有样式 -->
<!-- 手动输入弹窗 -->
<div v-if="showManualDialog" class="dialog-overlay">
<div class="dialog-content">
<div class="dialog-header">
@ -247,20 +264,14 @@
</div>
</template>
<!-- ScanPage.vue - 将所有设备设置为在线 -->
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { deviceService } from '@/services/deviceService'
import { useUserStore } from '@/stores/user'
import jsQR from 'jsqr'
const router = useRouter()
const userStore = useUserStore()
//
const isDevelopment = ref(true)
//
//
const deviceInfo = ref(null)
const showManualDialog = ref(false)
const manualDeviceId = ref('')
@ -273,6 +284,16 @@ const showResultDialog = ref(false)
const resultStatus = ref('')
const resultTitle = ref('')
const resultMessage = ref('')
const isScanning = ref(false)
const scanStatus = ref(null)
const router = useRouter()
const userStore = useUserStore()
//
const isDevelopment = computed(() => {
return import.meta.env.DEV;
});
//
const userInfo = computed(() => {
@ -280,7 +301,7 @@ const userInfo = computed(() => {
username: userStore.username || '用户',
studentId: userStore.studentId || '未登录'
}
})
});
//
const waterAmounts = [
@ -289,13 +310,13 @@ const waterAmounts = [
{ value: 750, price: '免费' }
]
// 线
//
const mockDevices = {
'TERM001': {
id: 'TERM001',
name: '教学楼饮水机',
deviceId: 'WM001',
status: 'online', // online
status: 'online',
statusText: '在线',
location: '教学楼1F大厅',
waterQuality: {
@ -307,7 +328,7 @@ const mockDevices = {
id: 'TERM002',
name: '学生公寓饮水机',
deviceId: 'WM002',
status: 'online', // online
status: 'online',
statusText: '在线',
location: '天马学生公寓1F',
waterQuality: {
@ -319,7 +340,7 @@ const mockDevices = {
id: 'TERM003',
name: '图书馆饮水机',
deviceId: 'WM003',
status: 'online', // online
status: 'online',
statusText: '在线',
location: '图书馆2F',
waterQuality: {
@ -329,22 +350,319 @@ const mockDevices = {
}
}
onMounted(() => {
console.log('扫码页面加载')
})
//
const checkCameraSupport = () => {
const isSecure = window.isSecureContext || window.location.protocol === 'https:'
const hasMediaDevices = 'mediaDevices' in navigator
const hasGetUserMedia = 'getUserMedia' in navigator.mediaDevices
console.log('摄像头支持检查:', {
isSecure,
hasMediaDevices,
hasGetUserMedia,
protocol: window.location.protocol
})
return isSecure && hasMediaDevices && hasGetUserMedia
}
// JavaScript
const startScan = async () => {
console.log('开始纯 JavaScript 扫码')
if (!checkCameraSupport()) {
alert('您的浏览器不支持摄像头访问,请使用手动输入')
showManualDialog.value = true
return
}
//
resetScan()
isScanning.value = true
scanStatus.value = {
type: 'info',
message: '正在准备扫码...'
}
try {
//
const scannerContainer = document.createElement('div')
scannerContainer.className = 'scanner-container'
scannerContainer.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.95);
z-index: 9999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
`
//
const title = document.createElement('div')
title.textContent = '请对准二维码'
title.style.cssText = `
color: white;
font-size: 18px;
margin-bottom: 20px;
font-weight: bold;
`
//
const videoContainer = document.createElement('div')
videoContainer.style.cssText = `
position: relative;
width: 300px;
height: 300px;
border: 2px solid #1890ff;
border-radius: 12px;
overflow: hidden;
margin-bottom: 20px;
`
//
const video = document.createElement('video')
video.style.cssText = `
width: 100%;
height: 100%;
object-fit: cover;
`
video.setAttribute('playsinline', 'true')
video.setAttribute('autoplay', 'true')
//
const scanFrame = document.createElement('div')
scanFrame.style.cssText = `
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
`
// 线
const scanLine = document.createElement('div')
scanLine.style.cssText = `
position: absolute;
width: 100%;
height: 2px;
background: linear-gradient(90deg, transparent, #1890ff, transparent);
top: 0;
animation: scanLineMove 2s infinite linear;
`
//
const style = document.createElement('style')
style.textContent = `
@keyframes scanLineMove {
0% { top: 0; }
100% { top: 100%; }
}
`
style.id = 'scanner-style'
//
const closeBtn = document.createElement('button')
closeBtn.textContent = '关闭'
closeBtn.style.cssText = `
padding: 12px 40px;
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
color: white;
border: none;
border-radius: 25px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
margin-top: 20px;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.3);
transition: all 0.3s;
`
closeBtn.onmouseenter = () => {
closeBtn.style.transform = 'translateY(-2px)'
closeBtn.style.boxShadow = '0 6px 16px rgba(24, 144, 255, 0.4)'
}
closeBtn.onmouseleave = () => {
closeBtn.style.transform = 'translateY(0)'
closeBtn.style.boxShadow = '0 4px 12px rgba(24, 144, 255, 0.3)'
}
//
scanFrame.appendChild(scanLine)
videoContainer.appendChild(video)
videoContainer.appendChild(scanFrame)
scannerContainer.appendChild(title)
scannerContainer.appendChild(videoContainer)
scannerContainer.appendChild(closeBtn)
document.head.appendChild(style)
document.body.appendChild(scannerContainer)
//
scanStatus.value.message = '正在访问摄像头...'
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment', // 使
width: { ideal: 1280 },
height: { ideal: 720 }
}
})
//
video.srcObject = stream
//
await new Promise((resolve) => {
video.onloadedmetadata = () => {
video.play()
resolve()
}
})
scanStatus.value.message = '请对准二维码...'
// Canvas
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
let scanning = true
//
const scanFrameFunc = () => {
if (!scanning) return
if (video.readyState === video.HAVE_ENOUGH_DATA) {
canvas.width = video.videoWidth
canvas.height = video.videoHeight
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
// 使 jsQR
try {
const code = jsQR(imageData.data, imageData.width, imageData.height, {
inversionAttempts: 'dontInvert',
})
if (code) {
console.log('找到二维码:', code.data)
scanning = false
//
stream.getTracks().forEach(track => track.stop())
//
document.body.removeChild(scannerContainer)
const styleEl = document.getElementById('scanner-style')
if (styleEl) {
document.head.removeChild(styleEl)
}
//
loadDeviceInfo(code.data)
return
}
} catch (error) {
console.warn('二维码分析错误:', error)
}
}
requestAnimationFrame(scanFrameFunc)
}
//
scanFrameFunc()
//
closeBtn.onclick = () => {
scanning = false
stream.getTracks().forEach(track => track.stop())
document.body.removeChild(scannerContainer)
const styleEl = document.getElementById('scanner-style')
if (styleEl) {
document.head.removeChild(styleEl)
}
isScanning.value = false
scanStatus.value = null
}
//
video.onerror = (error) => {
console.error('视频播放错误:', error)
scanning = false
stream.getTracks().forEach(track => track.stop())
document.body.removeChild(scannerContainer)
const styleEl = document.getElementById('scanner-style')
if (styleEl) {
document.head.removeChild(styleEl)
}
isScanning.value = false
scanStatus.value = {
type: 'error',
message: '摄像头访问失败'
}
}
} catch (error) {
console.error('扫码失败:', error)
isScanning.value = false
let errorMessage = '扫码失败'
if (error.name === 'NotAllowedError') {
errorMessage = '摄像头权限被拒绝,请在浏览器设置中开启权限'
} else if (error.name === 'NotFoundError') {
errorMessage = '未找到摄像头设备'
} else if (error.name === 'NotReadableError') {
errorMessage = '摄像头被其他应用占用'
} else if (error.name === 'OverconstrainedError') {
errorMessage = '摄像头配置不满足要求'
} else if (error.name === 'SecurityError') {
errorMessage = '需要在 HTTPS 或 localhost 环境下使用摄像头'
} else {
errorMessage = error.message || '未知错误'
}
scanStatus.value = {
type: 'error',
message: errorMessage
}
// 3
setTimeout(() => {
scanStatus.value = null
}, 3000)
//
setTimeout(() => {
showManualDialog.value = true
}, 1000)
}
}
//
const simulateScan = async (deviceId) => {
console.log('模拟扫码:', deviceId)
//
resetScan()
//
scanStatus.value = {
type: 'info',
message: '模拟扫码中...'
}
setTimeout(async () => {
await loadDeviceInfo(deviceId)
}, 100)
scanStatus.value = null
}, 500)
}
// 线
//
const loadDeviceInfo = async (terminalId) => {
try {
const result = await deviceService.getTerminalInfo(terminalId)
@ -354,30 +672,37 @@ const loadDeviceInfo = async (terminalId) => {
deviceInfo.value = {
id: terminalId,
name: data.terminalName || mockDevices[terminalId]?.name || '饮水机',
deviceId: data.deviceId || mockDevices[terminalId]?.deviceId,
status: 'online', // 线
name: data.terminalName || '饮水机',
deviceId: data.deviceId || '未知',
status: 'online',
statusText: '在线',
location: data.location || mockDevices[terminalId]?.location || '',
location: data.location || '未知位置',
waterQuality: {
tapWater: data.rawWaterTds || mockDevices[terminalId]?.waterQuality.tapWater || '--',
pureWater: data.pureWaterTds || mockDevices[terminalId]?.waterQuality.pureWater || '--'
tapWater: data.rawWaterTds || '--',
pureWater: data.pureWaterTds || '--'
}
}
} else {
// 使线
deviceInfo.value = {
...mockDevices[terminalId],
// 使
deviceInfo.value = mockDevices[terminalId] || {
id: terminalId,
name: `${terminalId}饮水机`,
deviceId: `WM${terminalId.slice(-3)}`,
status: 'online',
statusText: '在线'
statusText: '在线',
location: '校园内',
waterQuality: {
tapWater: '285',
pureWater: '15'
}
}
}
} catch (error) {
console.error('获取设备信息失败,使用模拟数据:', error)
// 使线
console.error('获取设备信息失败:', error)
deviceInfo.value = mockDevices[terminalId] || {
id: terminalId,
name: `${terminalId}饮水机`,
deviceId: `WM${terminalId.slice(-3)}`,
status: 'online',
statusText: '在线',
location: '未知位置',
@ -387,6 +712,8 @@ const loadDeviceInfo = async (terminalId) => {
}
}
}
isScanning.value = false
}
//
@ -407,7 +734,7 @@ const useCustomAmount = () => {
}
}
//
//
const confirmWater = async () => {
if (!selectedAmount.value) {
alert('请选择取水量')
@ -473,7 +800,6 @@ const startWaterProcess = async () => {
// API
const callWaterUsageAPI = async () => {
//
console.log('调用取水API水量:', selectedAmount.value)
try {
@ -502,14 +828,12 @@ const callWaterUsageAPI = async () => {
const completeWaterProcess = async () => {
isProcessing.value = false
showResult(
'success',
'取水成功',
`您已成功取水 ${selectedAmount.value}ml`
)
setTimeout(() => {
resetScan()
showResultDialog.value = false
@ -530,10 +854,8 @@ const closeResultDialog = () => {
resetScan()
}
///
//
const recordWaterHistory = (data) => {
//
if (!deviceInfo.value || !deviceInfo.value.id) {
console.log('设备信息不完整,不保存历史记录')
return
@ -550,45 +872,11 @@ const recordWaterHistory = (data) => {
location: deviceInfo.value.location || ''
}
//
const existingHistory = JSON.parse(localStorage.getItem('waterHistory') || '[]')
// 5
const now = new Date()
const fiveSecondsAgo = new Date(now.getTime() - 5000) // 5
const duplicateIndex = existingHistory.findIndex(record => {
const recordTime = new Date(record.timestamp)
return (
record.deviceId === deviceInfo.value.id &&
recordTime >= fiveSecondsAgo &&
recordTime <= now
)
})
if (duplicateIndex !== -1) {
// 5
console.log('发现重复记录,替换而不是新增')
existingHistory[duplicateIndex] = history
} else {
//
console.log('保存新记录')
existingHistory.unshift(history)
}
// terminalID
const deviceRecords = {}
const finalHistory = existingHistory.filter(record => {
if (!deviceRecords[record.deviceId]) {
deviceRecords[record.deviceId] = true
return true
}
return false
})
existingHistory.unshift(history)
//
const limitedHistory = finalHistory.slice(0, 20)
const limitedHistory = existingHistory.slice(0, 20)
localStorage.setItem('waterHistory', JSON.stringify(limitedHistory))
console.log('更新后的历史记录:', limitedHistory)
}
@ -629,13 +917,13 @@ const submitManualInput = async () => {
closeManualDialog()
}
//
//
const resetScan = () => {
deviceInfo.value = null
selectedAmount.value = null
customAmount.value = ''
isProcessing.value = false
progress.value = 0
isScanning.value = false
scanStatus.value = null
}
//
@ -660,6 +948,12 @@ const goToPage = (page) => {
break
}
}
onMounted(() => {
console.log('扫码页面已加载')
console.log('摄像头支持:', checkCameraSupport())
console.log('开发环境:', isDevelopment.value)
})
</script>
<style scoped>
@ -733,6 +1027,11 @@ const goToPage = (page) => {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
/* 扫描框容器 */
.scan-frame-container {
margin-top: 20px;
}
/* 扫描框 */
.scan-frame {
width: 250px;
@ -825,6 +1124,51 @@ const goToPage = (page) => {
color: #666;
}
/* 扫码状态提示 */
.scan-status {
padding: 10px 15px;
margin-bottom: 15px;
border-radius: 8px;
font-size: 14px;
text-align: center;
animation: fadeIn 0.3s ease;
}
.scan-status.error {
background-color: #ffebee;
color: #c62828;
border: 1px solid #ffcdd2;
}
.scan-status.warning {
background-color: #fff3e0;
color: #ef6c00;
border: 1px solid #ffe0b2;
}
.scan-status.info {
background-color: #e3f2fd;
color: #1565c0;
border: 1px solid #bbdefb;
}
.scan-status.success {
background-color: #e8f5e9;
color: #2e7d32;
border: 1px solid #c8e6c9;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 开发调试按钮 */
.dev-options {
margin-top: 16px;
@ -852,6 +1196,48 @@ const goToPage = (page) => {
color: white;
}
/* 扫码按钮容器 */
.scan-button-container {
display: flex;
justify-content: center;
margin-bottom: 30px;
}
.scan-btn {
display: flex;
flex-direction: column;
align-items: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 50px;
padding: 20px 30px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
transition: transform 0.2s, box-shadow 0.2s;
}
.scan-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25);
}
.scan-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.scan-icon {
font-size: 24px;
margin-bottom: 5px;
}
.scan-text {
font-size: 14px;
}
/* 手动输入 */
.manual-input {
background: white;
@ -1308,7 +1694,7 @@ const goToPage = (page) => {
/* 手动输入弹窗 */
.dialog-overlay {
position: absolute;
position: fixed;
top: 0;
left: 0;
right: 0;
@ -1317,15 +1703,10 @@ const goToPage = (page) => {
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
z-index: 1000;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.dialog-content {
background: white;
border-radius: 16px;
@ -1384,7 +1765,6 @@ const goToPage = (page) => {
border: 1px solid #e8e8e8;
border-radius: 6px;
font-size: 14px;
margin-bottom: 8px;
}
.device-input:focus {

Loading…
Cancel
Save