Files
web-fusion/src/pages/LoginPage.vue
2026-03-05 14:47:02 +08:00

585 lines
30 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="min-h-screen bg-[#F0F4F8] flex items-center justify-center font-sans antialiased relative overflow-hidden transition-colors duration-300">
<div class="absolute top-8 right-8 flex gap-4 z-20">
<!-- Language Selector -->
<el-dropdown>
<div
class="bg-white/95 border border-slate-200 rounded-2xl px-4 py-2 shadow-lg cursor-pointer hover:-translate-y-px transition-all flex items-center gap-2">
<span class="text-sm font-semibold text-slate-700">{{ locale === 'zh' ? '中文' : 'English' }}</span>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="switchLanguage('zh')">中文</el-dropdown-item>
<el-dropdown-item @click="switchLanguage('en')">English</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<!-- Background Shapes -->
<div class="absolute top-[-200px] right-[-200px] w-[800px] h-[800px] rounded-full mix-blend-multiply filter blur-3xl opacity-70 animate-pulse"
style="background: radial-gradient(circle, rgba(79, 129, 230, 0.2) 0%, rgba(79, 129, 230, 0) 70%)" />
<div class="absolute bottom-[-100px] left-[-100px] w-[600px] h-[600px] rounded-full mix-blend-multiply filter blur-3xl opacity-70 animate-pulse"
style="background: radial-gradient(circle, rgba(236, 72, 153, 0.15) 0%, rgba(236, 72, 153, 0) 70%); animation-duration: 4s" />
<div class="container mx-auto px-4 z-10 relative flex justify-center items-center h-full">
<div
class="bg-white/70 backdrop-blur-xl w-full max-w-5xl rounded-[2rem] overflow-hidden flex flex-col md:flex-row shadow-2xl border border-white/20">
<!-- Left Side: Form -->
<div class="w-full md:w-1/2 p-8 md:p-12 lg:p-16 flex flex-col justify-center">
<!-- Header / Logo注册页隐藏避免表单被挤出屏幕 -->
<div v-show="mode === 'login'" class="flex justify-center">
<img :src="logo" alt="Logo" class="w-[200px] h-auto" />
</div>
<!-- ==================== 登录表单 ==================== -->
<template v-if="mode === 'login'">
<div class="mb-8">
<h1 class="text-2xl font-bold text-gray-800 mb-2">欢迎回来</h1>
<p class="text-gray-500 text-sm">请输入您的账户信息以登录平台</p>
</div>
<form @submit.prevent="handleSubmit" class="space-y-5">
<!-- 租户号 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">租户号</label>
<div class="relative rounded-md shadow-sm">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
</div>
<input type="text" v-model="credentials.tenantName" placeholder="请输入租户号"
class="focus:ring-[#4F81E6] focus:border-[#4F81E6] block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-3 transition-colors bg-gray-50 focus:bg-white" />
</div>
</div>
<!-- 账号 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">账号</label>
<div class="relative rounded-md shadow-sm">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<input type="text" v-model="credentials.username" placeholder="请输入账号"
class="focus:ring-[#4F81E6] focus:border-[#4F81E6] block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-3 transition-colors bg-gray-50 focus:bg-white" />
</div>
</div>
<!-- 密码 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">密码</label>
<div class="relative rounded-md shadow-sm">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<input type="password" v-model="credentials.password" placeholder="请输入密码"
class="focus:ring-[#4F81E6] focus:border-[#4F81E6] block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-3 transition-colors bg-gray-50 focus:bg-white" />
</div>
</div>
<!-- 错误提示 -->
<div v-if="error"
class="flex items-center gap-2 text-red-600 text-xs bg-red-50 p-2.5 rounded-lg">
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ error }}
</div>
<!-- 登录按钮 -->
<div class="pt-2">
<button type="submit" :disabled="isLoading"
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-gradient-to-r from-blue-500 to-[#4F81E6] hover:from-blue-600 hover:to-[#3A6BC7] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#4F81E6] transition-all transform hover:scale-[1.01] disabled:opacity-70 disabled:cursor-not-allowed">
<template v-if="isLoading">
<span class="flex items-center gap-2">
<svg class="w-4 h-4 animate-spin" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
登录中
</span>
</template>
<template v-else>
</template>
</button>
</div>
</form>
<!-- 切换到注册 -->
<div class="mt-6 text-center">
<span class="text-gray-500 text-sm">还没有账号</span>
<button @click="switchMode('register')"
class="text-[#4F81E6] text-sm font-medium hover:text-blue-700 ml-1 transition-colors">
立即注册
</button>
</div>
</template>
<!-- ==================== 注册表单 ==================== -->
<template v-else>
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-800 mb-2">注册新账号</h1>
<p class="text-gray-500 text-sm">填写以下信息创建您的租户账号</p>
</div>
<form @submit.prevent="handleRegister" class="space-y-4">
<!-- 租户名称 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">租户名称</label>
<div class="relative rounded-md shadow-sm">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
</div>
<input type="text" v-model="registerForm.name" placeholder="2-20个字符"
class="focus:ring-[#4F81E6] focus:border-[#4F81E6] block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-2.5 transition-colors bg-gray-50 focus:bg-white" />
</div>
</div>
<!-- 联系人 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">联系人</label>
<div class="relative rounded-md shadow-sm">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<input type="text" v-model="registerForm.contactName" placeholder="请输入联系人姓名"
class="focus:ring-[#4F81E6] focus:border-[#4F81E6] block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-2.5 transition-colors bg-gray-50 focus:bg-white" />
</div>
</div>
<!-- 联系手机 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">联系手机</label>
<div class="relative rounded-md shadow-sm">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</div>
<input type="tel" v-model="registerForm.contactMobile" placeholder="请输入手机号码"
class="focus:ring-[#4F81E6] focus:border-[#4F81E6] block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-2.5 transition-colors bg-gray-50 focus:bg-white" />
</div>
</div>
<!-- 账号 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">账号</label>
<div class="relative rounded-md shadow-sm">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<input type="text" v-model="registerForm.username" placeholder="4-30个字符"
class="focus:ring-[#4F81E6] focus:border-[#4F81E6] block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-2.5 transition-colors bg-gray-50 focus:bg-white" />
</div>
</div>
<!-- 密码 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">密码</label>
<div class="relative rounded-md shadow-sm">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<input type="password" v-model="registerForm.password" placeholder="5-20个字符"
class="focus:ring-[#4F81E6] focus:border-[#4F81E6] block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-2.5 transition-colors bg-gray-50 focus:bg-white" />
</div>
</div>
<!-- 确认密码 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">确认密码</label>
<div class="relative rounded-md shadow-sm">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<input type="password" v-model="registerForm.confirmPassword" placeholder="请再次输入密码"
class="focus:ring-[#4F81E6] focus:border-[#4F81E6] block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-2.5 transition-colors bg-gray-50 focus:bg-white" />
</div>
</div>
<!-- Turnstile 人机验证 -->
<div class="flex justify-center">
<div id="turnstile-container"></div>
</div>
<!-- 错误提示 -->
<div v-if="error"
class="flex items-center gap-2 text-red-600 text-xs bg-red-50 p-2.5 rounded-lg">
<svg class="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ error }}
</div>
<!-- 注册按钮 -->
<div class="pt-1">
<button type="submit" :disabled="isLoading"
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-gradient-to-r from-blue-500 to-[#4F81E6] hover:from-blue-600 hover:to-[#3A6BC7] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#4F81E6] transition-all transform hover:scale-[1.01] disabled:opacity-70 disabled:cursor-not-allowed">
<template v-if="isLoading">
<span class="flex items-center gap-2">
<svg class="w-4 h-4 animate-spin" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
注册中
</span>
</template>
<template v-else>
</template>
</button>
</div>
</form>
<!-- 切换到登录 -->
<div class="mt-4 text-center">
<span class="text-gray-500 text-sm">已有账号</span>
<button @click="switchMode('login')"
class="text-[#4F81E6] text-sm font-medium hover:text-blue-700 ml-1 transition-colors">
返回登录
</button>
</div>
</template>
<div class="mt-6 text-center">
<span class="text-gray-300 text-xs font-mono">v{{ version }}</span>
</div>
</div>
<!-- Right Side: Illustration -->
<div
class="hidden md:flex w-1/2 bg-blue-50/50 relative items-center justify-center p-12 overflow-hidden">
<!-- Decorative Circle matches login.html style -->
<div
class="absolute w-96 h-96 bg-blue-100 rounded-full blur-3xl opacity-30 top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2">
</div>
<div class="relative z-10 w-full max-w-sm">
<img :src="illustration" alt="Illustration" class="w-full h-auto drop-shadow-xl animate-float"
style="animation: float 6s ease-in-out infinite" />
<div class="text-center mt-8">
<h3 class="text-xl font-bold text-gray-800 mb-2">连接全球创意</h3>
<p class="text-gray-500 text-sm">高效管理您的TikTok矩阵释放无限潜能</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { isElectron, getAppVersion } from '../utils/electronBridge'
import { setUser, setToken, setUserPass, getUserPass, setPermissions } from '@/utils/storage'
import { tenantRegister } from '@/api/register'
import logo from '../assets/logo.png'
import illustration from '../assets/illustration.webp'
const emit = defineEmits(['loginSuccess'])
const { locale } = useI18n()
// 当前模式login / register
const mode = ref('login')
// Language Switcher
const switchLanguage = (lang) => {
locale.value = lang
localStorage.setItem('lang', lang)
}
// 切换登录/注册
const switchMode = (target) => {
mode.value = target
error.value = ''
}
// ==================== 通用状态 ====================
const isLoading = ref(false)
const error = ref('')
const version = ref('')
// ==================== 登录相关 ====================
const credentials = ref({
tenantName: '',
username: '',
password: '',
})
onMounted(() => {
getAppVersion().then(v => {
version.value = v
})
try {
const saved = getUserPass()
if (saved) {
credentials.value = {
tenantName: saved.tenantName || '',
username: saved.username || saved.userId || '',
password: saved.password || '',
}
}
} catch { } // eslint-disable-line no-empty
})
const handleSubmit = async () => {
if (!credentials.value.tenantName || !credentials.value.username || !credentials.value.password) {
error.value = '请填写所有字段'
return
}
isLoading.value = true
error.value = ''
try {
setUserPass(credentials.value)
console.log('[LoginPage] 开始登录...', credentials.value)
if (!isElectron()) {
error.value = '非 Electron 环境,无法进行真实登录'
isLoading.value = false
return
}
const result = await window.electronAPI.login({ ...credentials.value })
console.log('[LoginPage] 登录结果:', result)
if (result.success && result.user) {
setToken(result.user.tokenValue);
setUser(result.user);
setPermissions({
bigBrother: result.user.bigBrother,
crawl: result.user.crawl,
webAi: result.user.webAi,
});
emit('loginSuccess')
} else {
error.value = result.error || '登录失败'
}
} catch (err) {
error.value = err.message || '登录失败'
} finally {
isLoading.value = false
}
}
// ==================== 注册相关 ====================
const registerForm = ref({
name: '',
contactName: '',
contactMobile: '',
username: '',
password: '',
confirmPassword: '',
})
// Turnstile 人机验证
const TURNSTILE_SITE_KEY = '0x4AAAAAACYSAf0bQMQ347Pz'
const turnstileToken = ref('')
const turnstileWidgetId = ref('')
const initTurnstile = () => {
const waitForTurnstile = (retries = 0) => {
if (retries > 50) {
console.error('[Turnstile] SDK 加载超时')
return
}
if (window.turnstile && !turnstileWidgetId.value) {
const container = document.getElementById('turnstile-container')
if (!container) return
try {
turnstileWidgetId.value = window.turnstile.render('#turnstile-container', {
sitekey: TURNSTILE_SITE_KEY,
theme: 'light',
callback: (token) => {
turnstileToken.value = token
},
'error-callback': () => {
turnstileToken.value = ''
},
'expired-callback': () => {
turnstileToken.value = ''
},
})
} catch (err) {
console.error('[Turnstile] 渲染错误:', err)
}
} else if (!window.turnstile) {
setTimeout(() => waitForTurnstile(retries + 1), 100)
}
}
waitForTurnstile()
}
const resetTurnstile = () => {
if (window.turnstile && turnstileWidgetId.value) {
window.turnstile.reset(turnstileWidgetId.value)
turnstileToken.value = ''
}
}
const destroyTurnstile = () => {
if (window.turnstile && turnstileWidgetId.value) {
window.turnstile.remove(turnstileWidgetId.value)
turnstileWidgetId.value = ''
turnstileToken.value = ''
}
}
// 切换到注册页时初始化 Turnstile切回登录时销毁
watch(mode, (newMode) => {
if (newMode === 'register') {
nextTick(() => initTurnstile())
} else {
destroyTurnstile()
}
})
const handleRegister = async () => {
const form = registerForm.value
// 表单校验
if (!form.name || !form.contactName || !form.contactMobile || !form.username || !form.password || !form.confirmPassword) {
error.value = '请填写所有字段'
return
}
if (form.name.length < 2 || form.name.length > 20) {
error.value = '租户名称长度必须介于 2 和 20 之间'
return
}
if (!/^1[3-9]\d{9}$/.test(form.contactMobile)) {
error.value = '请输入正确的手机号码'
return
}
if (form.username.length < 4 || form.username.length > 30) {
error.value = '账号长度必须介于 4 和 30 之间'
return
}
if (form.password.length < 5 || form.password.length > 20) {
error.value = '密码长度必须介于 5 和 20 之间'
return
}
if (/[<>"'|\\]/.test(form.password)) {
error.value = '密码不能包含非法字符:< > " \' \\ |'
return
}
if (form.password !== form.confirmPassword) {
error.value = '两次输入的密码不一致'
return
}
if (!turnstileToken.value) {
error.value = '请完成人机验证'
return
}
isLoading.value = true
error.value = ''
try {
await tenantRegister({
name: form.name,
contactName: form.contactName,
contactMobile: form.contactMobile,
username: form.username,
password: form.password,
turnstileToken: turnstileToken.value,
})
ElMessage.success('注册成功,请登录')
// 将租户名和账号回填到登录表单
credentials.value.tenantName = form.name
credentials.value.username = form.username
credentials.value.password = ''
// 清空注册表单
registerForm.value = {
name: '',
contactName: '',
contactMobile: '',
username: '',
password: '',
confirmPassword: '',
}
// 切换回登录
mode.value = 'login'
} catch (err) {
error.value = err.message || '注册失败,请稍后重试'
resetTurnstile()
} finally {
isLoading.value = false
}
}
</script>
<style>
@keyframes float {
0% {
transform: translateY(0px);
}
50% {
transform: translateY(-15px);
}
100% {
transform: translateY(0px);
}
}
</style>