- Rename PascalCase component files to kebab-case (AnnouncementModal → announcement-modal, McGlass → mc-glass) - Replace any types with proper uni-app/WeChat callback types (RequestSuccessCallbackResult, LoginRes, ShowModalRes, etc.) - Add JSDoc documentation comments to all exported/important functions Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
367 lines
8.6 KiB
Vue
367 lines
8.6 KiB
Vue
<template>
|
||
<view class="login-page">
|
||
<!-- Brand area -->
|
||
<view class="brand" :class="{ 'brand--visible': showContent }">
|
||
<image class="brand-logo" src="/static/logo.png" mode="aspectFit" />
|
||
<text class="brand-name">爱上萍乡</text>
|
||
<text class="brand-desc">赣湘之间,发现旅途美好</text>
|
||
</view>
|
||
|
||
<!-- Actions: 2 buttons -->
|
||
<view class="actions" :class="{ 'actions--visible': showContent }">
|
||
<!-- 未同意条款:普通按钮,点击提示 -->
|
||
<button
|
||
v-if="!agreed"
|
||
class="btn-primary"
|
||
:class="{ 'btn-primary--disabled': loading }"
|
||
:disabled="loading"
|
||
@tap="showToast('请先阅读并同意服务条款和隐私政策')"
|
||
>
|
||
<view class="btn-primary__inner">
|
||
<view class="wx-icon" />
|
||
<text class="btn-primary__text">微信一键登录</text>
|
||
</view>
|
||
</button>
|
||
<!-- 已同意条款:微信授权按钮 -->
|
||
<button
|
||
v-else
|
||
class="btn-primary"
|
||
:class="{ 'btn-primary--disabled': loading }"
|
||
open-type="getPhoneNumber"
|
||
@getphonenumber="handleLogin"
|
||
:disabled="loading"
|
||
>
|
||
<view v-if="!loading" class="btn-primary__inner">
|
||
<view class="wx-icon" />
|
||
<text class="btn-primary__text">微信一键登录</text>
|
||
</view>
|
||
<view v-else class="btn-primary__inner">
|
||
<view class="loading-spinner" />
|
||
<text class="btn-primary__text">登录中...</text>
|
||
</view>
|
||
</button>
|
||
|
||
<button class="btn-cancel" @tap="handleCancel">
|
||
<text class="btn-cancel__text">取消</text>
|
||
</button>
|
||
</view>
|
||
|
||
<!-- Agreement: checkbox + links -->
|
||
<view class="agreement" :class="{ 'agreement--visible': showContent }">
|
||
<view
|
||
class="checkbox"
|
||
:class="{ 'checkbox--checked': agreed }"
|
||
@tap="agreed = !agreed"
|
||
>
|
||
<view v-if="agreed" class="checkbox__check" />
|
||
</view>
|
||
<view class="agreement__text">
|
||
<text class="agreement__label">我已阅读并同意</text>
|
||
<text class="agreement__link" @tap="openAgreement">《服务条款》</text>
|
||
<text class="agreement__link" @tap="openPrivacy">《隐私政策》</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, onMounted } from 'vue'
|
||
import { useUserStore } from '../../store/user'
|
||
import { showToast } from '../../utils'
|
||
|
||
const userStore = useUserStore()
|
||
const agreed = ref(false)
|
||
const loading = ref(false)
|
||
const showContent = ref(false)
|
||
|
||
onMounted(() => {
|
||
setTimeout(() => {
|
||
showContent.value = true
|
||
}, 80)
|
||
})
|
||
|
||
/** 取消登录,返回上一页或首页 */
|
||
function handleCancel() {
|
||
uni.navigateBack({ fail: () => uni.switchTab({ url: '/pages/index/index' }) })
|
||
}
|
||
|
||
/** 打开服务条款 */
|
||
function openAgreement() {
|
||
uni.navigateTo({ url: '/pages/login/agreement' })
|
||
}
|
||
|
||
/** 打开隐私政策 */
|
||
function openPrivacy() {
|
||
uni.navigateTo({ url: '/pages/login/privacy' })
|
||
}
|
||
|
||
/** 微信一键登录(获取手机号后调后端登录接口) */
|
||
async function handleLogin(e: { detail: { errMsg: string; code?: string } }) {
|
||
if (e.detail.errMsg !== 'getPhoneNumber:ok') {
|
||
return
|
||
}
|
||
|
||
loading.value = true
|
||
try {
|
||
const phoneCode = e.detail.code
|
||
if (!phoneCode) {
|
||
showToast('手机号获取失败,请重试')
|
||
return
|
||
}
|
||
|
||
const loginRes = await new Promise<UniNamespace.LoginRes>((resolve, reject) => {
|
||
uni.login({ provider: 'weixin', success: resolve, fail: reject })
|
||
})
|
||
|
||
if (!loginRes || !loginRes.code) {
|
||
showToast('微信登录失败,请重试')
|
||
return
|
||
}
|
||
|
||
await userStore.wxLogin({
|
||
loginCode: loginRes.code,
|
||
phoneCode: phoneCode
|
||
})
|
||
|
||
showToast('登录成功', 'success')
|
||
setTimeout(() => {
|
||
uni.switchTab({ url: '/pages/index/index' })
|
||
}, 1000)
|
||
} catch (err: unknown) {
|
||
showToast(err instanceof Error ? err.message : '登录失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
/* Lucide icons — data URI pattern (matches scenic-detail) */
|
||
$icon-size: 40rpx;
|
||
$icon-wechat: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7.9 20A9 9 0 1 0 4 16.1L2 22Z'/%3E%3C/svg%3E");
|
||
$icon-check: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E");
|
||
|
||
.login-page {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 100vh;
|
||
background: #FFFFFF;
|
||
padding: 0 48rpx;
|
||
gap: 48rpx;
|
||
overflow-x: hidden;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
/* ========== Brand ========== */
|
||
.brand {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
opacity: 0;
|
||
transform: translateY(20rpx);
|
||
transition: opacity 0.6s ease, transform 0.6s ease;
|
||
}
|
||
|
||
.brand--visible {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
|
||
.brand-logo {
|
||
width: 160rpx;
|
||
height: 160rpx;
|
||
margin-bottom: 24rpx;
|
||
}
|
||
|
||
.brand-name {
|
||
font-family: $font-family-display;
|
||
font-size: $font-size-title;
|
||
font-weight: $font-weight-bold;
|
||
color: $uv-main-color;
|
||
line-height: $line-height-tight;
|
||
letter-spacing: 4rpx;
|
||
margin-bottom: 24rpx;
|
||
}
|
||
|
||
.brand-desc {
|
||
font-size: $font-size-base;
|
||
color: $uv-tips-color;
|
||
font-weight: $font-weight-normal;
|
||
letter-spacing: 2rpx;
|
||
}
|
||
|
||
/* ========== Actions ========== */
|
||
.actions {
|
||
width: 100%;
|
||
opacity: 0;
|
||
transform: translateY(20rpx);
|
||
transition: opacity 0.6s ease 0.1s, transform 0.6s ease 0.1s;
|
||
}
|
||
|
||
.actions--visible {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
|
||
.btn-primary {
|
||
width: 100%;
|
||
height: 92rpx;
|
||
border-radius: $radius-lg;
|
||
background: $uv-primary;
|
||
border: none;
|
||
padding: 0;
|
||
margin: 0 0 24rpx 0;
|
||
line-height: normal;
|
||
box-shadow: 0 6rpx 20rpx rgba(60, 156, 255, 0.28);
|
||
transition: all $transition-base;
|
||
|
||
&::after {
|
||
display: none;
|
||
}
|
||
}
|
||
|
||
.btn-primary--disabled {
|
||
opacity: 0.6;
|
||
}
|
||
|
||
.btn-primary:active:not(.btn-primary--disabled) {
|
||
transform: scale(0.98);
|
||
box-shadow: 0 2rpx 8rpx rgba(60, 156, 255, 0.2);
|
||
}
|
||
|
||
.btn-primary__inner {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 92rpx;
|
||
}
|
||
|
||
.btn-primary__text {
|
||
font-size: $font-size-base;
|
||
font-weight: $font-weight-medium;
|
||
color: #FFFFFF;
|
||
margin-left: $spacing-2;
|
||
letter-spacing: 2rpx;
|
||
}
|
||
|
||
.wx-icon {
|
||
width: $icon-size;
|
||
height: $icon-size;
|
||
background-image: $icon-wechat;
|
||
background-size: contain;
|
||
background-repeat: no-repeat;
|
||
background-position: center;
|
||
}
|
||
|
||
.loading-spinner {
|
||
width: 36rpx;
|
||
height: 36rpx;
|
||
border: 4rpx solid rgba(255, 255, 255, 0.3);
|
||
border-top-color: #FFFFFF;
|
||
border-radius: 50%;
|
||
animation: spin 0.8s linear infinite;
|
||
}
|
||
|
||
@keyframes spin {
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
|
||
.btn-cancel {
|
||
width: 100%;
|
||
height: 92rpx;
|
||
border-radius: $radius-lg;
|
||
background: #FFFFFF;
|
||
border: 2rpx solid $uv-border-color;
|
||
padding: 0;
|
||
margin: 0;
|
||
line-height: 92rpx;
|
||
text-align: center;
|
||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
|
||
transition: all $transition-base;
|
||
|
||
&::after {
|
||
display: none;
|
||
}
|
||
}
|
||
|
||
.btn-cancel:active {
|
||
transform: scale(0.98);
|
||
background: $uv-bg-color;
|
||
}
|
||
|
||
.btn-cancel__text {
|
||
display: block;
|
||
width: 100%;
|
||
font-size: $font-size-base;
|
||
font-weight: $font-weight-medium;
|
||
color: $uv-content-color;
|
||
text-align: center;
|
||
}
|
||
|
||
/* ========== Agreement ========== */
|
||
.agreement {
|
||
width: 100%;
|
||
display: flex;
|
||
align-items: flex-start;
|
||
opacity: 0;
|
||
transform: translateY(20rpx);
|
||
transition: opacity 0.6s ease 0.2s, transform 0.6s ease 0.2s;
|
||
}
|
||
|
||
.agreement--visible {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
|
||
.checkbox {
|
||
flex-shrink: 0;
|
||
width: 40rpx;
|
||
height: 40rpx;
|
||
border: 3rpx solid $uv-border-color;
|
||
border-radius: 10rpx;
|
||
margin-right: $spacing-2;
|
||
margin-top: 2rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all $transition-base;
|
||
}
|
||
|
||
.checkbox--checked {
|
||
background: $uv-primary;
|
||
border-color: $uv-primary;
|
||
}
|
||
|
||
.checkbox__check {
|
||
width: 24rpx;
|
||
height: 24rpx;
|
||
background-image: $icon-check;
|
||
background-size: contain;
|
||
background-repeat: no-repeat;
|
||
background-position: center;
|
||
}
|
||
|
||
.agreement__text {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
line-height: $line-height-normal;
|
||
}
|
||
|
||
.agreement__label {
|
||
font-size: $font-size-base;
|
||
color: $uv-content-color;
|
||
}
|
||
|
||
.agreement__link {
|
||
font-size: $font-size-base;
|
||
color: $uv-primary;
|
||
|
||
&:active {
|
||
opacity: 0.7;
|
||
}
|
||
}
|
||
</style>
|