feat: 登录扫一扫
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 1m25s

This commit is contained in:
sexygoat
2026-04-16 17:47:27 +08:00
parent 6d327c41cd
commit 63af194ec5
4 changed files with 95 additions and 44 deletions

View File

@@ -18,7 +18,8 @@
}, },
/* */ /* */
"modules" : { "modules" : {
"Barcode" : {} "Barcode" : {},
"Camera" : {}
}, },
/* */ /* */
"distribute" : { "distribute" : {

7
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"clipboard": "^2.0.11", "clipboard": "^2.0.11",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"html5-qrcode": "^2.3.8", "html5-qrcode": "^2.3.8",
"jsqr": "^1.4.0",
"uview-plus": "^3.6.29", "uview-plus": "^3.6.29",
"vue": "^3.4.0" "vue": "^3.4.0"
}, },
@@ -5103,6 +5104,12 @@
"graceful-fs": "^4.1.6" "graceful-fs": "^4.1.6"
} }
}, },
"node_modules/jsqr": {
"version": "1.4.0",
"resolved": "https://registry.npmmirror.com/jsqr/-/jsqr-1.4.0.tgz",
"integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==",
"license": "Apache-2.0"
},
"node_modules/lcid": { "node_modules/lcid": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmmirror.com/lcid/-/lcid-3.1.1.tgz", "resolved": "https://registry.npmmirror.com/lcid/-/lcid-3.1.1.tgz",

View File

@@ -16,10 +16,11 @@
"@dcloudio/uni-app": "3.0.0-4080720251210001", "@dcloudio/uni-app": "3.0.0-4080720251210001",
"@dcloudio/uni-components": "3.0.0-4080720251210001", "@dcloudio/uni-components": "3.0.0-4080720251210001",
"@dcloudio/uni-h5": "3.0.0-4080720251210001", "@dcloudio/uni-h5": "3.0.0-4080720251210001",
"vue": "^3.4.0",
"clipboard": "^2.0.11", "clipboard": "^2.0.11",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"html5-qrcode": "^2.3.8", "html5-qrcode": "^2.3.8",
"uview-plus": "^3.6.29" "jsqr": "^1.4.0",
"uview-plus": "^3.6.29",
"vue": "^3.4.0"
} }
} }

View File

@@ -62,25 +62,32 @@
</view> </view>
</view> </view>
<view v-if="showScanner" class="scanner-overlay"> <view v-if="showScanner" class="scanner-overlay">
<view class="scanner-container"> <view class="scanner-container">
<view class="scanner-header"> <view class="scanner-header">
<text>扫一扫</text> <text>扫一扫</text>
<text class="close-btn" @tap="closeScanner">×</text> <text class="close-btn" @tap="closeScanner">×</text>
</view>
<view class="scanner-body">
<input type="file" accept="image/*" capture="environment" @change="onFileSelected" class="file-input" id="file-input" />
<view class="scan-prompt">
<image src="/static/scan-code.png" mode="aspectFit" class="scan-icon-large"></image>
<text class="tip">点击下方按钮选择相机拍照</text>
<label for="file-input" class="btn-take-photo">拍照/选择图片</label>
</view>
</view>
<view class="scanner-tip">将二维码放入框内即可自动扫描</view>
</view> </view>
<view id="scanner-region" class="scanner-region"></view>
<view class="scanner-tip">将二维码放入框内即可自动扫描</view>
</view> </view>
</view> </view>
</view>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onUnmounted } from 'vue'; import { ref, onMounted } from 'vue';
import { authApi } from '@/api/index.js'; import { authApi } from '@/api/index.js';
import { useUserStore } from '@/store/index.js'; import { useUserStore } from '@/store/index.js';
import { APP_ID, USE_WECHAT_AUTH } from '@/utils/env.js'; import { APP_ID, USE_WECHAT_AUTH } from '@/utils/env.js';
import { Html5Qrcode } from 'html5-qrcode'; import jsQR from 'jsqr';
const userStore = useUserStore(); const userStore = useUserStore();
@@ -93,19 +100,12 @@
getPathDeviceId(); getPathDeviceId();
}); });
onUnmounted(() => {
if (html5QrCode) {
html5QrCode.stop().catch(() => {});
}
});
const identifier = ref(''); const identifier = ref('');
const loading = ref(false); const loading = ref(false);
const agreed = ref(true); const agreed = ref(true);
const inputFocus = ref(false); const inputFocus = ref(false);
const showError = ref(false); const showError = ref(false);
const showScanner = ref(false); const showScanner = ref(false);
let html5QrCode = null;
const getCode = () => { const getCode = () => {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
@@ -147,17 +147,26 @@
const handleScanLogin = async () => { const handleScanLogin = async () => {
showScanner.value = true; showScanner.value = true;
};
try { const onFileSelected = async (e) => {
html5QrCode = new Html5Qrcode('scanner-region'); const file = e.target.files[0];
await html5QrCode.start( if (!file) return;
{ facingMode: 'environment' },
{ const reader = new FileReader();
fps: 10, reader.onload = (event) => {
qrbox: { width: 250, height: 250 } const img = new Image();
}, img.onload = () => {
(decodedText) => { const canvas = document.createElement('canvas');
const match = decodedText.match(/=([^=]+)$/); canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const code = jsQR(imageData.data, imageData.width, imageData.height);
if (code) {
const match = code.data.match(/=([^=]+)$/);
if (match && match[1]) { if (match && match[1]) {
identifier.value = match[1]; identifier.value = match[1];
closeScanner(); closeScanner();
@@ -165,24 +174,18 @@
} else { } else {
uni.showToast({ title: '无效的二维码', icon: 'none' }); uni.showToast({ title: '无效的二维码', icon: 'none' });
} }
}, } else {
() => {} uni.showToast({ title: '无法识别二维码', icon: 'none' });
); }
} catch (err) { };
console.error('扫码失败', err); img.src = event.target.result;
uni.showToast({ title: '无法访问摄像头', icon: 'none' }); };
closeScanner(); reader.readAsDataURL(file);
}
}; };
const closeScanner = async () => { const closeScanner = async () => {
showScanner.value = false; showScanner.value = false;
if (html5QrCode) { document.getElementById('file-input').value = '';
try {
await html5QrCode.stop();
} catch (e) {}
html5QrCode = null;
}
}; };
const getPathDeviceId = () => { const getPathDeviceId = () => {
@@ -546,4 +549,43 @@
font-size: 26rpx; font-size: 26rpx;
color: var(--text-tertiary); color: var(--text-tertiary);
} }
.scanner-body {
padding: 60rpx 40rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.file-input {
display: none;
}
.scan-prompt {
display: flex;
flex-direction: column;
align-items: center;
gap: 24rpx;
}
.scan-icon-large {
width: 160rpx;
height: 160rpx;
opacity: 0.6;
}
.tip {
font-size: 28rpx;
color: var(--text-tertiary);
}
.btn-take-photo {
padding: 24rpx 48rpx;
background: var(--primary);
color: var(--text-inverse);
border-radius: var(--radius-medium);
font-size: 30rpx;
font-weight: 500;
cursor: pointer;
}
</style> </style>