更新go结构和uniapp

This commit is contained in:
2026-07-15 22:38:33 +08:00
parent bca5170e22
commit 61a937f7a3
23 changed files with 3536 additions and 293 deletions
+481
View File
@@ -0,0 +1,481 @@
<template>
<view class="auth-page">
<view class="page-inner">
<view class="nav-back" @tap="goBack">
<FaIcon name="chevron-left" color="#303133" :size="18" />
<text class="nav-text">返回</text>
</view>
<view class="header">
<text class="title">忘记密码</text>
<text class="subtitle">
{{ currentStep === 1 ? '验证您的账号' : currentStep === 2 ? '通过手机号验证身份' : '设置新密码' }}
</text>
</view>
<view class="form-card">
<!-- 第一步验证租户和账号 -->
<view v-if="currentStep === 1" class="form">
<view class="field">
<u-input
v-model="form.tenant_name"
placeholder="租户名称"
border="none"
color="#303133"
:customStyle="inputStyle"
:placeholderStyle="placeholderStyle"
/>
</view>
<view class="field">
<u-input
v-model="form.account"
placeholder="账号"
border="none"
color="#303133"
:customStyle="inputStyle"
:placeholderStyle="placeholderStyle"
/>
</view>
<view class="btn-primary" :class="{ disabled: loading }" @tap="handleVerifyAccount">
{{ loading ? '验证中...' : '下一步' }}
</view>
</view>
<!-- 第二步验证手机号并发送验证码 -->
<view v-if="currentStep === 2" class="form">
<view class="info-box">
<text class="info-label">租户</text>
<text class="info-value">{{ form.tenant_name }}</text>
</view>
<view class="info-box">
<text class="info-label">账号</text>
<text class="info-value">{{ form.account }}</text>
</view>
<view v-if="verifyResult.phone" class="field">
<u-input
v-model="form.phone"
placeholder="手机号"
type="number"
maxlength="11"
border="none"
color="#303133"
:customStyle="inputStyle"
:placeholderStyle="placeholderStyle"
/>
</view>
<view v-else class="empty-contact">
<text>账号未绑定手机号请联系管理员</text>
</view>
<view class="field field-row">
<u-input
v-model="form.sms_code"
placeholder="短信验证码"
type="number"
maxlength="6"
border="none"
color="#303133"
:customStyle="inputStyle"
:placeholderStyle="placeholderStyle"
/>
<text
class="code-link"
:class="{ disabled: countdown > 0 || codeLoading || !verifyResult.phone }"
@tap="handleSendCode"
>{{ countdown > 0 ? `${countdown}s` : (codeLoading ? '发送中' : '获取验证码') }}</text>
</view>
<view class="btn-primary" :class="{ disabled: loading }" @tap="handleVerifyPhone">
{{ loading ? '验证中...' : '下一步' }}
</view>
<view class="action-row">
<text class="link-text" @tap="currentStep = 1">返回上一步</text>
<text class="link-text" @tap="goBack">返回登录</text>
</view>
</view>
<!-- 第三步重置密码 -->
<view v-if="currentStep === 3" class="form">
<view class="info-box">
<text class="info-label">租户</text>
<text class="info-value">{{ form.tenant_name }}</text>
</view>
<view class="info-box">
<text class="info-label">账号</text>
<text class="info-value">{{ form.account }}</text>
</view>
<view class="field">
<u-input
v-model="form.new_password"
placeholder="新密码"
type="password"
border="none"
color="#303133"
:customStyle="inputStyle"
:placeholderStyle="placeholderStyle"
/>
</view>
<view class="field">
<u-input
v-model="form.confirm_password"
placeholder="确认新密码"
type="password"
border="none"
color="#303133"
:customStyle="inputStyle"
:placeholderStyle="placeholderStyle"
/>
</view>
<view class="btn-primary" :class="{ disabled: loading }" @tap="handleResetPassword">
{{ loading ? '提交中...' : '重置密码' }}
</view>
<view class="action-row">
<text class="link-text" @tap="currentStep = 2">返回上一步</text>
<text class="link-text" @tap="goBack">返回登录</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { reactive, ref, onUnmounted } from 'vue'
import { verifyAccount, resetPassword, sendResetCode } from '@/api/auth.js'
import { setTenantName, getTenantName } from '@/utils/auth.js'
const currentStep = ref(1)
const loading = ref(false)
const codeLoading = ref(false)
const countdown = ref(0)
let timer = null
const form = reactive({
tenant_name: getTenantName() || '',
account: '',
phone: '',
sms_code: '',
new_password: '',
confirm_password: ''
})
const verifyResult = reactive({
phone: '',
email: ''
})
const inputStyle = {
backgroundColor: 'transparent',
padding: '0 8rpx',
height: '96rpx',
fontSize: '28rpx'
}
const placeholderStyle = 'color: #909399; font-size: 28rpx'
function startCountdown() {
countdown.value = 60
if (timer) clearInterval(timer)
timer = setInterval(() => {
countdown.value -= 1
if (countdown.value <= 0) {
clearInterval(timer)
timer = null
}
}, 1000)
}
// 第一步:验证租户和账号
async function handleVerifyAccount() {
if (loading.value) return
if (!form.tenant_name.trim() || !form.account.trim()) {
uni.showToast({ title: '请填写租户和账号', icon: 'none' })
return
}
loading.value = true
try {
const data = await verifyAccount({
tenant_name: form.tenant_name.trim(),
account: form.account.trim()
})
verifyResult.phone = data?.phone || ''
verifyResult.email = data?.email || ''
if (!verifyResult.phone && !verifyResult.email) {
uni.showToast({ title: '账号未绑定验证方式', icon: 'none' })
return
}
form.phone = verifyResult.phone || ''
currentStep.value = 2
} catch (err) {
// error handled by request interceptor
} finally {
loading.value = false
}
}
// 第二步:发送验证码
async function handleSendCode() {
if (codeLoading.value || countdown.value > 0) return
if (!form.phone.trim()) {
uni.showToast({ title: '请输入手机号', icon: 'none' })
return
}
if (!/^1\d{10}$/.test(form.phone)) {
uni.showToast({ title: '请输入正确的手机号', icon: 'none' })
return
}
codeLoading.value = true
try {
await sendResetCode({
tenant_name: form.tenant_name.trim(),
account: form.account.trim(),
phone: form.phone.trim(),
channel: 'sms'
})
uni.showToast({ title: '验证码已发送', icon: 'success' })
startCountdown()
} catch (err) {
// error handled by request interceptor
} finally {
codeLoading.value = false
}
}
// 第二步验证:验证手机和验证码
async function handleVerifyPhone() {
if (loading.value) return
if (!form.phone.trim()) {
uni.showToast({ title: '请输入手机号', icon: 'none' })
return
}
if (!form.sms_code.trim()) {
uni.showToast({ title: '请输入验证码', icon: 'none' })
return
}
loading.value = true
try {
// 后端的验证码验证在重置密码时进行,这里只是提交到第三步
currentStep.value = 3
} catch (err) {
// error handled by request interceptor
} finally {
loading.value = false
}
}
// 第三步:重置密码
async function handleResetPassword() {
if (loading.value) return
if (!form.tenant_name.trim() || !form.account.trim() || !form.phone.trim()) {
uni.showToast({ title: '请填写租户、账号和手机号', icon: 'none' })
return
}
if (!form.new_password) {
uni.showToast({ title: '请输入新密码', icon: 'none' })
return
}
if (form.new_password !== form.confirm_password) {
uni.showToast({ title: '两次密码不一致', icon: 'none' })
return
}
if (form.new_password.length < 6) {
uni.showToast({ title: '密码长度不能少于6个字符', icon: 'none' })
return
}
if (!form.sms_code.trim()) {
uni.showToast({ title: '请输入验证码', icon: 'none' })
return
}
loading.value = true
try {
await resetPassword({
tenant_name: form.tenant_name.trim(),
account: form.account.trim(),
phone: form.phone.trim(),
sms_code: form.sms_code.trim(),
new_password: form.new_password,
confirm_password: form.confirm_password
})
setTenantName(form.tenant_name.trim())
uni.showToast({ title: '重置成功,请登录', icon: 'success' })
setTimeout(() => {
uni.reLaunch({ url: '/pages/login/login' })
}, 500)
} catch (err) {
// error handled by request interceptor
} finally {
loading.value = false
}
}
function goBack() {
uni.navigateBack({ fail: () => uni.reLaunch({ url: '/pages/login/login' }) })
}
onUnmounted(() => {
if (timer) clearInterval(timer)
})
</script>
<style lang="scss" scoped>
@import '@/src/styles/page-common.scss';
.auth-page {
min-height: 100vh;
background: $color-bg-page;
}
.page-inner {
padding: 0 48rpx;
padding-top: calc(var(--status-bar-height, 44px) + 24rpx);
padding-bottom: 60rpx;
}
.nav-back {
display: flex;
align-items: center;
gap: 4rpx;
margin-bottom: 32rpx;
padding: 8rpx 0;
}
.nav-text {
font-size: 28rpx;
color: $color-text;
}
.header {
margin-bottom: 40rpx;
}
.title {
display: block;
font-size: 44rpx;
font-weight: 600;
color: $color-text;
}
.subtitle {
display: block;
font-size: 26rpx;
color: $color-text-muted;
margin-top: 12rpx;
}
.form-card {
@include card;
padding: 32rpx 28rpx;
}
.form {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.field {
background: $color-bg-page;
border-radius: $radius-md;
padding: 0 24rpx;
overflow: hidden;
}
.field-row {
display: flex;
align-items: center;
}
.code-link {
flex-shrink: 0;
font-size: 26rpx;
color: $color-primary;
padding-left: 16rpx;
white-space: nowrap;
&.disabled {
color: $color-text-muted;
}
}
.info-box {
background: $color-bg-page;
border-radius: $radius-md;
padding: 24rpx;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8rpx;
}
.info-label {
font-size: 26rpx;
color: $color-text-muted;
}
.info-value {
font-size: 26rpx;
color: $color-text;
font-weight: 500;
}
.empty-contact {
background: $color-bg-page;
border-radius: $radius-md;
padding: 24rpx;
text-align: center;
font-size: 26rpx;
color: $color-text-muted;
}
.btn-primary {
height: 96rpx;
background: $color-primary;
border-radius: $radius-md;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
font-weight: 600;
color: #fff;
margin-top: 12rpx;
&:active {
background: $color-primary-dark;
}
&.disabled {
opacity: 0.7;
}
}
.action-row {
display: flex;
justify-content: space-between;
gap: 16rpx;
margin-top: 8rpx;
}
.link-text {
flex: 1;
text-align: center;
font-size: 26rpx;
color: $color-primary;
padding: 12rpx 0;
}
.footer-link {
text-align: center;
font-size: 26rpx;
color: $color-primary;
padding: 16rpx 0 4rpx;
}
</style>
+30
View File
@@ -0,0 +1,30 @@
<template>
<web-view :src="webviewSrc" @message="onMessage" />
</template>
<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
const webviewSrc = ref('')
let eventChannel = null
onLoad((query) => {
const captchaId = decodeURIComponent(query.captchaId || '')
webviewSrc.value = `/static/html/geetest-captcha.html?captchaId=${encodeURIComponent(captchaId)}`
const pages = getCurrentPages()
const page = pages[pages.length - 1]
eventChannel = page.getOpenerEventChannel?.()
})
function onMessage(e) {
const payload = (e.detail && e.detail.data && e.detail.data[0]) || {}
if (payload.type === 'success') {
eventChannel?.emit('geetestSuccess', payload.result || {})
uni.navigateBack()
return
}
eventChannel?.emit('geetestFail', payload.msg || '人机验证未通过')
uni.navigateBack()
}
</script>
+379 -215
View File
@@ -1,16 +1,11 @@
<template>
<view class="login-page">
<view class="page-inner">
<!-- 品牌区 -->
<view class="header">
<view class="logo">
<text class="logo-char"></text>
</view>
<text class="title">欢迎回来</text>
<text class="subtitle">登录云泽开启你的旅程</text>
</view>
<!-- 登录方式切换 -->
<view class="tabs">
<view
class="tab"
@@ -24,75 +19,117 @@
>账号登录</view>
</view>
<!-- 表单 -->
<view class="form-card">
<view class="form">
<template v-if="loginType === 'phone'">
<view class="field">
<u-input
v-model="phone"
placeholder="请输入手机号"
type="number"
maxlength="11"
border="none"
color="#303133"
:customStyle="inputStyle"
:placeholderStyle="placeholderStyle"
>
<template #prefix>
<text class="prefix">+86</text>
</template>
</u-input>
</view>
<view class="field field-row">
<u-input
v-model="code"
placeholder="请输入验证码"
type="number"
maxlength="6"
v-model="tenantName"
placeholder="请输入租户名称"
border="none"
color="#303133"
:customStyle="inputStyle"
:placeholderStyle="placeholderStyle"
/>
<text
class="code-link"
:class="{ disabled: countdown > 0 }"
@tap="sendCode"
>{{ countdown > 0 ? `${countdown}s 后重发` : '获取验证码' }}</text>
</view>
</template>
<template v-else>
<view class="field">
<u-input
v-model="username"
placeholder="请输入号"
border="none"
color="#303133"
:customStyle="inputStyle"
:placeholderStyle="placeholderStyle"
/>
</view>
<view class="field">
<u-input
v-model="password"
placeholder="请输入密码"
type="password"
border="none"
color="#303133"
:customStyle="inputStyle"
:placeholderStyle="placeholderStyle"
/>
</view>
</template>
<template v-if="loginType === 'phone'">
<view class="field">
<u-input
v-model="phone"
placeholder="请输入手机号"
type="number"
maxlength="11"
border="none"
color="#303133"
:customStyle="inputStyle"
:placeholderStyle="placeholderStyle"
>
<template #prefix>
<text class="prefix">+86</text>
</template>
</u-input>
</view>
<view class="field field-row">
<u-input
v-model="smsCode"
placeholder="请输入验证码"
type="number"
maxlength="6"
border="none"
color="#303133"
:customStyle="inputStyle"
:placeholderStyle="placeholderStyle"
/>
<text
class="code-link"
:class="{ disabled: countdown > 0 || sendingCode }"
@tap="sendPhoneCode"
>{{ countdown > 0 ? `${countdown}s 后重发` : (sendingCode ? '发送中' : '获取验证码') }}</text>
</view>
</template>
<view class="btn-primary" @tap="handleLogin"> </view>
<view class="btn-ghost" @tap="handleTestLogin">测试登录</view>
<template v-else>
<view class="field">
<u-input
v-model="username"
placeholder="请输入账号"
border="none"
color="#303133"
:customStyle="inputStyle"
:placeholderStyle="placeholderStyle"
/>
</view>
<view class="field">
<u-input
v-model="password"
placeholder="请输入密码"
type="password"
border="none"
color="#303133"
:customStyle="inputStyle"
:placeholderStyle="placeholderStyle"
/>
</view>
<!-- 后台开启短信/邮箱登录校验时展示 -->
<view v-if="needLoginCode" class="field field-row">
<u-input
v-model="verifyCode"
placeholder="请输入登录验证码"
type="number"
maxlength="8"
border="none"
color="#303133"
:customStyle="inputStyle"
:placeholderStyle="placeholderStyle"
/>
<text
class="code-link"
:class="{ disabled: countdown > 0 || sendingCode }"
@tap="sendAccountVerifyCode"
>{{ countdown > 0 ? `${countdown}s 后重发` : (sendingCode ? '发送中' : '获取验证码') }}</text>
</view>
<view v-if="verifyHint" class="verify-hint">
<text>{{ verifyHint }}</text>
</view>
<view class="remember-row" @tap="rememberMe = !rememberMe">
<view class="remember-dot" :class="{ on: rememberMe }">
<text v-if="rememberMe" class="remember-check"></text>
</view>
<text class="remember-text">记住我</text>
</view>
</template>
<view class="btn-primary" :class="{ disabled: submitting }" @tap="handleLogin">
{{ submitting ? '登录中...' : ' ' }}
</view>
<view class="link-row">
<text class="link" @tap="goRegister">注册账号</text>
<text class="link" @tap="goForget">忘记密码</text>
</view>
</view>
</view>
<!-- 协议 -->
<view class="agreement" @tap="agreed = !agreed">
<view class="agree-dot" :class="{ on: agreed }">
<text v-if="agreed" class="agree-check"></text>
@@ -104,37 +141,43 @@
<text class="agree-link">隐私政策</text>
</text>
</view>
<!-- 第三方 -->
<view class="oauth">
<text class="oauth-label">其他方式</text>
<view class="oauth-icons">
<view class="oauth-btn" @tap="socialLogin('wechat')">
<FaIcon name="weixin" type="brands" color="#3c9cff" :size="22" />
</view>
<view class="oauth-btn" @tap="socialLogin('qq')">
<FaIcon name="qq" type="brands" color="#3c9cff" :size="22" />
</view>
<view class="oauth-btn" @tap="socialLogin('alipay')">
<FaIcon name="alipay" type="brands" color="#3c9cff" :size="22" />
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { loginSuccess, isLoggedIn } from '@/utils/auth.js'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import {
login,
loginBySms,
sendLoginCode,
fetchVerifyConfig
} from '@/api/auth.js'
import {
loginSuccess,
isLoggedIn,
setTenantName,
getTenantName,
getRememberLogin,
saveRememberLogin,
clearRememberLogin
} from '@/utils/auth.js'
import { showGeetest4 } from '@/utils/geetest.js'
const loginType = ref('phone')
const loginType = ref('account')
const tenantName = ref('')
const phone = ref('')
const code = ref('')
const smsCode = ref('')
const username = ref('')
const password = ref('')
const verifyCode = ref('')
const agreed = ref(true)
const rememberMe = ref(false)
const countdown = ref(0)
const sendingCode = ref(false)
const submitting = ref(false)
const openVerify = ref(false)
const verifyType = ref('')
let timer = null
const inputStyle = {
@@ -143,81 +186,235 @@ const inputStyle = {
height: '96rpx',
fontSize: '28rpx'
}
const placeholderStyle = 'color: #909399; font-size: 28rpx'
onMounted(() => {
if (isLoggedIn()) {
uni.reLaunch({ url: '/pages/dashboard/dashboard' })
}
const needLoginCode = computed(() => {
return openVerify.value && (verifyType.value === 'sms' || verifyType.value === 'email')
})
function sendCode() {
if (countdown.value > 0) return
const needGeetest = computed(() => {
return loginType.value === 'account' && !needLoginCode.value
})
const verifyHint = computed(() => {
if (!openVerify.value) return ''
if (verifyType.value === 'sms') return '已开启短信验证,请先获取验证码'
if (verifyType.value === 'email') return '已开启邮箱验证,请先获取验证码'
return ''
})
onMounted(async () => {
if (isLoggedIn()) {
uni.reLaunch({ url: '/pages/dashboard/dashboard' })
return
}
const remembered = getRememberLogin()
if (remembered.rememberMe) {
rememberMe.value = true
tenantName.value = remembered.tenantName
username.value = remembered.account
password.value = remembered.password
} else {
tenantName.value = getTenantName()
}
const cfg = await fetchVerifyConfig()
openVerify.value = cfg.openVerify
verifyType.value = cfg.verifyType
})
onUnmounted(() => {
if (timer) clearInterval(timer)
})
function startCountdown() {
countdown.value = 60
if (timer) clearInterval(timer)
timer = setInterval(() => {
countdown.value--
if (countdown.value <= 0) {
clearInterval(timer)
timer = null
}
}, 1000)
}
async function sendPhoneCode() {
if (countdown.value > 0 || sendingCode.value) return
if (!tenantName.value.trim()) {
uni.showToast({ title: '请输入租户名称', icon: 'none' })
return
}
if (!/^1\d{10}$/.test(phone.value)) {
uni.showToast({ title: '请输入正确手机号', icon: 'none' })
return
}
countdown.value = 60
timer = setInterval(() => {
countdown.value--
if (countdown.value <= 0) clearInterval(timer)
}, 1000)
uni.showToast({ title: '验证码已发送', icon: 'success' })
sendingCode.value = true
try {
await sendLoginCode({
tenant_name: tenantName.value.trim(),
account: phone.value.trim(),
channel: 'sms'
})
uni.showToast({ title: '验证码已发送', icon: 'success' })
startCountdown()
} catch {
// toast 已在 request 中处理
} finally {
sendingCode.value = false
}
}
async function sendAccountVerifyCode() {
if (countdown.value > 0 || sendingCode.value) return
if (!tenantName.value.trim()) {
uni.showToast({ title: '请输入租户名称', icon: 'none' })
return
}
if (!username.value.trim()) {
uni.showToast({ title: '请输入账号', icon: 'none' })
return
}
const channel = verifyType.value === 'email' ? 'email' : 'sms'
sendingCode.value = true
try {
await sendLoginCode({
tenant_name: tenantName.value.trim(),
account: username.value.trim(),
channel
})
uni.showToast({ title: '验证码已发送', icon: 'success' })
startCountdown()
} catch {
// handled
} finally {
sendingCode.value = false
}
}
function navigateAfterLogin() {
uni.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => {
uni.reLaunch({ url: '/pages/dashboard/dashboard' })
}, 500)
}, 400)
}
function handleTestLogin() {
loginSuccess({ nickname: '测试用户', phone: '13800000000' })
navigateAfterLogin()
}
function handleLogin() {
if (!agreed.value) {
uni.showToast({ title: '请先同意用户协议', icon: 'none' })
return
async function submitAccountLogin(geetestResult = null) {
if (!username.value.trim()) {
uni.showToast({ title: '请输入账号', icon: 'none' })
return false
}
if (loginType.value === 'phone') {
if (!/^1\d{10}$/.test(phone.value)) {
uni.showToast({ title: '请输入正确手机号', icon: 'none' })
return
}
if (!code.value || code.value.length < 4) {
uni.showToast({ title: '请输入验证码', icon: 'none' })
return
}
loginSuccess({ phone: phone.value, nickname: '用户' + phone.value.slice(-4) })
if (!password.value.trim()) {
uni.showToast({ title: '请输入密码', icon: 'none' })
return false
}
if (needLoginCode.value && !verifyCode.value.trim()) {
uni.showToast({ title: '请输入登录验证码', icon: 'none' })
return false
}
const payload = {
tenant_name: tenantName.value.trim(),
account: username.value.trim(),
password: password.value
}
if (needLoginCode.value) {
payload.code = verifyCode.value.trim()
}
if (geetestResult) {
Object.assign(payload, geetestResult)
}
const data = await login(payload)
setTenantName(tenantName.value.trim())
loginSuccess({
token: data.token,
user: data.user
})
if (rememberMe.value) {
saveRememberLogin({
tenantName: tenantName.value.trim(),
account: username.value.trim(),
password: password.value
})
} else {
if (!username.value.trim()) {
uni.showToast({ title: '请输入账号', icon: 'none' })
return
}
if (!password.value.trim()) {
uni.showToast({ title: '请输入密码', icon: 'none' })
return
}
loginSuccess({ nickname: username.value })
clearRememberLogin()
}
navigateAfterLogin()
return true
}
function socialLogin(type) {
async function handleLogin() {
if (submitting.value) return
if (!agreed.value) {
uni.showToast({ title: '请先同意用户协议', icon: 'none' })
return
}
const names = { wechat: '微信', qq: 'QQ', alipay: '支付宝' }
loginSuccess({ nickname: names[type] + '用户' })
uni.showToast({ title: `${names[type]}登录成功`, icon: 'success' })
setTimeout(() => {
uni.reLaunch({ url: '/pages/dashboard/dashboard' })
}, 500)
if (!tenantName.value.trim()) {
uni.showToast({ title: '请输入租户名称', icon: 'none' })
return
}
submitting.value = true
try {
if (loginType.value === 'phone') {
if (!/^1\d{10}$/.test(phone.value)) {
uni.showToast({ title: '请输入正确手机号', icon: 'none' })
return
}
if (!smsCode.value || smsCode.value.length < 4) {
uni.showToast({ title: '请输入验证码', icon: 'none' })
return
}
const data = await loginBySms({
tenant_name: tenantName.value.trim(),
phone: phone.value.trim(),
code: smsCode.value.trim()
})
setTenantName(tenantName.value.trim())
loginSuccess({
token: data.token,
user: data.user || { phone: phone.value, nickname: '用户' + phone.value.slice(-4) }
})
navigateAfterLogin()
return
}
if (needGeetest.value) {
if (!username.value.trim()) {
uni.showToast({ title: '请输入账号', icon: 'none' })
return
}
if (!password.value.trim()) {
uni.showToast({ title: '请输入密码', icon: 'none' })
return
}
try {
const geetestResult = await showGeetest4()
const ok = await submitAccountLogin(geetestResult)
if (ok) navigateAfterLogin()
} catch (err) {
const msg = err?.message || '人机验证失败'
if (msg !== '人机验证未通过') {
uni.showToast({ title: msg, icon: 'none' })
}
}
return
}
const ok = await submitAccountLogin()
if (ok) navigateAfterLogin()
} catch {
// toast 已处理
} finally {
submitting.value = false
}
}
function goRegister() {
uni.navigateTo({ url: '/pages/login/register' })
}
function goForget() {
uni.navigateTo({ url: '/pages/login/forget' })
}
</script>
@@ -238,23 +435,6 @@ function socialLogin(type) {
margin-bottom: 64rpx;
}
.logo {
width: 96rpx;
height: 96rpx;
border-radius: $radius-lg;
background: $color-primary-bg;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 32rpx;
}
.logo-char {
font-size: 44rpx;
font-weight: 600;
color: $color-primary;
}
.title {
display: block;
font-size: 48rpx;
@@ -296,9 +476,6 @@ function socialLogin(type) {
.form-card {
@include card;
padding: 32rpx 28rpx;
display: flex;
flex-direction: column;
gap: 24rpx;
}
.form {
@@ -339,6 +516,47 @@ function socialLogin(type) {
}
}
.verify-hint {
font-size: 22rpx;
color: $color-text-muted;
line-height: 1.5;
padding: 0 4rpx;
}
.remember-row {
display: flex;
align-items: center;
gap: 12rpx;
padding: 4rpx;
}
.remember-dot {
width: 32rpx;
height: 32rpx;
border-radius: 50%;
border: 1rpx solid $color-border;
background: $color-card;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&.on {
background: $color-primary;
border-color: $color-primary;
}
}
.remember-check {
font-size: 18rpx;
color: #fff;
}
.remember-text {
font-size: 26rpx;
color: $color-text-secondary;
}
.btn-primary {
height: 96rpx;
background: $color-primary;
@@ -354,22 +572,21 @@ function socialLogin(type) {
&:active {
background: $color-primary-dark;
}
&.disabled {
opacity: 0.7;
}
}
.btn-ghost {
height: 96rpx;
background: $color-card;
border: 1rpx solid $color-border;
border-radius: $radius-md;
.link-row {
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
color: $color-text-secondary;
justify-content: space-between;
padding: 8rpx 4rpx 0;
}
&:active {
background: $color-bg-page;
}
.link {
font-size: 26rpx;
color: $color-primary;
}
.agreement {
@@ -377,6 +594,7 @@ function socialLogin(type) {
align-items: flex-start;
gap: 12rpx;
margin-top: 32rpx;
padding-bottom: 60rpx;
}
.agree-dot {
@@ -411,58 +629,4 @@ function socialLogin(type) {
.agree-link {
color: $color-primary;
}
.oauth {
margin-top: 64rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 28rpx;
padding-bottom: 60rpx;
}
.oauth-label {
font-size: 24rpx;
color: $color-text-muted;
position: relative;
padding: 0 32rpx;
&::before,
&::after {
content: '';
position: absolute;
top: 50%;
width: 80rpx;
height: 1rpx;
background: $color-border;
}
&::before {
right: 100%;
}
&::after {
left: 100%;
}
}
.oauth-icons {
display: flex;
gap: 48rpx;
}
.oauth-btn {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
background: $color-card;
box-shadow: $shadow-card;
display: flex;
align-items: center;
justify-content: center;
&:active {
background: $color-bg-page;
}
}
</style>
+341
View File
@@ -0,0 +1,341 @@
<template>
<view class="auth-page">
<view class="page-inner">
<view class="nav-back" @tap="goBack">
<FaIcon name="chevron-left" color="#303133" :size="18" />
<text class="nav-text">返回</text>
</view>
<view class="header">
<text class="title">注册账号</text>
<text class="subtitle">按租户创建管理员账号</text>
</view>
<view class="form-card">
<view class="form">
<view class="field">
<u-input
v-model="form.tenant_name"
placeholder="租户名称"
border="none"
color="#303133"
:customStyle="inputStyle"
:placeholderStyle="placeholderStyle"
/>
</view>
<view class="field">
<u-input
v-model="form.account"
placeholder="账号"
border="none"
color="#303133"
:customStyle="inputStyle"
:placeholderStyle="placeholderStyle"
/>
</view>
<view class="field">
<u-input
v-model="form.name"
placeholder="姓名"
border="none"
color="#303133"
:customStyle="inputStyle"
:placeholderStyle="placeholderStyle"
/>
</view>
<view class="field">
<u-input
v-model="form.phone"
placeholder="手机号"
type="number"
maxlength="11"
border="none"
color="#303133"
:customStyle="inputStyle"
:placeholderStyle="placeholderStyle"
/>
</view>
<view class="field field-row">
<u-input
v-model="form.sms_code"
placeholder="短信验证码"
type="number"
maxlength="6"
border="none"
color="#303133"
:customStyle="inputStyle"
:placeholderStyle="placeholderStyle"
/>
<text
class="code-link"
:class="{ disabled: countdown > 0 || codeLoading }"
@tap="handleSendCode"
>{{ countdown > 0 ? `${countdown}s` : (codeLoading ? '发送中' : '获取验证码') }}</text>
</view>
<view class="field">
<u-input
v-model="form.email"
placeholder="邮箱(可选)"
border="none"
color="#303133"
:customStyle="inputStyle"
:placeholderStyle="placeholderStyle"
/>
</view>
<view class="field">
<u-input
v-model="form.password"
placeholder="密码"
type="password"
border="none"
color="#303133"
:customStyle="inputStyle"
:placeholderStyle="placeholderStyle"
/>
</view>
<view class="field">
<u-input
v-model="form.confirm_password"
placeholder="确认密码"
type="password"
border="none"
color="#303133"
:customStyle="inputStyle"
:placeholderStyle="placeholderStyle"
/>
</view>
<view class="btn-primary" :class="{ disabled: loading }" @tap="handleSubmit">
{{ loading ? '提交中...' : ' ' }}
</view>
<view class="footer-link" @tap="goBack">
<text>已有账号去登录</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { reactive, ref, onUnmounted } from 'vue'
import { register, sendRegisterCode } from '@/api/auth.js'
import { setTenantName } from '@/utils/auth.js'
const loading = ref(false)
const codeLoading = ref(false)
const countdown = ref(0)
let timer = null
const form = reactive({
tenant_name: '',
account: '',
name: '',
phone: '',
sms_code: '',
email: '',
password: '',
confirm_password: ''
})
const inputStyle = {
backgroundColor: 'transparent',
padding: '0 8rpx',
height: '96rpx',
fontSize: '28rpx'
}
const placeholderStyle = 'color: #909399; font-size: 28rpx'
function startCountdown() {
countdown.value = 60
if (timer) clearInterval(timer)
timer = setInterval(() => {
countdown.value -= 1
if (countdown.value <= 0) {
clearInterval(timer)
timer = null
}
}, 1000)
}
async function handleSendCode() {
if (codeLoading.value || countdown.value > 0) return
if (!form.tenant_name || !form.account || !form.phone) {
uni.showToast({ title: '请先填写租户、账号和手机号', icon: 'none' })
return
}
if (!/^1\d{10}$/.test(form.phone)) {
uni.showToast({ title: '请输入正确手机号', icon: 'none' })
return
}
codeLoading.value = true
try {
await sendRegisterCode({
tenant_name: form.tenant_name.trim(),
account: form.account.trim(),
phone: form.phone.trim()
})
uni.showToast({ title: '验证码已发送', icon: 'success' })
startCountdown()
} catch {
// handled
} finally {
codeLoading.value = false
}
}
async function handleSubmit() {
if (loading.value) return
if (!form.tenant_name.trim() || !form.account.trim() || !form.phone.trim()) {
uni.showToast({ title: '请填写租户、账号和手机号', icon: 'none' })
return
}
if (!form.password) {
uni.showToast({ title: '请输入密码', icon: 'none' })
return
}
if (form.password !== form.confirm_password) {
uni.showToast({ title: '两次密码不一致', icon: 'none' })
return
}
loading.value = true
try {
await register({
tenant_name: form.tenant_name.trim(),
account: form.account.trim(),
name: form.name.trim(),
phone: form.phone.trim(),
sms_code: form.sms_code.trim(),
email: form.email.trim(),
password: form.password,
confirm_password: form.confirm_password
})
setTenantName(form.tenant_name.trim())
uni.showToast({ title: '注册成功,请登录', icon: 'success' })
setTimeout(() => {
uni.navigateBack({ fail: () => uni.reLaunch({ url: '/pages/login/login' }) })
}, 500)
} catch {
// handled
} finally {
loading.value = false
}
}
function goBack() {
uni.navigateBack({ fail: () => uni.reLaunch({ url: '/pages/login/login' }) })
}
onUnmounted(() => {
if (timer) clearInterval(timer)
})
</script>
<style lang="scss" scoped>
@import '@/src/styles/page-common.scss';
.auth-page {
min-height: 100vh;
background: $color-bg-page;
}
.page-inner {
padding: 0 48rpx;
padding-top: calc(var(--status-bar-height, 44px) + 24rpx);
padding-bottom: 60rpx;
}
.nav-back {
display: flex;
align-items: center;
gap: 4rpx;
margin-bottom: 32rpx;
padding: 8rpx 0;
}
.nav-text {
font-size: 28rpx;
color: $color-text;
}
.header {
margin-bottom: 40rpx;
}
.title {
display: block;
font-size: 44rpx;
font-weight: 600;
color: $color-text;
}
.subtitle {
display: block;
font-size: 26rpx;
color: $color-text-muted;
margin-top: 12rpx;
}
.form-card {
@include card;
padding: 32rpx 28rpx;
}
.form {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.field {
background: $color-bg-page;
border-radius: $radius-md;
padding: 0 24rpx;
overflow: hidden;
}
.field-row {
display: flex;
align-items: center;
}
.code-link {
flex-shrink: 0;
font-size: 26rpx;
color: $color-primary;
padding-left: 16rpx;
white-space: nowrap;
&.disabled {
color: $color-text-muted;
}
}
.btn-primary {
height: 96rpx;
background: $color-primary;
border-radius: $radius-md;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
font-weight: 600;
color: #fff;
margin-top: 12rpx;
&:active {
background: $color-primary-dark;
}
&.disabled {
opacity: 0.7;
}
}
.footer-link {
text-align: center;
font-size: 26rpx;
color: $color-primary;
padding: 16rpx 0 4rpx;
}
</style>
+51 -17
View File
@@ -6,8 +6,8 @@
<text class="avatar-text">{{ avatarText }}</text>
</view>
<view class="profile-detail">
<text class="profile-name">{{ user?.nickname || '云泽用户' }}</text>
<text class="profile-id">ID: {{ userId }}</text>
<text class="profile-name">{{ user?.nickname || user?.name || '云泽用户' }}</text>
<text class="profile-id">{{ user?.account ? `账号: ${user.account}` : `ID: ${userId}` }}</text>
</view>
<view class="profile-edit" @tap="onEdit">
<FaIcon name="pen-to-square" color="#ffffff" :size="16" />
@@ -43,7 +43,7 @@
</view>
</view>
<view class="logout-btn" @tap="handleLogout">
<view class="logout-btn" hover-class="logout-btn-hover" @tap.stop="handleLogout">
<text class="logout-text">退出登录</text>
</view>
@@ -56,20 +56,24 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { getUser, logout, isLoggedIn } from '@/utils/auth.js'
import { getUser, setUser, logout, isLoggedIn } from '@/utils/auth.js'
import { getCurrentUser, logoutApi } from '@/api/auth.js'
import AppTabbar from '@/components/AppTabbar.vue'
const user = ref(null)
const avatarText = computed(() => {
const name = user.value?.nickname || '云'
const name = user.value?.nickname || user.value?.name || '云'
return name.charAt(0).toUpperCase()
})
const userId = computed(() => {
return user.value?.phone
? user.value.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
: '10086'
if (user.value?.phone) {
return user.value.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
}
if (user.value?.account) return user.value.account
if (user.value?.id) return String(user.value.id)
return '-'
})
const profileStats = ref([
@@ -96,12 +100,32 @@ const menuGroups = ref([
]
])
async function loadUser() {
user.value = getUser()
try {
const data = await getCurrentUser()
if (data) {
const prev = getUser() || {}
const merged = {
...prev,
...data,
nickname: data.name || data.nickname || data.account || prev.nickname || '云泽用户'
}
setUser(merged)
user.value = merged
}
} catch {
// 401 时 request 会跳转登录
user.value = getUser()
}
}
onMounted(() => {
if (!isLoggedIn()) {
uni.reLaunch({ url: '/pages/login/login' })
return
}
user.value = getUser()
loadUser()
})
function onEdit() {
@@ -109,6 +133,10 @@ function onEdit() {
}
function onMenuTap(item) {
if (item.title === '账号安全') {
uni.navigateTo({ url: '/pages/login/forget' })
return
}
uni.showToast({ title: item.title, icon: 'none' })
}
@@ -116,11 +144,15 @@ function handleLogout() {
uni.showModal({
title: '提示',
content: '确定要退出登录吗?',
confirmText: '退出',
cancelText: '取消',
success(res) {
if (res.confirm) {
logout()
uni.reLaunch({ url: '/pages/login/login' })
}
if (!res.confirm) return
// 先清本地并跳转,避免接口超时/失败导致“退出无效”
logout()
// 后端无状态退出,失败可忽略(不阻塞)
logoutApi().catch(() => {})
uni.reLaunch({ url: '/pages/login/login' })
}
})
}
@@ -289,18 +321,20 @@ function handleLogout() {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8rpx;
}
&:active {
background: $color-bg-page;
}
.logout-btn-hover {
background: $color-bg-page !important;
}
.logout-text {
font-size: 30rpx;
color: $color-text-secondary;
pointer-events: none;
}
.bottom-space {
height: 24rpx;
height: 48rpx;
}
</style>