- Add company module (3-tier org, CEO planning, parallel departments) - Add company orchestrator, knowledge extractor, presets, scheduler - Add company API endpoints, models, and frontend views - Add 今天吃啥 PWA app (69 dishes, real images, offline support) - Add team_projects output directory structure - Add unified manage.ps1 for service lifecycle - Add Windows startup guide v1.0 - Add TTS troubleshooting doc - Update frontend (AgentChat UX overhaul, new views) - Update backend (voice engine fix, multi-tenant, RBAC) - Remove deprecated startup scripts and old docs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
804 lines
27 KiB
Vue
804 lines
27 KiB
Vue
<template>
|
||
<div class="login-page" @keydown.enter="onEnter">
|
||
<!-- 背景装饰 -->
|
||
<div class="bg-decor" aria-hidden="true">
|
||
<span class="bg-circle bg-circle--1"></span>
|
||
<span class="bg-circle bg-circle--2"></span>
|
||
</div>
|
||
|
||
<!-- 居中卡片 -->
|
||
<div class="login-card" ref="cardRef">
|
||
<!-- 品牌头部 -->
|
||
<header class="card-brand">
|
||
<h1 class="brand-title">天工智能体</h1>
|
||
<p class="brand-desc">AI 驱动的企业协作平台</p>
|
||
</header>
|
||
|
||
<!-- 错误 / 冷却提示 -->
|
||
<transition name="el-fade-in">
|
||
<el-alert
|
||
v-if="loginState === 'error' && errorMessage"
|
||
class="error-alert"
|
||
:title="errorMessage"
|
||
type="error"
|
||
:closable="true"
|
||
show-icon
|
||
@close="dismissError"
|
||
/>
|
||
</transition>
|
||
<transition name="el-fade-in">
|
||
<el-alert
|
||
v-if="loginState === 'disabled'"
|
||
class="error-alert"
|
||
:title="`操作过于频繁,请 ${countdown} 秒后重试`"
|
||
type="warning"
|
||
:closable="false"
|
||
show-icon
|
||
/>
|
||
</transition>
|
||
|
||
<!-- 登录 / 注册 Tab -->
|
||
<el-tabs v-model="activeTab" class="login-tabs" :disabled="isFormDisabled">
|
||
<!-- ========== 登录 ========== -->
|
||
<el-tab-pane label="登录" name="login">
|
||
<el-form
|
||
ref="loginFormRef"
|
||
:model="loginForm"
|
||
:rules="loginRules"
|
||
label-position="top"
|
||
size="large"
|
||
:disabled="isFormDisabled"
|
||
@submit.prevent
|
||
>
|
||
<el-form-item prop="username">
|
||
<el-input
|
||
v-model="loginForm.username"
|
||
placeholder="用户名 / 邮箱"
|
||
:prefix-icon="UserIcon"
|
||
:maxlength="64"
|
||
clearable
|
||
autocomplete="username"
|
||
tabindex="1"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item prop="password">
|
||
<el-input
|
||
v-model="loginForm.password"
|
||
type="password"
|
||
placeholder="密码"
|
||
:prefix-icon="LockIcon"
|
||
:maxlength="32"
|
||
show-password
|
||
autocomplete="current-password"
|
||
tabindex="2"
|
||
/>
|
||
</el-form-item>
|
||
|
||
<transition name="el-zoom-in-top">
|
||
<el-form-item v-if="showCaptcha" prop="captcha">
|
||
<div style="display:flex; gap:8px; align-items:center; width:100%;">
|
||
<el-input
|
||
v-model="loginForm.captcha"
|
||
placeholder="验证码"
|
||
:prefix-icon="KeyIcon"
|
||
:maxlength="6"
|
||
clearable
|
||
autocomplete="off"
|
||
tabindex="3"
|
||
style="flex:1;"
|
||
/>
|
||
<img
|
||
v-if="captchaImg"
|
||
:src="captchaImg"
|
||
title="看不清?点击刷新"
|
||
style="width:110px; height:40px; border-radius:6px; cursor:pointer; border:1px solid var(--el-border-color); flex-shrink:0;"
|
||
@click="fetchCaptcha"
|
||
/>
|
||
<div
|
||
v-else
|
||
style="width:110px; height:40px; border-radius:6px; cursor:pointer; border:1px dashed var(--el-border-color); display:flex; align-items:center; justify-content:center; font-size:13px; color:var(--el-text-color-secondary); flex-shrink:0;"
|
||
@click="fetchCaptcha"
|
||
>
|
||
{{ captchaLoading ? '加载中' : '点击获取' }}
|
||
</div>
|
||
</div>
|
||
</el-form-item>
|
||
</transition>
|
||
|
||
<div class="form-extra">
|
||
<el-checkbox v-model="loginForm.rememberMe" label="30天内免登录" tabindex="4" />
|
||
<a class="link" href="#" @click.prevent="onForgotPwd">忘记密码?</a>
|
||
</div>
|
||
|
||
<el-button
|
||
class="submit-btn"
|
||
type="primary"
|
||
:loading="loginState === 'loading'"
|
||
:disabled="isSubmitDisabled"
|
||
size="large"
|
||
round
|
||
tabindex="5"
|
||
@click="handleLogin"
|
||
>
|
||
{{ submitText }}
|
||
</el-button>
|
||
</el-form>
|
||
</el-tab-pane>
|
||
|
||
<!-- ========== 注册 ========== -->
|
||
<el-tab-pane label="注册" name="register">
|
||
<el-form
|
||
ref="registerFormRef"
|
||
:model="registerForm"
|
||
:rules="registerRules"
|
||
label-position="top"
|
||
size="large"
|
||
:disabled="isFormDisabled"
|
||
@submit.prevent
|
||
>
|
||
<el-form-item prop="username">
|
||
<el-input
|
||
v-model="registerForm.username"
|
||
placeholder="用户名"
|
||
:prefix-icon="UserIcon"
|
||
:maxlength="32"
|
||
clearable
|
||
autocomplete="off"
|
||
tabindex="1"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item prop="email">
|
||
<el-input
|
||
v-model="registerForm.email"
|
||
placeholder="邮箱"
|
||
:prefix-icon="MessageIcon"
|
||
:maxlength="64"
|
||
clearable
|
||
autocomplete="off"
|
||
tabindex="2"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item prop="password">
|
||
<el-input
|
||
v-model="registerForm.password"
|
||
type="password"
|
||
placeholder="密码(6-32位)"
|
||
:prefix-icon="LockIcon"
|
||
:maxlength="32"
|
||
show-password
|
||
autocomplete="new-password"
|
||
tabindex="3"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item prop="confirmPassword">
|
||
<el-input
|
||
v-model="registerForm.confirmPassword"
|
||
type="password"
|
||
placeholder="确认密码"
|
||
:prefix-icon="LockIcon"
|
||
:maxlength="32"
|
||
show-password
|
||
autocomplete="new-password"
|
||
tabindex="4"
|
||
/>
|
||
</el-form-item>
|
||
|
||
<el-button
|
||
class="submit-btn"
|
||
type="primary"
|
||
:loading="loginState === 'loading'"
|
||
:disabled="isSubmitDisabled"
|
||
size="large"
|
||
round
|
||
tabindex="5"
|
||
@click="handleRegister"
|
||
>
|
||
{{ submitText }}
|
||
</el-button>
|
||
</el-form>
|
||
</el-tab-pane>
|
||
</el-tabs>
|
||
</div>
|
||
|
||
<!-- 忘记密码弹窗 -->
|
||
<el-dialog
|
||
v-model="forgotPwdVisible"
|
||
title="重置密码"
|
||
width="min(92vw, 400px)"
|
||
:close-on-click-modal="false"
|
||
destroy-on-close
|
||
center
|
||
>
|
||
<el-form
|
||
ref="forgotFormRef"
|
||
:model="forgotForm"
|
||
:rules="forgotRules"
|
||
label-position="top"
|
||
:disabled="forgotLoading"
|
||
@submit.prevent
|
||
>
|
||
<el-form-item prop="email">
|
||
<el-input
|
||
v-model="forgotForm.email"
|
||
placeholder="请输入注册邮箱"
|
||
:prefix-icon="MessageIcon"
|
||
:maxlength="64"
|
||
clearable
|
||
autocomplete="off"
|
||
>
|
||
<template #append>
|
||
<el-button
|
||
:loading="sendingCode"
|
||
:disabled="codeCooldown > 0 || forgotLoading"
|
||
@click="sendResetCode"
|
||
>
|
||
{{ codeCooldown > 0 ? `${codeCooldown}s` : '发送验证码' }}
|
||
</el-button>
|
||
</template>
|
||
</el-input>
|
||
</el-form-item>
|
||
|
||
<el-form-item prop="code">
|
||
<el-input
|
||
v-model="forgotForm.code"
|
||
placeholder="请输入6位验证码"
|
||
:prefix-icon="KeyIcon"
|
||
:maxlength="6"
|
||
clearable
|
||
autocomplete="off"
|
||
/>
|
||
</el-form-item>
|
||
|
||
<el-form-item prop="newPassword">
|
||
<el-input
|
||
v-model="forgotForm.newPassword"
|
||
type="password"
|
||
placeholder="新密码(6-32位)"
|
||
:prefix-icon="LockIcon"
|
||
:maxlength="32"
|
||
show-password
|
||
autocomplete="new-password"
|
||
/>
|
||
</el-form-item>
|
||
|
||
<el-form-item prop="confirmPassword">
|
||
<el-input
|
||
v-model="forgotForm.confirmPassword"
|
||
type="password"
|
||
placeholder="确认新密码"
|
||
:prefix-icon="LockIcon"
|
||
:maxlength="32"
|
||
show-password
|
||
autocomplete="new-password"
|
||
/>
|
||
</el-form-item>
|
||
</el-form>
|
||
|
||
<template #footer>
|
||
<el-button @click="forgotPwdVisible = false" :disabled="forgotLoading">
|
||
取消
|
||
</el-button>
|
||
<el-button
|
||
type="primary"
|
||
:loading="forgotLoading"
|
||
:disabled="sendingCode"
|
||
@click="handleResetPassword"
|
||
>
|
||
重置密码
|
||
</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<footer class="page-footer">© 2026 天工科技</footer>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, reactive, computed, onBeforeUnmount, nextTick, watch } from 'vue'
|
||
import { useRouter, useRoute } from 'vue-router'
|
||
import { ElMessage, ElNotification } from 'element-plus'
|
||
import { User as UserIcon, Lock as LockIcon, Key as KeyIcon, Message as MessageIcon } from '@element-plus/icons-vue'
|
||
import type { FormInstance, FormRules } from 'element-plus'
|
||
import { useUserStore } from '@/stores/user'
|
||
import api from '@/api'
|
||
|
||
// ── types ──────────────────────────────────────────
|
||
type LoginState = 'idle' | 'validating' | 'loading' | 'success' | 'error' | 'disabled'
|
||
|
||
// ── router / store ─────────────────────────────────
|
||
const router = useRouter()
|
||
const route = useRoute()
|
||
const userStore = useUserStore()
|
||
|
||
// ── refs ───────────────────────────────────────────
|
||
const cardRef = ref<HTMLElement | null>(null)
|
||
const loginFormRef = ref<FormInstance | null>(null)
|
||
const registerFormRef = ref<FormInstance | null>(null)
|
||
const forgotFormRef = ref<FormInstance | null>(null)
|
||
const activeTab = ref('login')
|
||
const loginState = ref<LoginState>('idle')
|
||
const failedCount = ref(0)
|
||
const errorMessage = ref('')
|
||
const countdown = ref(60)
|
||
|
||
// ── captcha state ──────────────────────────────────
|
||
const captchaRequired = ref(false) // 后端标记需要验证码
|
||
const captchaId = ref('')
|
||
const captchaImg = ref('')
|
||
const captchaLoading = ref(false)
|
||
|
||
// ── forgot password state ──────────────────────────
|
||
const forgotPwdVisible = ref(false)
|
||
const forgotLoading = ref(false)
|
||
const sendingCode = ref(false)
|
||
const codeCooldown = ref(0)
|
||
let codeTimer: ReturnType<typeof setInterval> | null = null
|
||
|
||
const forgotForm = reactive({
|
||
email: '',
|
||
code: '',
|
||
newPassword: '',
|
||
confirmPassword: '',
|
||
})
|
||
|
||
// ── forms ──────────────────────────────────────────
|
||
const loginForm = reactive({
|
||
username: '',
|
||
password: '',
|
||
captcha: '',
|
||
rememberMe: false,
|
||
})
|
||
|
||
const registerForm = reactive({
|
||
username: '',
|
||
email: '',
|
||
password: '',
|
||
confirmPassword: '',
|
||
})
|
||
|
||
// ── validation rules ───────────────────────────────
|
||
const loginRules: FormRules = {
|
||
username: [
|
||
{ required: true, message: '请输入用户名', trigger: ['blur', 'change'] },
|
||
{ min: 3, message: '用户名不少于 3 个字符', trigger: ['blur', 'change'] },
|
||
],
|
||
password: [
|
||
{ required: true, message: '请输入密码', trigger: ['blur', 'change'] },
|
||
{ min: 6, message: '密码不少于 6 个字符', trigger: ['blur', 'change'] },
|
||
],
|
||
captcha: [
|
||
{ required: true, message: '请输入验证码', trigger: ['blur', 'change'] },
|
||
{ pattern: /^[a-zA-Z0-9]{4,6}$/, message: '验证码为 4-6 位字母或数字', trigger: ['blur', 'change'] },
|
||
],
|
||
}
|
||
|
||
const validateConfirmPwd = (_rule: any, value: string, callback: Function) => {
|
||
if (value !== registerForm.password) {
|
||
callback(new Error('两次输入的密码不一致'))
|
||
} else {
|
||
callback()
|
||
}
|
||
}
|
||
|
||
const registerRules: FormRules = {
|
||
username: [
|
||
{ required: true, message: '请输入用户名', trigger: ['blur', 'change'] },
|
||
{ min: 3, max: 32, message: '用户名 3-32 个字符', trigger: ['blur', 'change'] },
|
||
],
|
||
email: [
|
||
{ required: true, message: '请输入邮箱', trigger: ['blur', 'change'] },
|
||
{ type: 'email', message: '邮箱格式不正确', trigger: ['blur', 'change'] },
|
||
],
|
||
password: [
|
||
{ required: true, message: '请输入密码', trigger: ['blur', 'change'] },
|
||
{ min: 6, max: 32, message: '密码 6-32 位', trigger: ['blur', 'change'] },
|
||
],
|
||
confirmPassword: [
|
||
{ required: true, message: '请确认密码', trigger: ['blur', 'change'] },
|
||
{ validator: validateConfirmPwd, trigger: ['blur', 'change'] },
|
||
],
|
||
}
|
||
|
||
// ── computed ───────────────────────────────────────
|
||
const showCaptcha = computed(() => captchaRequired.value || failedCount.value >= 3)
|
||
const isSubmitDisabled = computed(() => loginState.value === 'disabled')
|
||
const isFormDisabled = computed(() => loginState.value === 'loading' || loginState.value === 'disabled')
|
||
|
||
// ── captcha ────────────────────────────────────────
|
||
async function fetchCaptcha() {
|
||
captchaLoading.value = true
|
||
try {
|
||
const { data } = await api.get('/api/v1/auth/captcha', { skipErrorHandler: true } as any)
|
||
captchaId.value = data.captcha_id
|
||
captchaImg.value = data.image
|
||
loginForm.captcha = ''
|
||
} catch {
|
||
// 获取失败静默忽略,用户可点击图片重试
|
||
} finally {
|
||
captchaLoading.value = false
|
||
}
|
||
}
|
||
|
||
// 验证码从隐藏变为显示时,自动拉取一张
|
||
watch(showCaptcha, (visible) => {
|
||
if (visible && !captchaImg.value) fetchCaptcha()
|
||
})
|
||
|
||
const submitText = computed(() => {
|
||
if (loginState.value === 'disabled') return `请 ${countdown.value} 秒后重试`
|
||
if (loginState.value === 'loading') return activeTab.value === 'login' ? '登录中...' : '注册中...'
|
||
if (loginState.value === 'success') return '成功 ✓'
|
||
return activeTab.value === 'login' ? '登 录' : '注 册'
|
||
})
|
||
|
||
// ── state machine ──────────────────────────────────
|
||
const transitions: Record<LoginState, LoginState[]> = {
|
||
idle: ['validating'],
|
||
validating: ['loading', 'idle'],
|
||
loading: ['success', 'error'],
|
||
success: ['idle'],
|
||
error: ['idle', 'disabled'],
|
||
disabled: ['idle'],
|
||
}
|
||
|
||
function setState(s: LoginState) {
|
||
if (transitions[loginState.value].includes(s)) {
|
||
loginState.value = s
|
||
}
|
||
}
|
||
|
||
// ── form validation ────────────────────────────────
|
||
async function validateForm(): Promise<boolean> {
|
||
const formRef = activeTab.value === 'login' ? loginFormRef.value : registerFormRef.value
|
||
if (!formRef) return false
|
||
try {
|
||
const valid = await formRef.validate()
|
||
if (!valid) {
|
||
await nextTick()
|
||
const el = cardRef.value?.querySelector('.is-error')
|
||
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||
return false
|
||
}
|
||
return true
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
// ── login ──────────────────────────────────────────
|
||
async function handleLogin() {
|
||
if (loginState.value !== 'idle' && loginState.value !== 'error') return
|
||
setState('validating')
|
||
const valid = await validateForm()
|
||
if (!valid) { setState('idle'); return }
|
||
|
||
setState('loading')
|
||
errorMessage.value = ''
|
||
|
||
try {
|
||
await userStore.login(loginForm.username.trim(), loginForm.password, {
|
||
captchaId: captchaId.value,
|
||
captcha: loginForm.captcha,
|
||
rememberMe: loginForm.rememberMe,
|
||
})
|
||
failedCount.value = 0
|
||
captchaRequired.value = false
|
||
captchaId.value = ''
|
||
captchaImg.value = ''
|
||
loginForm.captcha = ''
|
||
setState('success')
|
||
ElMessage.success('登录成功')
|
||
setTimeout(() => {
|
||
const redirect = (route.query.redirect as string) || '/'
|
||
router.push(redirect)
|
||
}, 600)
|
||
} catch (error: any) {
|
||
failedCount.value++
|
||
|
||
// 后端 detail 可能是字符串或 { message, captcha_required }
|
||
const raw = error?.response?.data?.detail
|
||
const isObj = raw && typeof raw === 'object'
|
||
const detail = (isObj ? raw.message : raw) || error?.message || '用户名或密码错误'
|
||
if (isObj && raw.captcha_required) captchaRequired.value = true
|
||
|
||
// 需要验证码时刷新图片,清空旧输入
|
||
if (showCaptcha.value) {
|
||
loginForm.captcha = ''
|
||
await fetchCaptcha()
|
||
}
|
||
|
||
// ── 渐进式错误提示 ──
|
||
if (failedCount.value >= 5) {
|
||
// 第5次:15秒短冷却 + 联系管理员入口
|
||
errorMessage.value = '多次尝试失败,请 15 秒后重试。如忘记密码可重置,或联系管理员。'
|
||
setState('disabled')
|
||
startCooldown(15) // 15秒而非60秒
|
||
} else if (failedCount.value >= 3) {
|
||
// 第3-4次:显示忘记密码提示
|
||
errorMessage.value = `${detail}。忘记密码?点击下方"忘记密码"进行重置。`
|
||
setState('error')
|
||
} else {
|
||
// 第1-2次:通用错误提示
|
||
errorMessage.value = detail
|
||
setState('error')
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── register ───────────────────────────────────────
|
||
async function handleRegister() {
|
||
if (loginState.value !== 'idle' && loginState.value !== 'error') return
|
||
setState('validating')
|
||
const valid = await validateForm()
|
||
if (!valid) { setState('idle'); return }
|
||
|
||
setState('loading')
|
||
errorMessage.value = ''
|
||
|
||
try {
|
||
await userStore.register(
|
||
registerForm.username.trim(),
|
||
registerForm.email.trim(),
|
||
registerForm.password,
|
||
)
|
||
setState('success')
|
||
ElMessage.success('注册成功,请登录')
|
||
// 自动切换到登录,填入用户名
|
||
loginForm.username = registerForm.username.trim()
|
||
registerForm.username = ''
|
||
registerForm.email = ''
|
||
registerForm.password = ''
|
||
registerForm.confirmPassword = ''
|
||
activeTab.value = 'login'
|
||
setState('idle')
|
||
} catch (error: any) {
|
||
failedCount.value++
|
||
const detail = error?.response?.data?.detail || error?.message || '注册失败'
|
||
errorMessage.value = detail
|
||
setState('error')
|
||
}
|
||
}
|
||
|
||
// ── cooldown ───────────────────────────────────────
|
||
let timer: ReturnType<typeof setInterval> | null = null
|
||
|
||
function startCooldown(seconds = 60) {
|
||
countdown.value = seconds
|
||
clearTimer()
|
||
timer = setInterval(() => {
|
||
countdown.value--
|
||
if (countdown.value <= 0) {
|
||
clearTimer()
|
||
failedCount.value = 0
|
||
errorMessage.value = ''
|
||
setState('idle')
|
||
}
|
||
}, 1000)
|
||
}
|
||
|
||
function clearTimer() {
|
||
if (timer) { clearInterval(timer); timer = null }
|
||
}
|
||
|
||
// ── misc ───────────────────────────────────────────
|
||
function dismissError() {
|
||
errorMessage.value = ''
|
||
if (loginState.value === 'error') setState('idle')
|
||
}
|
||
|
||
function onEnter() {
|
||
if (loginState.value === 'idle' || loginState.value === 'error') {
|
||
activeTab.value === 'login' ? handleLogin() : handleRegister()
|
||
}
|
||
}
|
||
|
||
function onForgotPwd() {
|
||
// 重置表单
|
||
forgotForm.email = ''
|
||
forgotForm.code = ''
|
||
forgotForm.newPassword = ''
|
||
forgotForm.confirmPassword = ''
|
||
forgotFormRef.value?.clearValidate()
|
||
forgotPwdVisible.value = true
|
||
}
|
||
|
||
// ── forgot password ────────────────────────────────
|
||
const validateForgotConfirmPwd = (_rule: any, value: string, callback: Function) => {
|
||
if (value !== forgotForm.newPassword) {
|
||
callback(new Error('两次输入的密码不一致'))
|
||
} else {
|
||
callback()
|
||
}
|
||
}
|
||
|
||
const forgotRules: FormRules = {
|
||
email: [
|
||
{ required: true, message: '请输入注册邮箱', trigger: ['blur', 'change'] },
|
||
{ type: 'email', message: '邮箱格式不正确', trigger: ['blur', 'change'] },
|
||
],
|
||
code: [
|
||
{ required: true, message: '请输入验证码', trigger: ['blur', 'change'] },
|
||
{ pattern: /^\d{6}$/, message: '验证码为6位数字', trigger: ['blur', 'change'] },
|
||
],
|
||
newPassword: [
|
||
{ required: true, message: '请输入新密码', trigger: ['blur', 'change'] },
|
||
{ min: 6, max: 32, message: '密码 6-32 位', trigger: ['blur', 'change'] },
|
||
],
|
||
confirmPassword: [
|
||
{ required: true, message: '请确认新密码', trigger: ['blur', 'change'] },
|
||
{ validator: validateForgotConfirmPwd, trigger: ['blur', 'change'] },
|
||
],
|
||
}
|
||
|
||
async function sendResetCode() {
|
||
if (!forgotForm.email) {
|
||
ElMessage.warning('请先输入邮箱')
|
||
return
|
||
}
|
||
const emailPattern = /^[^@]+@[^@]+\.[^@]+$/
|
||
if (!emailPattern.test(forgotForm.email)) {
|
||
ElMessage.warning('邮箱格式不正确')
|
||
return
|
||
}
|
||
|
||
sendingCode.value = true
|
||
try {
|
||
const response = await fetch('/api/v1/auth/forgot-password', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ email: forgotForm.email.trim() }),
|
||
})
|
||
const data = await response.json()
|
||
if (!response.ok) {
|
||
throw new Error(data.detail || '发送失败')
|
||
}
|
||
ElMessage.success(data.message || '验证码已发送')
|
||
// 启动 60 秒冷却
|
||
codeCooldown.value = 60
|
||
codeTimer = setInterval(() => {
|
||
codeCooldown.value--
|
||
if (codeCooldown.value <= 0) {
|
||
clearInterval(codeTimer!)
|
||
codeTimer = null
|
||
}
|
||
}, 1000)
|
||
} catch (error: any) {
|
||
ElMessage.error(error.message || '发送失败')
|
||
} finally {
|
||
sendingCode.value = false
|
||
}
|
||
}
|
||
|
||
async function handleResetPassword() {
|
||
if (forgotLoading.value) return
|
||
const valid = await forgotFormRef.value?.validate().catch(() => false)
|
||
if (!valid) return
|
||
|
||
forgotLoading.value = true
|
||
try {
|
||
const response = await fetch('/api/v1/auth/reset-password', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
email: forgotForm.email.trim(),
|
||
code: forgotForm.code.trim(),
|
||
new_password: forgotForm.newPassword,
|
||
}),
|
||
})
|
||
const data = await response.json()
|
||
if (!response.ok) {
|
||
throw new Error(data.detail || '重置失败')
|
||
}
|
||
ElMessage.success('密码重置成功,请使用新密码登录')
|
||
forgotPwdVisible.value = false
|
||
} catch (error: any) {
|
||
ElMessage.error(error.message || '重置失败')
|
||
} finally {
|
||
forgotLoading.value = false
|
||
}
|
||
}
|
||
|
||
onBeforeUnmount(() => {
|
||
clearTimer()
|
||
if (codeTimer) { clearInterval(codeTimer); codeTimer = null }
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* ── 页面背景 ───────────────────────────────────── */
|
||
.login-page {
|
||
position: relative;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 100dvh;
|
||
padding: 24px 16px;
|
||
background: linear-gradient(135deg, #f0f5ff 0%, #e8f0fe 30%, #f5f7fa 70%, #eef3fc 100%);
|
||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
||
'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||
}
|
||
|
||
/* 背景装饰 */
|
||
.bg-decor { position: fixed; inset: 0; pointer-events: none; overflow: hidden; }
|
||
.bg-circle { position: absolute; border-radius: 50%; opacity: 0.06; background: #409EFF; }
|
||
.bg-circle--1 { width: 600px; height: 600px; top: -200px; right: -150px; }
|
||
.bg-circle--2 { width: 400px; height: 400px; bottom: -120px; left: -100px; }
|
||
|
||
/* ── 卡片 ───────────────────────────────────────── */
|
||
.login-card {
|
||
position: relative;
|
||
z-index: 1;
|
||
width: min(92vw, 440px);
|
||
padding: 36px 32px 28px;
|
||
background: #fff;
|
||
border-radius: 16px;
|
||
box-shadow: 0 4px 32px rgba(64, 158, 255, 0.1), 0 1px 4px rgba(0, 0, 0, 0.04);
|
||
animation: card-in 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||
}
|
||
@keyframes card-in { from { opacity: 0; transform: translateY(20px) scale(0.97); } to { opacity: 1; transform: none; } }
|
||
|
||
/* ── 品牌头 ─────────────────────────────────────── */
|
||
.card-brand { text-align: center; margin-bottom: 8px; }
|
||
.brand-title { margin: 0; font-size: 22px; font-weight: 700; color: #303133; letter-spacing: 2px; }
|
||
.brand-desc { margin: 4px 0 0; font-size: 13px; color: #909399; }
|
||
|
||
/* ── 错误 ───────────────────────────────────────── */
|
||
.error-alert { margin-bottom: 12px; }
|
||
|
||
/* ── Tabs ───────────────────────────────────────── */
|
||
.login-tabs { margin-top: 4px; }
|
||
.login-tabs :deep(.el-tabs__header) { margin-bottom: 16px; }
|
||
.login-tabs :deep(.el-tabs__nav-wrap::after) { height: 1px; }
|
||
.login-tabs :deep(.el-tabs__item) { font-size: 15px; padding: 0 20px; }
|
||
|
||
/* ── 表单 ───────────────────────────────────────── */
|
||
:deep(.el-form-item) { margin-bottom: 16px; }
|
||
|
||
:deep(.el-input__wrapper) {
|
||
border-radius: 8px;
|
||
box-shadow: 0 0 0 1px #DCDFE6 inset;
|
||
transition: box-shadow 0.25s;
|
||
}
|
||
:deep(.el-input.is-focus .el-input__wrapper) {
|
||
box-shadow: 0 0 0 1px #409EFF inset, 0 0 0 3px rgba(64, 158, 255, 0.12);
|
||
}
|
||
:deep(.el-form-item.is-error .el-input__wrapper) {
|
||
box-shadow: 0 0 0 1px #F56C6C inset;
|
||
}
|
||
|
||
.form-extra {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 16px;
|
||
font-size: 13px;
|
||
}
|
||
.link { color: #409EFF; text-decoration: none; }
|
||
.link:hover { color: #66B1FF; }
|
||
|
||
.submit-btn { width: 100%; height: 44px; font-size: 16px; font-weight: 500; letter-spacing: 6px; }
|
||
|
||
/* ── 页脚 ───────────────────────────────────────── */
|
||
.page-footer { position: relative; z-index: 1; margin-top: 24px; font-size: 12px; color: #C0C4CC; }
|
||
|
||
/* ── 忘记密码弹窗 ───────────────────────────────── */
|
||
:deep(.el-input-group__append) { padding: 0; }
|
||
:deep(.el-input-group__append .el-button) {
|
||
margin: -1px -1px -1px 0;
|
||
border-radius: 0 7px 7px 0;
|
||
height: calc(100% + 2px);
|
||
font-size: 12px;
|
||
}
|
||
|
||
/* ── 响应式 ─────────────────────────────────────── */
|
||
@media (max-width: 480px) {
|
||
.login-card { width: 100%; padding: 24px 18px; border-radius: 12px; }
|
||
.brand-title { font-size: 20px; }
|
||
.page-footer { margin-top: 16px; }
|
||
}
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.login-card { animation: none; }
|
||
}
|
||
</style>
|