- 统一定制顶部导航组件,支持沉浸式渐变主题与返回逻辑 - 适配 H5 环境的页面跳转逻辑,避免 reLaunch 导致的闪屏问题 - 重构会员、充值、发布等页面布局,改用 Grid 提升移动端适配 - 优化搜索、收藏、消息等页面交互细节,增加点击反馈 - 统一配置页面导航样式,替换原生导航为自定义组件
474 行
13 KiB
Vue
474 行
13 KiB
Vue
<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>
|
||
|