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

<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>