Files
aiagent/frontend/src/views/Login.vue
renjianbo f2e65a8fbb feat: virtual company module, team projects, PWA dishes app, and startup scripts overhaul
- 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>
2026-07-10 22:37:56 +08:00

804 lines
27 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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">&copy; 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>