You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
771 lines
19 KiB
771 lines
19 KiB
<script setup>
|
|
import { reactive, ref } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import request from '@/utils/http'
|
|
import * as urls from '@/config/urls'
|
|
import { useUserStore } from '@/stores/modules/userStore'
|
|
import { useUiStore } from '@/stores/modules/uiStore'
|
|
|
|
const router = useRouter()
|
|
const userStore = useUserStore()
|
|
const ui = useUiStore()
|
|
|
|
const mode = ref('login')
|
|
const method = ref('password')
|
|
const loading = ref(false)
|
|
const pwdVisible = ref(false)
|
|
const remember = ref(true)
|
|
|
|
const account = ref('')
|
|
const password = ref('')
|
|
const smsPhone = ref('')
|
|
const smsCode = ref('')
|
|
const captcha = ref('')
|
|
const captchaValue = ref('7K4P')
|
|
const loginError = ref('')
|
|
const loginCodeTimer = ref(0)
|
|
|
|
const reg = reactive({
|
|
company: '',
|
|
contact: '',
|
|
phone: '',
|
|
email: '',
|
|
password: '',
|
|
confirm: '',
|
|
agreement: false,
|
|
})
|
|
const registerError = ref('')
|
|
|
|
const forgotVisible = ref(false)
|
|
const forgot = reactive({ account: '', phone: '', code: '', password: '' })
|
|
const forgotError = ref('')
|
|
|
|
function refreshCaptcha() {
|
|
const chars = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'
|
|
captchaValue.value = Array.from({ length: 4 }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
|
|
}
|
|
|
|
async function submitLogin() {
|
|
loginError.value = ''
|
|
if (!captcha.value) {
|
|
loginError.value = '请输入图形验证码。'
|
|
return
|
|
}
|
|
if (captcha.value.toUpperCase() !== captchaValue.value) {
|
|
loginError.value = '图形验证码不正确,请重新输入。'
|
|
return
|
|
}
|
|
if (method.value === 'password' && (!account.value || !password.value)) {
|
|
loginError.value = '请输入账号和密码。'
|
|
return
|
|
}
|
|
if (method.value === 'sms') {
|
|
loginError.value = '短信登录暂未开放,请使用账号密码登录。'
|
|
return
|
|
}
|
|
loading.value = true
|
|
try {
|
|
const data = await request.post(urls.AUTH_LOGIN, {
|
|
phone: account.value,
|
|
password: password.value,
|
|
})
|
|
const token = data?.accessToken || data?.token || data?.jToken
|
|
if (!token) {
|
|
loginError.value = '登录成功但未返回 token,请检查后端响应字段。'
|
|
return
|
|
}
|
|
userStore.setLogin(token, data.user || data.userInfo || null)
|
|
ui.toast('登录成功')
|
|
router.push('/monitor')
|
|
} catch (e) {
|
|
// 错误已由拦截器提示
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function submitRegister() {
|
|
registerError.value = ''
|
|
if (!reg.company || !reg.contact || !reg.phone || !reg.email || !reg.password || !reg.confirm) {
|
|
registerError.value = '请完整填写注册信息。'
|
|
return
|
|
}
|
|
if (reg.password.length < 6 || reg.password !== reg.confirm) {
|
|
registerError.value = '请确认密码一致且不少于 6 位。'
|
|
return
|
|
}
|
|
if (!reg.agreement) {
|
|
registerError.value = '请先同意平台服务协议和隐私政策。'
|
|
return
|
|
}
|
|
loading.value = true
|
|
try {
|
|
const data = await request.post(urls.AUTH_REGISTER, {
|
|
name: reg.company,
|
|
phone: reg.phone,
|
|
email: reg.email,
|
|
password: reg.password,
|
|
})
|
|
const token = data?.accessToken || data?.token || data?.jToken
|
|
if (token) {
|
|
userStore.setLogin(token, data.user || data.userInfo || null)
|
|
ui.toast('注册成功')
|
|
router.push('/monitor')
|
|
} else {
|
|
mode.value = 'login'
|
|
account.value = reg.phone
|
|
password.value = ''
|
|
ui.toast('注册成功,请登录')
|
|
}
|
|
} catch (e) {
|
|
// 错误已由拦截器提示
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function sendLoginCode() {
|
|
if (!smsPhone.value) {
|
|
loginError.value = '请先输入手机号。'
|
|
return
|
|
}
|
|
if (loginCodeTimer.value) return
|
|
loginCodeTimer.value = 60
|
|
const timer = setInterval(() => {
|
|
loginCodeTimer.value -= 1
|
|
if (loginCodeTimer.value <= 0) clearInterval(timer)
|
|
}, 1000)
|
|
request.post(urls.AUTH_SMS_CODE, { phone: smsPhone.value }).catch(() => {})
|
|
ui.toast('短信验证码已发送')
|
|
}
|
|
|
|
function sendForgotCode() {
|
|
if (!forgot.phone) {
|
|
forgotError.value = '请先输入手机号。'
|
|
return
|
|
}
|
|
request.post(urls.AUTH_SMS_CODE, { phone: forgot.phone }).catch(() => {})
|
|
ui.toast('短信验证码已发送')
|
|
}
|
|
|
|
async function submitForgot() {
|
|
forgotError.value = ''
|
|
if (!forgot.account || !forgot.phone || !forgot.code || !forgot.password) {
|
|
forgotError.value = '请完整填写找回信息。'
|
|
return false
|
|
}
|
|
if (forgot.password.length < 6) {
|
|
forgotError.value = '新密码至少需要 6 位。'
|
|
return false
|
|
}
|
|
try {
|
|
await request.post(urls.AUTH_RESET_PASSWORD, {
|
|
phone: forgot.phone,
|
|
smsCode: forgot.code,
|
|
newPassword: forgot.password,
|
|
})
|
|
forgotVisible.value = false
|
|
ui.toast('密码已重置,请使用新密码登录')
|
|
} catch (e) {
|
|
return false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<section :class="s.page">
|
|
<div :class="s.visual">
|
|
<div :class="s.visualMap" />
|
|
<div :class="s.visualShade" />
|
|
<div :class="s.brand">
|
|
<div :class="s.brandMark"><span /><span /><span /></div>
|
|
<div>
|
|
<strong>嘉谷</strong>
|
|
<small>低空智控平台</small>
|
|
</div>
|
|
</div>
|
|
<div :class="s.visualCopy">
|
|
<span>LOW-ALTITUDE INTELLIGENCE</span>
|
|
<h1>让每一次飞行<br />都可控、可追溯</h1>
|
|
<p>统一管理机巢、无人机、任务与视频资产</p>
|
|
</div>
|
|
<div :class="s.visualFooter">
|
|
<span>嘉谷低空智控平台</span>
|
|
<span>© 2026 嘉谷科技</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div :class="s.formSide">
|
|
<div :class="s.formWrap">
|
|
<div v-show="mode === 'login'" :class="s.panel">
|
|
<div :class="s.methodTabs">
|
|
<button
|
|
type="button"
|
|
:class="[s.tab, { [s.tabActive]: method === 'password' }]"
|
|
@click="method = 'password'"
|
|
>
|
|
账号密码登录
|
|
</button>
|
|
<button
|
|
type="button"
|
|
:class="[s.tab, { [s.tabActive]: method === 'sms' }]"
|
|
@click="method = 'sms'"
|
|
>
|
|
手机号登录
|
|
</button>
|
|
</div>
|
|
|
|
<t-form label-width="0" @submit="submitLogin">
|
|
<template v-if="method === 'password'">
|
|
<t-form-item>
|
|
<t-input
|
|
v-model="account"
|
|
size="large"
|
|
clearable
|
|
placeholder="请输入手机号或企业邮箱"
|
|
autocomplete="username"
|
|
>
|
|
<template #prefix-icon>
|
|
<t-icon name="user" />
|
|
</template>
|
|
</t-input>
|
|
</t-form-item>
|
|
<t-form-item>
|
|
<t-input
|
|
v-model="password"
|
|
size="large"
|
|
clearable
|
|
:type="pwdVisible ? 'text' : 'password'"
|
|
placeholder="请输入登录密码"
|
|
autocomplete="current-password"
|
|
>
|
|
<template #prefix-icon>
|
|
<t-icon name="lock-on" />
|
|
</template>
|
|
<template #suffix-icon>
|
|
<t-icon
|
|
:name="pwdVisible ? 'browse' : 'browse-off'"
|
|
:class="s.iconClick"
|
|
@click="pwdVisible = !pwdVisible"
|
|
/>
|
|
</template>
|
|
</t-input>
|
|
</t-form-item>
|
|
</template>
|
|
|
|
<template v-else>
|
|
<t-form-item>
|
|
<t-input
|
|
v-model="smsPhone"
|
|
size="large"
|
|
clearable
|
|
type="tel"
|
|
placeholder="请输入绑定手机号"
|
|
>
|
|
<template #prefix-icon>
|
|
<t-icon name="mobile" />
|
|
</template>
|
|
</t-input>
|
|
</t-form-item>
|
|
<t-form-item>
|
|
<div :class="s.codeRow">
|
|
<t-input
|
|
v-model="smsCode"
|
|
size="large"
|
|
clearable
|
|
:maxlength="6"
|
|
placeholder="请输入短信验证码"
|
|
>
|
|
<template #prefix-icon>
|
|
<t-icon name="mail" />
|
|
</template>
|
|
</t-input>
|
|
<t-button
|
|
theme="default"
|
|
variant="outline"
|
|
size="large"
|
|
:disabled="loginCodeTimer > 0"
|
|
@click="sendLoginCode"
|
|
>
|
|
{{ loginCodeTimer > 0 ? `${loginCodeTimer}s 后重试` : '获取验证码' }}
|
|
</t-button>
|
|
</div>
|
|
</t-form-item>
|
|
</template>
|
|
|
|
<t-form-item>
|
|
<div :class="s.codeRow">
|
|
<t-input
|
|
v-model="captcha"
|
|
size="large"
|
|
clearable
|
|
:maxlength="4"
|
|
placeholder="请输入验证码"
|
|
>
|
|
<template #prefix-icon>
|
|
<t-icon name="secured" />
|
|
</template>
|
|
</t-input>
|
|
<t-button
|
|
theme="default"
|
|
variant="outline"
|
|
size="large"
|
|
:class="s.captchaBtn"
|
|
title="刷新验证码"
|
|
@click="refreshCaptcha"
|
|
>
|
|
{{ captchaValue }}
|
|
</t-button>
|
|
</div>
|
|
</t-form-item>
|
|
|
|
<div :class="s.options">
|
|
<t-checkbox v-model="remember">记住登录状态</t-checkbox>
|
|
<t-button theme="primary" variant="text" @click="forgotVisible = true">
|
|
忘记密码?
|
|
</t-button>
|
|
</div>
|
|
|
|
<div v-if="loginError" :class="s.error">{{ loginError }}</div>
|
|
|
|
<t-form-item>
|
|
<t-button
|
|
block
|
|
size="large"
|
|
type="submit"
|
|
theme="primary"
|
|
:loading="loading"
|
|
>
|
|
登录平台
|
|
</t-button>
|
|
</t-form-item>
|
|
</t-form>
|
|
|
|
<p :class="s.help">
|
|
还没有企业账号?
|
|
<t-button theme="primary" variant="text" @click="mode = 'register'">
|
|
注册企业账号
|
|
</t-button>
|
|
</p>
|
|
</div>
|
|
|
|
<div v-show="mode === 'register'" :class="s.panel">
|
|
<div :class="s.heading">
|
|
<span>企业入驻</span>
|
|
<h2>注册账号</h2>
|
|
<p>创建企业账户,开始使用低空智控平台</p>
|
|
</div>
|
|
|
|
<t-form label-width="0" @submit="submitRegister">
|
|
<t-form-item>
|
|
<t-input
|
|
v-model="reg.company"
|
|
size="large"
|
|
clearable
|
|
placeholder="请输入企业全称"
|
|
>
|
|
<template #prefix-icon>
|
|
<t-icon name="root-list" />
|
|
</template>
|
|
</t-input>
|
|
</t-form-item>
|
|
|
|
<div :class="s.row">
|
|
<t-form-item>
|
|
<t-input v-model="reg.contact" size="large" clearable placeholder="联系人姓名" />
|
|
</t-form-item>
|
|
<t-form-item>
|
|
<t-input
|
|
v-model="reg.phone"
|
|
size="large"
|
|
clearable
|
|
type="tel"
|
|
placeholder="联系电话"
|
|
/>
|
|
</t-form-item>
|
|
</div>
|
|
|
|
<t-form-item>
|
|
<t-input
|
|
v-model="reg.email"
|
|
size="large"
|
|
clearable
|
|
type="email"
|
|
placeholder="企业邮箱,用于接收平台通知"
|
|
>
|
|
<template #prefix-icon>
|
|
<t-icon name="mail" />
|
|
</template>
|
|
</t-input>
|
|
</t-form-item>
|
|
|
|
<div :class="s.row">
|
|
<t-form-item>
|
|
<t-input
|
|
v-model="reg.password"
|
|
size="large"
|
|
type="password"
|
|
clearable
|
|
placeholder="设置密码(至少 6 位)"
|
|
/>
|
|
</t-form-item>
|
|
<t-form-item>
|
|
<t-input
|
|
v-model="reg.confirm"
|
|
size="large"
|
|
type="password"
|
|
clearable
|
|
placeholder="确认密码"
|
|
/>
|
|
</t-form-item>
|
|
</div>
|
|
|
|
<t-form-item>
|
|
<t-checkbox v-model="reg.agreement">
|
|
我已阅读并同意《平台服务协议》和《隐私政策》
|
|
</t-checkbox>
|
|
</t-form-item>
|
|
|
|
<div v-if="registerError" :class="s.error">{{ registerError }}</div>
|
|
|
|
<t-form-item>
|
|
<t-button
|
|
block
|
|
size="large"
|
|
type="submit"
|
|
theme="primary"
|
|
:loading="loading"
|
|
>
|
|
提交注册
|
|
</t-button>
|
|
</t-form-item>
|
|
</t-form>
|
|
|
|
<p :class="s.help">
|
|
已有企业账号?
|
|
<t-button theme="primary" variant="text" @click="mode = 'login'">
|
|
返回登录
|
|
</t-button>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<t-dialog
|
|
v-model:visible="forgotVisible"
|
|
header="找回登录密码"
|
|
width="420px"
|
|
:confirm-btn="{ content: '确认重置', theme: 'primary' }"
|
|
cancel-btn="取消"
|
|
@confirm="submitForgot"
|
|
>
|
|
<t-form label-width="0">
|
|
<t-form-item>
|
|
<t-input v-model="forgot.account" size="large" clearable placeholder="请输入登录账号(手机号)" />
|
|
</t-form-item>
|
|
<t-form-item>
|
|
<t-input
|
|
v-model="forgot.phone"
|
|
size="large"
|
|
clearable
|
|
type="tel"
|
|
placeholder="请输入接收验证码的手机号"
|
|
/>
|
|
</t-form-item>
|
|
<t-form-item>
|
|
<div :class="s.codeRow">
|
|
<t-input
|
|
v-model="forgot.code"
|
|
size="large"
|
|
clearable
|
|
:maxlength="6"
|
|
placeholder="请输入短信验证码"
|
|
/>
|
|
<t-button theme="default" variant="outline" size="large" @click="sendForgotCode">
|
|
获取验证码
|
|
</t-button>
|
|
</div>
|
|
</t-form-item>
|
|
<t-form-item>
|
|
<t-input
|
|
v-model="forgot.password"
|
|
size="large"
|
|
type="password"
|
|
clearable
|
|
placeholder="设置新密码(至少 6 位)"
|
|
/>
|
|
</t-form-item>
|
|
<div v-if="forgotError" :class="s.error">{{ forgotError }}</div>
|
|
</t-form>
|
|
</t-dialog>
|
|
</section>
|
|
</template>
|
|
|
|
<style lang="less" module="s">
|
|
.page {
|
|
position: relative;
|
|
display: block;
|
|
min-height: 100vh;
|
|
overflow: hidden;
|
|
background: #102a35;
|
|
}
|
|
|
|
.visual {
|
|
position: absolute;
|
|
z-index: 0;
|
|
inset: 0;
|
|
width: 100%;
|
|
min-height: 100vh;
|
|
color: #fff;
|
|
background: #1b323c;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.visualMap {
|
|
position: absolute;
|
|
inset: 0;
|
|
background-image: url('/assets/login-hero-dock-drone.png');
|
|
background-size: cover;
|
|
background-position: center;
|
|
filter: saturate(0.82) contrast(1.04);
|
|
}
|
|
|
|
.visualShade {
|
|
position: absolute;
|
|
inset: 0;
|
|
background:
|
|
linear-gradient(90deg, rgba(10, 31, 40, 0.78), rgba(12, 38, 48, 0.18) 62%, rgba(9, 26, 34, 0.34)),
|
|
linear-gradient(0deg, rgba(9, 26, 34, 0.72), transparent 42%);
|
|
}
|
|
|
|
.brand {
|
|
position: absolute;
|
|
z-index: 1;
|
|
top: 36px;
|
|
left: 40px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
|
|
strong {
|
|
display: block;
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
small {
|
|
display: block;
|
|
margin-top: 2px;
|
|
opacity: 0.78;
|
|
font-size: 12px;
|
|
}
|
|
}
|
|
|
|
.brandMark {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 6px);
|
|
gap: 4px;
|
|
|
|
span {
|
|
width: 6px;
|
|
height: 18px;
|
|
border-radius: 2px;
|
|
background: #5ec8f0;
|
|
|
|
&:nth-child(2) {
|
|
opacity: 0.7;
|
|
height: 12px;
|
|
align-self: end;
|
|
}
|
|
|
|
&:nth-child(3) {
|
|
opacity: 0.45;
|
|
height: 8px;
|
|
align-self: end;
|
|
}
|
|
}
|
|
}
|
|
|
|
.visualCopy {
|
|
position: absolute;
|
|
z-index: 1;
|
|
left: 40px;
|
|
bottom: 96px;
|
|
max-width: 420px;
|
|
|
|
> span {
|
|
letter-spacing: 0.12em;
|
|
font-size: 11px;
|
|
opacity: 0.72;
|
|
}
|
|
|
|
h1 {
|
|
margin: 14px 0 12px;
|
|
font-size: 34px;
|
|
line-height: 1.25;
|
|
font-weight: 600;
|
|
}
|
|
|
|
p {
|
|
margin: 0;
|
|
opacity: 0.82;
|
|
font-size: 14px;
|
|
}
|
|
}
|
|
|
|
.visualFooter {
|
|
position: absolute;
|
|
z-index: 1;
|
|
left: 40px;
|
|
right: 40px;
|
|
bottom: 28px;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
opacity: 0.65;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.formSide {
|
|
position: relative;
|
|
z-index: 2;
|
|
width: 100%;
|
|
min-height: 100vh;
|
|
padding: 40px 24px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.formWrap {
|
|
width: min(400px, 100%);
|
|
padding: 36px 32px 28px;
|
|
border: 1px solid rgba(225, 231, 236, 0.9);
|
|
border-radius: 10px;
|
|
background: rgba(255, 255, 255, 0.96);
|
|
box-shadow: 0 20px 55px rgba(10, 31, 43, 0.25);
|
|
}
|
|
|
|
.panel {
|
|
width: 100%;
|
|
}
|
|
|
|
.methodTabs {
|
|
height: 38px;
|
|
margin: 0 0 22px;
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
border-bottom: 1px solid #dce4e9;
|
|
}
|
|
|
|
.tab {
|
|
position: relative;
|
|
border: 0;
|
|
color: #7d8b94;
|
|
background: transparent;
|
|
font-size: 13px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.tabActive {
|
|
color: #1f6feb;
|
|
font-weight: 600;
|
|
|
|
&::after {
|
|
content: '';
|
|
position: absolute;
|
|
right: 0;
|
|
bottom: -1px;
|
|
left: 0;
|
|
height: 2px;
|
|
background: #1f6feb;
|
|
}
|
|
}
|
|
|
|
.codeRow {
|
|
width: 100%;
|
|
display: grid;
|
|
grid-template-columns: 1fr auto;
|
|
gap: 10px;
|
|
align-items: center;
|
|
}
|
|
|
|
.captchaBtn {
|
|
min-width: 96px;
|
|
letter-spacing: 0.18em;
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
}
|
|
|
|
.options {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin: 4px 0 14px;
|
|
}
|
|
|
|
.error {
|
|
margin: 0 0 12px;
|
|
color: #d54941;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.help {
|
|
margin: 8px 0 0;
|
|
color: #6b7b86;
|
|
font-size: 13px;
|
|
text-align: center;
|
|
}
|
|
|
|
.heading {
|
|
margin-bottom: 22px;
|
|
|
|
> span {
|
|
color: #1f6feb;
|
|
font-size: 12px;
|
|
}
|
|
|
|
h2 {
|
|
margin: 10px 0 8px;
|
|
color: #263945;
|
|
font-size: 28px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
p {
|
|
margin: 0;
|
|
color: #6b7b86;
|
|
font-size: 13px;
|
|
}
|
|
}
|
|
|
|
.row {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 10px;
|
|
}
|
|
|
|
.iconClick {
|
|
cursor: pointer;
|
|
}
|
|
|
|
@media (max-width: 820px) {
|
|
.visual {
|
|
display: none;
|
|
}
|
|
|
|
.page {
|
|
background: #f3f6f9;
|
|
}
|
|
|
|
.formSide {
|
|
padding: 28px 22px;
|
|
}
|
|
|
|
.formWrap {
|
|
box-shadow: 0 12px 28px rgba(28, 53, 70, 0.08);
|
|
}
|
|
|
|
.row {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
</style>
|
|
|