Files
device-voice-h5/pages/login/login.vue
sexygoat bd0bb149b8
Some checks failed
构建并部署前端到生产环境 / build-and-deploy (push) Failing after 43s
feat: 登录扫一扫
2026-04-16 17:31:46 +08:00

555 lines
12 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>
</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 id="scanner-region" class="scanner-region"></view>
<view class="scanner-tip">将二维码放入框内即可自动扫描</view>
</view>
</view>
</view>
</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>
</template>
<script setup>
import { ref, onMounted, onUnmounted } 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 { Html5Qrcode } from 'html5-qrcode';
const userStore = useUserStore();
onMounted(() => {
const token = uni.getStorageSync('token');
if (token) {
uni.reLaunch({ url: '/pages/index/index' });
}
handleWechatCallback();
getPathDeviceId();
});
onUnmounted(() => {
if (html5QrCode) {
html5QrCode.stop().catch(() => {});
}
});
const identifier = ref('');
const loading = ref(false);
const agreed = ref(true);
const inputFocus = ref(false);
const showError = ref(false);
const showScanner = ref(false);
let html5QrCode = null;
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;
try {
html5QrCode = new Html5Qrcode('scanner-region');
await html5QrCode.start(
{ facingMode: 'environment' },
{
fps: 10,
qrbox: { width: 250, height: 250 }
},
(decodedText) => {
const match = decodedText.match(/=([^=]+)$/);
if (match && match[1]) {
identifier.value = match[1];
closeScanner();
handleLogin();
} else {
uni.showToast({ title: '无效的二维码', icon: 'none' });
}
},
() => {}
);
} catch (err) {
console.error('扫码失败', err);
uni.showToast({ title: '无法访问摄像头', icon: 'none' });
closeScanner();
}
};
const closeScanner = async () => {
showScanner.value = false;
if (html5QrCode) {
try {
await html5QrCode.stop();
} catch (e) {}
html5QrCode = null;
}
};
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);
}
</style>