文件
fenleixinxi/uniapp/pages/auth/login.vue
MiAn d508949921 feat: 规范化页面导航与交互体验,优化多端适配
- 统一定制顶部导航组件,支持沉浸式渐变主题与返回逻辑
- 适配 H5 环境的页面跳转逻辑,避免 reLaunch 导致的闪屏问题
- 重构会员、充值、发布等页面布局,改用 Grid 提升移动端适配
- 优化搜索、收藏、消息等页面交互细节,增加点击反馈
- 统一配置页面导航样式,替换原生导航为自定义组件
2026-10-20 13:56:05 +08:00

474 行
13 KiB
Vue

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

<template>
<view class="login">
<view class="login__bg"></view>
<!-- 规范化顶部 Topbar:自适应系统状态栏与微信胶囊避让,提供返回键 -->
<fc-navbar title="" theme="gradient" :show-back="true" />
<view class="login__brand">
<image class="fc-logo fc-logo--lg" :src="logoUrl()" mode="aspectFit" />
<text class="login__title">{{ siteName }}</text>
<text class="login__slogan">招聘 · 房产 · 二手 · 本地服务,一站直达</text>
</view>
<view class="login__panel">
<text class="login__panel-title">选择身份</text>
<view class="login__types">
<view class="login__type" :class="{ 'login__type--active': userType === 1 }" @click="userType = 1">
<image class="fc-icon--badge-sm" :src="iconUrl('login-person')" mode="aspectFit" />
<text class="login__type-name">个人用户</text>
<text class="login__type-desc">发布闲置 / 查看信息</text>
</view>
<view class="login__type" :class="{ 'login__type--active': userType === 2 }" @click="userType = 2">
<image class="fc-icon--badge-sm" :src="iconUrl('login-shop')" mode="aspectFit" />
<text class="login__type-name">商家用户</text>
<text class="login__type-desc">需营业执照资质认证</text>
</view>
</view>
<view v-if="userType === 2" class="login__license">
<view class="login__license-box" @click="chooseLicense">
<text v-if="!license">+ 上传营业执照</text>
<text v-else class="login__license-file">已选择:{{ license }}</text>
</view>
<text class="login__license-tip">提交后由后台人工审核,审核期间可正常浏览</text>
</view>
<!-- 微信资料(可选):头像用官方 chooseAvatar,昵称用官方 nickname 输入组件 -->
<view class="login__profile">
<button class="login__avatar" open-type="chooseAvatar" @chooseavatar="onChooseAvatar">
<image v-if="avatarUrl" class="login__avatar-img" :src="avatarUrl" mode="aspectFill" />
<text v-else class="login__avatar-ph">+</text>
</button>
<view class="login__profile-fields">
<input class="login__nickname" type="nickname" v-model="nickname" placeholder="点击获取微信昵称"
placeholder-class="login__nickname-ph" />
<text class="login__profile-tip">同步您的微信头像与昵称(可跳过)</text>
</view>
</view>
<view class="login__btn" :class="{ 'login__btn--disabled': loading }" @click="onLogin">
<text>{{ loading ? '登录中…' : '微信一键登录' }}</text>
</view>
<!-- 手机号绑定:优先微信一键获取(open-type=getPhoneNumber),失败/不支持时回落手动输入 -->
<button class="login__phone" open-type="getPhoneNumber" @getphonenumber="onGetPhoneNumber">
<image class="fc-icon fc-icon--sm" :src="iconUrl('phonebind')" mode="aspectFit" />
<text class="login__phone-text">{{ phoneText }}</text>
</button>
<text class="login__agree">登录即代表同意《用户协议》与《隐私政策》</text>
</view>
</view>
</template>
<script>
import { useUserStore } from '@/common/store/index.js'
import authApi from '@/common/api/auth.js'
import postApi from '@/common/api/post.js'
import config from '@/common/config/index.js'
import { iconUrl, logoUrl, loadUiConfig, uiState } from '@/common/store/ui.js'
import FcNavbar from '@/components/fc-navbar/fc-navbar.vue'
export default {
components: { FcNavbar },
data() {
return {
statusBarHeight: 20,
userType: config.constants.USER_TYPE.PERSON,
// license 仅用于展示文件名;licenseTemp 保存本地临时路径,登录拿到 token 后再上传
license: '',
licenseTemp: '',
loading: false,
// 微信资料(可选):昵称 + 头像临时路径(登录拿到 token 后再上传落库)
nickname: '',
avatarUrl: '',
pendingAvatar: '',
}
},
computed: {
siteName() {
return uiState.siteName || '同城分类信息'
},
userInfo() {
try {
return useUserStore().state.userInfo || {}
} catch (e) {
return {}
}
},
phoneText() {
const phone = this.userInfo.phone || ''
return phone ? '已绑定 ' + this.maskPhone(phone) : '手机号授权绑定'
},
},
onLoad() {
try {
this.statusBarHeight = uni.getSystemInfoSync().statusBarHeight || 20
} catch (e) {}
loadUiConfig()
},
methods: {
iconUrl,
logoUrl,
chooseLicense() {
uni.chooseImage({
count: 1,
success: (res) => {
const f = (res.tempFilePaths && res.tempFilePaths[0]) || ''
if (!f) return
// 保存本地临时路径,登录后上传为持久化 URL;显示仅取文件名
this.licenseTemp = f
this.license = String(f).split('/').pop() || '营业执照'
uni.showToast({ title: '已选择资质文件', icon: 'none' })
},
// 用户取消选择:不伪造文件名(原先会写入假的「营业执照.jpg」,可绕过资质校验)
fail: () => {},
})
},
async onLogin() {
if (this.loading) return
if (this.userType === config.constants.USER_TYPE.BUSINESS && !this.licenseTemp && !this.license) {
uni.showToast({ title: '商家用户请先上传营业执照', icon: 'none' })
return
}
this.loading = true
const store = useUserStore()
try {
const wxCode = await this.getWxCode()
// 登录时带上微信昵称(头像需先登录拿 token 再上传,见下方)
await store.login({ code: wxCode, nickname: this.nickname || undefined })
// 头像:chooseAvatar 拿到的是本地临时文件,登录后上传到服务端并写回资料
if (this.pendingAvatar) {
try {
const up = await authApi.uploadAvatar(this.pendingAvatar)
if (up && up.avatar) await authApi.saveProfile({ avatar: up.avatar })
} catch (e) {
// 头像上传失败不影响登录
}
}
if (this.nickname || this.pendingAvatar) {
await store.fetchProfile()
}
// 商家营业执照:登录拿到 token 后把本地图片上传为可持久化 URL,再提交资质认证
let license = this.license
if (this.userType === config.constants.USER_TYPE.BUSINESS && this.licenseTemp) {
const up = await postApi.uploadMedia(this.licenseTemp, 'image')
license = (up && up.url) || ''
if (!license) throw new Error('营业执照上传失败,请重试')
}
const res = await store.switchType(this.userType, license)
uni.showToast({ title: res.needAudit ? '已提交资质,等待审核' : '登录成功', icon: 'none' })
setTimeout(() => {
const pages = (typeof getCurrentPages === 'function') ? getCurrentPages() : []
if (pages && pages.length > 1) {
uni.navigateBack()
} else {
// #ifdef H5
uni.redirectTo({
url: '/pages/mine/index',
fail: () => uni.reLaunch({ url: '/pages/mine/index' }),
})
// #endif
// #ifndef H5
uni.reLaunch({ url: '/pages/mine/index' })
// #endif
}
}, 600)
} catch (e) {
uni.showToast({ title: e.message || '登录失败', icon: 'none' })
} finally {
this.loading = false
}
},
getWxCode() {
return new Promise((resolve, reject) => {
const fail = () => reject(new Error('微信授权失败,请重试'))
// #ifdef MP-WEIXIN
try {
if (typeof uni !== 'undefined' && uni.login) {
uni.login({
provider: 'weixin',
success: (r) => (r && r.code ? resolve(r.code) : fail()),
fail,
})
return
}
} catch (e) {}
// #endif
// #ifdef H5
// H5 浏览器端调试体验:独立分配每个客户端浏览器独享的测试 Openid
let devCode = ''
try {
devCode = uni.getStorageSync('fc_h5_openid')
} catch (e) {}
if (!devCode) {
devCode = 'openid_h5_' + Math.random().toString(36).slice(2, 10)
try {
uni.setStorageSync('fc_h5_openid', devCode)
} catch (e) {}
}
resolve(devCode)
return
// #endif
fail()
})
},
/** 选择微信头像(官方组件回调,返回的是本地临时文件) */
onChooseAvatar(e) {
const temp = (e && e.detail && e.detail.avatarUrl) || ''
if (!temp) return
this.pendingAvatar = temp
this.avatarUrl = temp // 本地预览
},
async onGetPhoneNumber(e) {
// e.detail.code:微信手机号快捷验证的临时凭证(有效期 5 分钟,一次性)
const code = (e && e.detail && e.detail.code) || ''
if (!code) {
// 用户点了「拒绝」或当前环境(H5/模拟器)不支持快捷获取 → 手动输入兜底
this.manualBindPhone('可手动输入手机号完成绑定')
return
}
try {
const res = await authApi.wxPhone({ code })
this.applyPhone(res.phone)
uni.showToast({ title: '手机号已绑定', icon: 'none' })
} catch (err) {
// 快捷验证未开通/额度用尽/code 失效等 → 手动输入兜底,保证仍能拿到号码
this.manualBindPhone(err && err.message)
}
},
/** 手动输入手机号绑定(真实落库,替代原先前端拼假号的做法) */
manualBindPhone(tip) {
uni.showModal({
title: '绑定手机号',
editable: true,
placeholderText: '请输入 11 位手机号',
content: tip || '',
success: async (r) => {
if (!r.confirm) return
const phone = String(r.content || '').trim()
if (!/^1\d{10}$/.test(phone)) {
uni.showToast({ title: '手机号格式不正确', icon: 'none' })
return
}
try {
const res = await authApi.bindPhone({ phone })
this.applyPhone(res.phone)
uni.showToast({ title: '手机号已绑定', icon: 'none' })
} catch (err) {
uni.showToast({ title: (err && err.message) || '绑定失败', icon: 'none' })
}
},
})
},
applyPhone(phone) {
const store = useUserStore()
store.state.userInfo = { ...(store.state.userInfo || {}), phone }
},
maskPhone(phone) {
return String(phone || '').replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2')
},
},
}
</script>
<style lang="scss" scoped>
.login {
min-height: 100vh;
position: relative;
}
.login__bg {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 620rpx;
background: $fc-gradient-hero;
border-radius: 0 0 60rpx 60rpx;
}
.login__brand {
position: relative;
padding: 40rpx 60rpx 50rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.login__title {
margin-top: 28rpx;
color: #ffffff;
font-size: 44rpx;
font-weight: 700;
letter-spacing: 2rpx;
}
.login__slogan {
margin-top: 12rpx;
color: rgba(255, 255, 255, 0.88);
font-size: $fc-font-sm;
}
.login__panel {
position: relative;
margin: 40rpx 40rpx 0;
background: #ffffff;
border-radius: 32rpx;
padding: 40rpx 32rpx;
box-shadow: $fc-shadow-lg;
}
.login__panel-title {
font-size: $fc-font-lg;
font-weight: 700;
color: $fc-text;
}
.login__types {
display: flex;
margin-top: 24rpx;
}
.login__type {
flex: 1;
border: 2rpx solid $fc-border;
border-radius: $fc-radius-lg;
padding: 24rpx 20rpx;
display: flex;
flex-direction: column;
background: $fc-bg;
&:first-child {
margin-right: 20rpx;
}
&--active {
border-color: $fc-primary-text;
background: $fc-primary-lighter;
}
}
.login__type-name {
margin-top: 16rpx;
font-size: $fc-font-lg;
font-weight: 700;
color: $fc-text;
}
.login__type-desc {
margin-top: 6rpx;
font-size: $fc-font-xs;
color: $fc-text-secondary;
}
.login__license {
margin-top: 24rpx;
}
.login__license-box {
height: 120rpx;
border: 2rpx dashed $fc-primary-text;
border-radius: $fc-radius;
background: $fc-bg;
display: flex;
align-items: center;
justify-content: center;
color: $fc-primary-text;
font-size: $fc-font;
}
.login__license-file {
color: $fc-success;
}
.login__license-tip {
display: block;
margin-top: 12rpx;
font-size: $fc-font-xs;
color: $fc-text-placeholder;
}
.login__btn {
margin-top: 40rpx;
height: 92rpx;
border-radius: $fc-radius-pill;
background: $fc-gradient-primary;
color: $fc-primary-ink;
font-size: 32rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 10rpx 28rpx rgba(4, 48, 30, 0.35);
&--disabled {
opacity: 0.7;
}
}
.login__profile {
margin-top: 32rpx;
padding: 24rpx;
border-radius: $fc-radius;
background: rgba(255, 255, 255, 0.78);
display: flex;
align-items: center;
}
.login__avatar {
width: 108rpx;
height: 108rpx;
padding: 0;
line-height: 1;
border-radius: 50%;
background: $fc-primary-lighter;
border: 2rpx solid $fc-primary-soft;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
/* 小程序 button 默认自带 ::after 边框,需清除 */
&::after {
border: none;
}
}
.login__avatar-img {
width: 108rpx;
height: 108rpx;
}
.login__avatar-ph {
font-size: 40rpx;
color: $fc-primary-text;
font-weight: 700;
}
.login__profile-fields {
flex: 1;
margin-left: 24rpx;
}
.login__nickname {
height: 60rpx;
font-size: $fc-font;
color: $fc-text;
border-bottom: 2rpx solid $fc-border;
}
.login__nickname-ph {
color: $fc-text-placeholder;
}
.login__profile-tip {
display: block;
margin-top: 10rpx;
font-size: 22rpx;
color: $fc-text-secondary;
}
.login__phone {
margin-top: 24rpx;
height: 88rpx;
line-height: 1;
border-radius: $fc-radius-pill;
border: 2rpx solid $fc-primary-text;
color: $fc-primary-text;
background: transparent;
display: flex;
align-items: center;
justify-content: center;
font-size: $fc-font;
/* 小程序 button 默认自带 ::after 边框,需清除以保证描边样式一致 */
&::after {
border: none;
}
}
.login__phone-text {
margin-left: 12rpx;
}
.login__agree {
display: block;
margin-top: 28rpx;
text-align: center;
font-size: $fc-font-xs;
color: $fc-text-placeholder;
}
</style>