Files
device-voice-h5/pages/login/login.vue
sexygoat 63af194ec5
All checks were successful
构建并部署前端到生产环境 / build-and-deploy (push) Successful in 1m25s
feat: 登录扫一扫
2026-04-16 17:47:27 +08:00

592 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="login-page">
<view class="content-wrapper">
<view class="content">
<view class="header">
<view class="main-title">欢迎登录</view>
<view class="sub-title">请输入标识进行登录</view>
</view>
<view class="form-section">
<view class="input-wrap" :class="{ focus: inputFocus }">
<input
v-model="identifier"
class="input"
placeholder="请输入标识"
placeholder-class="placeholder"
@focus="inputFocus = true"
@blur="handleBlur"
@input="showError = false"
/>
<image
v-if="identifier && inputFocus"
class="clear-icon"
src="/static/clear.png"
mode="aspectFit"
@mousedown.prevent="clearInput"
@touchstart.prevent="clearInput"
></image>
</view>
<text v-if="showError" class="error">请输入标识</text>
</view>
<view class="btn-section">
<button
class="btn-login"
:class="{ disabled: !identifier || loading || !agreed }"
:disabled="!identifier || loading || !agreed"
@tap="handleLogin"
>
<view v-if="loading" class="loading">
<view class="dot"/>
<view class="dot"/>
<view class="dot"/>
</view>
<text v-else>立即登录</text>
</button>
<button class="btn-scan" @tap="handleScanLogin">
<image src="/static/scan-code.png" mode="aspectFit" class="scan-icon"></image>
<text>扫一扫登录</text>
</button>
</view>
<view class="agreement">
<view class="agreement-text">
<text class="text">登录即表示同意</text>
<text class="link">用户协议</text>
<text class="text"></text>
<text class="link">隐私政策</text>
</view>
</view>
</view>
</view>
<view v-if="showScanner" class="scanner-overlay">
<view class="scanner-container">
<view class="scanner-header">
<text>扫一扫</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>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { authApi } from '@/api/index.js';
import { useUserStore } from '@/store/index.js';
import { APP_ID, USE_WECHAT_AUTH } from '@/utils/env.js';
import jsQR from 'jsqr';
const userStore = useUserStore();
onMounted(() => {
const token = uni.getStorageSync('token');
if (token) {
uni.reLaunch({ url: '/pages/index/index' });
}
handleWechatCallback();
getPathDeviceId();
});
const identifier = ref('');
const loading = ref(false);
const agreed = ref(true);
const inputFocus = ref(false);
const showError = ref(false);
const showScanner = ref(false);
const getCode = () => {
const params = new URLSearchParams(window.location.search);
return params.get('code');
};
const clearCode = () => {
window.history.replaceState({}, '', window.location.pathname);
};
const redirectToWxAuth = (assetToken) => {
sessionStorage.setItem('assetToken', assetToken);
const redirectUri = encodeURIComponent('https://cmp-c.boss160.cn/');
const url = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${APP_ID}&redirect_uri=${redirectUri}&response_type=code&scope=snsapi_userinfo&state=wechat_auth#wechat_redirect`;
window.location.href = url;
};
const clearInput = () => {
identifier.value = '';
showError.value = false;
// 保持焦点状态
inputFocus.value = true;
};
const handleBlur = () => {
// 延迟失焦,给清除按钮点击留出时间
setTimeout(() => {
inputFocus.value = false;
}, 200);
};
const handleLogin = () => {
if (!identifier.value) {
showError.value = true;
return;
}
doLogin();
};
const handleScanLogin = async () => {
showScanner.value = true;
};
const onFileSelected = async (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
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]) {
identifier.value = match[1];
closeScanner();
handleLogin();
} else {
uni.showToast({ title: '无效的二维码', icon: 'none' });
}
} else {
uni.showToast({ title: '无法识别二维码', icon: 'none' });
}
};
img.src = event.target.result;
};
reader.readAsDataURL(file);
};
const closeScanner = async () => {
showScanner.value = false;
document.getElementById('file-input').value = '';
};
const getPathDeviceId = () => {
const params = new URLSearchParams(window.location.search);
const deviceId = params.get('device_id');
if (deviceId) {
identifier.value = deviceId;
handleLogin();
}
};
const doLogin = async () => {
loading.value = true;
try {
const verifyData = await authApi.verifyAsset(identifier.value);
userStore.setAssetToken(verifyData.asset_token);
userStore.setIdentifier(identifier.value);
if (!USE_WECHAT_AUTH) {
const loginData = await authApi.wechatLogin(verifyData.asset_token, 'mock_code');
userStore.setToken(loginData.token);
uni.setStorageSync('identifier', userStore.state.identifier);
// 判断是否需要绑定手机号
if (loginData.need_bind_phone) {
uni.redirectTo({ url: '/pages/bind/bind?fromLogin=true' });
} else {
uni.redirectTo({ url: '/pages/index/index' });
}
return;
}
redirectToWxAuth(verifyData.asset_token);
} catch (e) {
console.error('登录失败', e);
loading.value = false;
}
};
const handleWechatCallback = async () => {
if (!USE_WECHAT_AUTH) return;
const code = getCode();
const assetToken = sessionStorage.getItem('assetToken');
if (!code || !assetToken) return;
loading.value = true;
clearCode();
try {
const loginData = await authApi.wechatLogin(assetToken, code);
userStore.setToken(loginData.token);
uni.setStorageSync('identifier', userStore.state.identifier);
// H5 环境获取微信用户信息
// #ifdef H5
try {
// 通过 uni.getUserProfile 获取用户信息(需要用户点击授权)
// 注意:这个方法在微信公众号 H5 中不可用,需要通过后端接口获取
// 这里暂时保存默认信息,实际应该由后端返回用户信息
if (loginData.user_info) {
userStore.setUserInfo({
avatar: loginData.user_info.avatar || '',
nickname: loginData.user_info.nickname || ''
});
}
} catch (err) {
console.log('获取用户信息失败', err);
}
// #endif
// #ifdef MP-WEIXIN
// 小程序环境可以直接获取用户信息
try {
const { userInfo } = await uni.getUserProfile({
desc: '用于完善用户资料'
});
userStore.setUserInfo({
avatar: userInfo.avatarUrl || '',
nickname: userInfo.nickName || ''
});
} catch (err) {
console.log('用户取消授权');
}
// #endif
// 判断是否需要绑定手机号
if (loginData.need_bind_phone) {
// 需要绑定手机号,跳转到绑定页面
uni.redirectTo({ url: '/pages/bind/bind?fromLogin=true' });
} else {
// 不需要绑定,直接进入首页
uni.redirectTo({ url: '/pages/index/index' });
}
} catch (e) {
console.error('微信登录失败', e);
loading.value = false;
}
};
onMounted(() => {
handleWechatCallback();
});
</script>
<style lang="scss" scoped>
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: var(--bg-secondary);
max-width: 750rpx;
margin: 0 auto;
width: 100%;
padding: 40rpx;
box-sizing: border-box;
}
.content-wrapper {
width: 100%;
}
.content {
background: var(--bg-primary);
border-radius: var(--radius-large);
padding: 60rpx 40rpx;
box-shadow: var(--shadow-medium);
}
.header {
text-align: center;
margin-bottom: 60rpx;
}
.main-title {
font-size: 48rpx;
font-weight: 700;
color: var(--text-primary);
line-height: 1.3;
margin-bottom: 16rpx;
letter-spacing: -0.5rpx;
}
.sub-title {
font-size: 26rpx;
color: var(--text-tertiary);
line-height: 1.5;
}
.form-section {
margin-bottom: 48rpx;
}
.input-wrap {
display: flex;
align-items: center;
gap: 16rpx;
background: var(--bg-secondary);
border: 2rpx solid var(--border-color);
border-radius: var(--radius-medium);
padding: 28rpx 32rpx;
transition: all 0.3s ease;
}
.input-wrap.focus {
background: var(--bg-primary);
border-color: var(--primary);
box-shadow: 0 0 0 4rpx rgba(10, 132, 255, 0.1);
}
.input {
flex: 1;
font-size: 30rpx;
color: var(--text-primary);
background: transparent;
}
.placeholder {
color: var(--text-quaternary);
}
.clear-icon {
width: 32rpx;
height: 32rpx;
opacity: 0.4;
transition: opacity 0.3s ease;
cursor: pointer;
flex-shrink: 0;
}
.clear-icon:active {
opacity: 0.6;
}
.error {
font-size: 24rpx;
color: var(--danger);
margin-top: 16rpx;
padding-left: 8rpx;
}
.btn-section {
margin-bottom: 48rpx;
}
.btn-login {
width: 100%;
height: 96rpx;
background: var(--primary);
border: none;
border-radius: var(--radius-medium);
font-size: 32rpx;
font-weight: 600;
color: var(--text-inverse);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
box-shadow: 0 4rpx 12rpx rgba(10, 132, 255, 0.2);
}
.btn-login.disabled {
background: var(--gray-300);
color: var(--text-quaternary);
box-shadow: none;
}
.btn-login:active:not(.disabled) {
transform: translateY(2rpx);
box-shadow: 0 2rpx 8rpx rgba(10, 132, 255, 0.2);
}
.btn-scan {
width: 100%;
height: 88rpx;
background: var(--bg-primary);
border: 2rpx solid var(--primary);
border-radius: var(--radius-medium);
font-size: 30rpx;
font-weight: 500;
color: var(--primary);
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
margin-top: 24rpx;
transition: all 0.3s ease;
}
.btn-scan:active {
background: rgba(10, 132, 255, 0.05);
}
.scan-icon {
width: 40rpx;
height: 40rpx;
}
.loading {
display: flex;
align-items: center;
gap: 12rpx;
}
.dot {
width: 12rpx;
height: 12rpx;
background: var(--text-inverse);
border-radius: 50%;
animation: load 1.4s infinite ease-in-out both;
}
.dot:nth-child(1) { animation-delay: -0.32s; }
.dot:nth-child(2) { animation-delay: -0.16s; }
@keyframes load {
0%, 80%, 100% {
transform: scale(0.5);
opacity: 0.3;
}
40% {
transform: scale(1);
opacity: 1;
}
}
.agreement {
display: flex;
align-items: center;
justify-content: center;
padding: 0 8rpx;
}
.agreement-text {
font-size: 24rpx;
line-height: 1.6;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 4rpx;
}
.text {
color: var(--text-tertiary);
white-space: nowrap;
}
.link {
color: var(--primary);
font-weight: 500;
white-space: nowrap;
}
.scanner-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
z-index: 999;
display: flex;
align-items: center;
justify-content: center;
}
.scanner-container {
width: 600rpx;
background: var(--bg-primary);
border-radius: var(--radius-large);
overflow: hidden;
}
.scanner-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx;
font-size: 32rpx;
font-weight: 600;
border-bottom: 1rpx solid var(--border-light);
}
.close-btn {
font-size: 48rpx;
color: var(--text-tertiary);
line-height: 1;
}
.scanner-region {
width: 100%;
height: 600rpx;
}
.scanner-tip {
padding: 24rpx;
text-align: center;
font-size: 26rpx;
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>