928 lines
24 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>
<!-- Login Page v2 -->
<div class="login-page">
<!-- 顶部导航栏 -->
<div class="login-header-bar">
<div class="header-left">
<div class="logo-container">
<img v-if="logoUrl" :src="logoUrl" alt="Logo" class="logo-image" />
<Icon v-else name="home" size="lg" color="#31f08b" />
</div>
<span class="site-name">{{ siteName }}</span>
</div>
<div class="header-right">
<!-- 可以在这里添加其他按钮,如"帮助"等 -->
</div>
</div>
<!-- 登录 / 注册 表单区域 -->
<div class="login-container">
<div class="login-box">
<h1 class="login-title">{{ activeTab === 'login' ? '登录' : '注册' }}</h1>
<!-- 登录表单 -->
<a-form
v-if="activeTab === 'login'"
:model="loginForm"
:rules="loginRules"
@finish="handleLogin"
layout="vertical"
class="login-form"
>
<a-form-item name="username">
<a-input
v-model:value="loginForm.username"
size="large"
placeholder="请输入用户名"
class="login-input"
/>
</a-form-item>
<a-form-item name="password">
<a-input-password
v-model:value="loginForm.password"
size="large"
placeholder="请输入密码"
class="login-input"
/>
</a-form-item>
<a-form-item>
<div class="agreement-checkbox">
<a-checkbox v-model:checked="agreed">
<span class="agreement-text">
我已阅读并同意
<a href="#" class="agreement-link">服务条款</a>
和
<a href="#" class="agreement-link">隐私协议</a>
</span>
</a-checkbox>
</div>
</a-form-item>
<a-form-item>
<a-button
type="primary"
size="large"
block
:loading="loading"
:disabled="!agreed"
html-type="submit"
class="login-button"
>
{{ loading ? '登录中...' : '登录' }}
</a-button>
</a-form-item>
<div class="auth-switch">
<span class="switch-text">还没有账号?</span>
<button type="button" class="switch-link" @click="activeTab = 'register'">
注册账号
</button>
</div>
</a-form>
<!-- 注册表单 -->
<a-form
v-else
:model="registerForm"
:rules="registerRules"
@finish="handleRegister"
layout="vertical"
class="login-form register-form"
>
<a-form-item name="realName">
<a-input
v-model:value="registerForm.realName"
size="large"
placeholder="请输入公司花名"
class="login-input"
/>
</a-form-item>
<a-form-item name="username">
<a-input
v-model:value="registerForm.username"
size="large"
placeholder="请输入用户名"
class="login-input"
/>
</a-form-item>
<a-form-item name="password">
<a-input-password
v-model:value="registerForm.password"
size="large"
placeholder="请输入密码"
class="login-input"
/>
</a-form-item>
<a-form-item name="confirmPassword">
<a-input-password
v-model:value="registerForm.confirmPassword"
size="large"
placeholder="请再次输入密码"
class="login-input"
/>
</a-form-item>
<a-form-item name="roleId" label="选择角色" class="form-item-with-label">
<a-radio-group v-model:value="registerForm.roleId" size="large" class="role-radio-group">
<a-radio :value="6" class="role-radio">
<Icon name="users" size="sm" style="margin-right: 4px; vertical-align: middle;" />
需求方
</a-radio>
<a-radio :value="5" class="role-radio">
<Icon name="paintBrush" size="sm" style="margin-right: 4px; vertical-align: middle;" />
设计师
</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item name="deptId" label="选择部门" class="form-item-with-label">
<a-select
v-model:value="registerForm.deptId"
size="large"
placeholder="请选择部门"
class="login-select"
:options="filteredDepartments"
/>
</a-form-item>
<a-form-item name="email">
<a-input
v-model:value="registerForm.email"
size="large"
placeholder="请输入邮箱(选填)"
class="login-input"
/>
</a-form-item>
<a-form-item name="phone">
<a-input
v-model:value="registerForm.phone"
size="large"
placeholder="请输入手机号(选填)"
class="login-input"
/>
</a-form-item>
<a-form-item>
<a-button
type="primary"
size="large"
block
:loading="registerLoading"
html-type="submit"
class="login-button"
>
{{ registerLoading ? '注册中...' : '注册' }}
</a-button>
</a-form-item>
<div class="auth-switch">
<span class="switch-text">已经有账号?</span>
<button type="button" class="switch-link" @click="activeTab = 'login'">
返回登录
</button>
</div>
</a-form>
<!-- 其他登录方式 -->
<div v-if="activeTab === 'login'" class="alternative-login">
<a-button
v-if="dingTalkLoginEnabled"
block
class="alt-login-button dingtalk-login-button"
@click="handleDingTalkLogin"
:loading="dingTalkLoginLoading"
>
<img
src="https://img.alicdn.com/imgextra/i1/O1CN01CHK9VA1YTzZJxXGf7_!!6000000003022-2-tps-400-400.png"
alt="钉钉"
class="dingtalk-logo"
onerror="this.style.display='none'; this.nextSibling.style.display='inline-block';"
/>
<span class="dingtalk-logo-text" style="display: none;">D</span>
钉钉登录
</a-button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref, onMounted, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useSiteSettingsStore } from '@/stores/siteSettings'
import Icon from '@/components/Icon.vue'
import type { Rule } from 'ant-design-vue/es/form'
import { message } from 'ant-design-vue'
import { register as registerApi } from '@/api/auth'
import * as departmentApi from '@/api/department'
import { isDingTalkFeatureEnabled } from '@/api/dingtalk'
import request from '@/utils/request'
import type { Result } from '@/api/types'
const router = useRouter()
const authStore = useAuthStore()
const siteSettingsStore = useSiteSettingsStore()
const activeTab = ref<'login' | 'register'>('login')
const loading = ref(false)
const registerLoading = ref(false)
const agreed = ref(false)
const loginForm = reactive({
username: '',
password: ''
})
const registerForm = reactive({
username: '',
password: '',
confirmPassword: '',
realName: '',
roleId: 6, // 默认为需求方
deptId: undefined as number | undefined,
email: '',
phone: ''
})
// 钉钉登录
const dingTalkLoginEnabled = ref(false)
const dingTalkLoginLoading = ref(false)
// 部门列表
const departments = ref<any[]>([])
const filteredDepartments = computed(() => {
if (!registerForm.roleId) return []
const deptType = registerForm.roleId === 5 ? 'receiving' : 'demand'
return departments.value
.filter(dept => dept.deptType === deptType)
.map(dept => ({
value: dept.id,
label: dept.deptName
}))
})
const loginRules: Record<string, Rule[]> = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
}
const registerRules: Record<string, Rule[]> = {
realName: [{ required: true, message: '请输入公司花名', trigger: 'blur' }],
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '用户名长度为3-20个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码至少6个字符', trigger: 'blur' }
],
confirmPassword: [
{ required: true, message: '请再次输入密码', trigger: 'blur' },
{
validator: (_rule: Rule, value: string) => {
if (!value) {
return Promise.reject('请再次输入密码')
}
if (value !== registerForm.password) {
return Promise.reject('两次输入的密码不一致')
}
return Promise.resolve()
},
trigger: 'blur'
}
],
roleId: [{ required: true, message: '请选择角色', trigger: 'change' }],
deptId: [{ required: true, message: '请选择部门', trigger: 'change' }]
}
// 监听角色变化,自动清空部门选择
watch(() => registerForm.roleId, () => {
registerForm.deptId = undefined
})
// 使用全局store的网站设置
const siteName = computed(() => siteSettingsStore.siteName)
const logoUrl = computed(() => siteSettingsStore.logoUrl)
// 监听网站设置更新
const handleSiteSettingsUpdate = (event: Event) => {
const customEvent = event as CustomEvent
if (customEvent.detail) {
siteSettingsStore.updateSiteSettings(
customEvent.detail.siteName || 'DesignHub',
customEvent.detail.logoUrl || ''
)
}
}
const handleLogin = async () => {
loading.value = true
try {
const success = await authStore.login(loginForm.username, loginForm.password)
if (success) {
// 登录成功,跳转到首页
router.push('/')
}
} finally {
loading.value = false
}
}
const handleRegister = async () => {
registerLoading.value = true
try {
const res = await registerApi({
username: registerForm.username,
password: registerForm.password,
realName: registerForm.realName,
roleId: registerForm.roleId!,
deptId: registerForm.deptId!,
email: registerForm.email,
phone: registerForm.phone
})
if (res.code === 200) {
message.success('注册成功,请使用新账号登录')
// 切换回登录标签,并自动填充账号密码
activeTab.value = 'login'
loginForm.username = registerForm.username
loginForm.password = registerForm.password
} else {
message.error(res.message || '注册失败')
}
} catch (error: any) {
console.error('注册失败:', error)
message.error(error.message || '注册失败')
} finally {
registerLoading.value = false
}
}
// 加载部门列表
const loadDepartments = async () => {
try {
const res = await departmentApi.getDepartmentList()
if (res.code === 200 && res.data) {
departments.value = res.data
console.log('部门列表加载成功:', departments.value)
}
} catch (error) {
console.error('加载部门列表失败:', error)
message.error('加载部门列表失败,请刷新页面重试')
}
}
// 检查钉钉登录是否启用
const checkDingTalkLogin = async () => {
try {
dingTalkLoginEnabled.value = await isDingTalkFeatureEnabled('login')
console.log('钉钉登录状态:', dingTalkLoginEnabled.value)
} catch (error) {
console.error('检查钉钉登录状态失败:', error)
dingTalkLoginEnabled.value = false
}
}
// 处理钉钉登录
const handleDingTalkLogin = async () => {
dingTalkLoginLoading.value = true
try {
// 获取钉钉登录URL
// 注意:request 拦截器已经处理了 Result 包装,返回的就是 Result 对象
const response = await request.get<Result<{ authUrl: string; appKey: string }>>('/dingtalk/oauth/login', {
params: {
redirectUrl: `${window.location.origin}/auth/dingtalk/callback`
}
})
// response 已经是 Result 对象,直接使用 response.data
if (response.code === 200 && response.data) {
// 跳转到钉钉登录页面
window.location.href = response.data.authUrl
} else {
message.error(response.message || '获取钉钉登录地址失败')
}
} catch (error: any) {
console.error('钉钉登录失败:', error)
message.error(error.response?.data?.message || error.message || '钉钉登录失败,请稍后重试')
} finally {
dingTalkLoginLoading.value = false
}
}
onMounted(() => {
// 加载网站设置
siteSettingsStore.loadSiteSettings()
// 加载部门列表
loadDepartments()
// 检查钉钉登录是否启用
checkDingTalkLogin()
// 监听网站设置更新事件(在加载后监听,确保能接收到事件)
window.addEventListener('siteSettingsUpdated', handleSiteSettingsUpdate)
// 监听storage事件(跨标签页同步)
window.addEventListener('storage', (e) => {
if (e.key === 'siteName' || e.key === 'logoUrl') {
siteSettingsStore.loadSiteSettings()
}
})
})
onUnmounted(() => {
window.removeEventListener('siteSettingsUpdated', handleSiteSettingsUpdate)
})
</script>
<style lang="scss" scoped>
.login-page {
min-height: 100vh;
background: $bg-primary;
position: relative;
}
// 顶部导航栏
.login-header-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: $space-lg $space-xl;
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 10;
.header-left {
display: flex;
align-items: center;
gap: $space-md;
.logo-container {
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
.logo-image {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.site-name {
font-size: $font-size-2xl;
font-weight: $font-weight-semibold;
color: $text-white;
line-height: 60px;
height: 60px;
display: flex;
align-items: center;
}
}
.header-right {
// 可以在这里添加其他按钮
}
}
// 登录容器
.login-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: $space-3xl $space-lg;
}
.login-box {
width: 100%;
max-width: 400px;
animation: slideUp 0.4s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.login-title {
font-size: $font-size-4xl;
font-weight: $font-weight-bold;
color: $text-white;
margin-bottom: $space-3xl;
text-align: left;
}
.login-form {
margin-bottom: $space-xl;
:deep(.ant-form-item) {
margin-bottom: $space-md;
&:last-child {
margin-bottom: 0;
}
}
:deep(.ant-form-item-label) {
display: none;
}
// 注册表单需要显示label
&.register-form {
:deep(.form-item-with-label) {
.ant-form-item-label {
display: block;
padding-bottom: 8px;
label {
color: $text-white;
font-size: $font-size-base;
font-weight: $font-weight-medium;
&::before {
color: $brand-primary !important;
}
}
}
}
}
.login-input {
height: 48px;
line-height: 48px;
background: $bg-secondary !important;
border: 1px solid $border-color !important;
border-radius: $radius-md;
color: $text-white !important;
font-size: $font-size-base;
transition: all 0.2s ease;
text-align: left;
padding: 0 12px;
box-sizing: border-box;
&::placeholder {
color: $text-tertiary !important;
line-height: normal;
}
&:hover {
background: $bg-secondary !important;
border-color: rgba($brand-primary, 0.5) !important;
color: $text-white !important;
}
&:focus,
&.ant-input-focused,
&:focus-visible {
background: $bg-secondary !important;
border-color: $brand-primary !important;
box-shadow: 0 0 0 2px rgba($brand-primary, 0.1) !important;
color: $text-white !important;
outline: none !important;
}
}
:deep(.ant-input-password) {
background: $bg-secondary !important;
border: 1px solid $border-color !important;
border-radius: $radius-md !important;
height: 48px !important;
width: 100% !important;
box-shadow: none !important;
transition: all 0.2s ease;
padding: 0 !important;
display: flex;
align-items: center;
.ant-input {
background: transparent !important;
border: none !important;
box-shadow: none !important;
color: $text-white !important;
height: 48px;
line-height: 48px;
font-size: $font-size-base;
padding: 0 40px 0 12px !important;
text-align: left;
flex: 1;
width: 100%;
box-sizing: border-box;
&::placeholder {
color: $text-tertiary !important;
line-height: normal;
}
&:hover {
background: transparent !important;
color: $text-white !important;
}
&:focus,
&:focus-visible {
background: transparent !important;
color: $text-white !important;
outline: none !important;
box-shadow: none !important;
}
}
.ant-input-password-icon {
color: $text-secondary;
right: 12px;
&:hover {
color: $text-primary;
}
}
&:hover {
background: $bg-secondary !important;
border-color: rgba($brand-primary, 0.5) !important;
}
&.ant-input-password-focused,
&:focus,
&:focus-visible {
background: $bg-secondary !important;
border-color: $brand-primary !important;
box-shadow: 0 0 0 2px rgba($brand-primary, 0.1) !important;
outline: none !important;
}
}
.agreement-checkbox {
margin-bottom: $space-md;
:deep(.ant-checkbox-wrapper) {
color: $text-white;
font-size: $font-size-sm;
.ant-checkbox {
.ant-checkbox-inner {
background-color: $bg-secondary;
border-color: $border-color;
}
&.ant-checkbox-checked .ant-checkbox-inner {
background-color: $brand-primary;
border-color: $brand-primary;
}
}
}
.agreement-text {
color: $text-white;
font-size: $font-size-sm;
}
.agreement-link {
color: $brand-primary;
text-decoration: none;
transition: color 0.2s ease;
&:hover {
color: $brand-hover;
text-decoration: underline;
}
}
}
// 下拉选择框样式
.login-select {
height: 48px;
:deep(.ant-select-selector) {
height: 48px !important;
background: $bg-secondary !important;
border: 1px solid $border-color !important;
border-radius: $radius-md !important;
color: $text-white !important;
padding: 0 12px !important;
.ant-select-selection-item,
.ant-select-selection-placeholder {
line-height: 46px !important;
color: $text-white !important;
}
.ant-select-selection-placeholder {
color: $text-tertiary !important;
}
}
&:hover :deep(.ant-select-selector) {
border-color: rgba($brand-primary, 0.5) !important;
}
:deep(.ant-select-focused .ant-select-selector) {
border-color: $brand-primary !important;
box-shadow: 0 0 0 2px rgba($brand-primary, 0.1) !important;
}
:deep(.ant-select-arrow) {
color: $text-secondary;
}
}
// 角色单选按钮组
.role-radio-group {
display: flex;
gap: 16px;
:deep(.ant-radio-wrapper) {
flex: 1;
padding: 12px 16px;
background: $bg-secondary;
border: 1px solid $border-color;
border-radius: $radius-md;
transition: all 0.2s ease;
color: $text-white;
&:hover {
border-color: rgba($brand-primary, 0.5);
background: rgba($brand-primary, 0.05);
}
.ant-radio {
.ant-radio-inner {
background: $bg-tertiary;
border-color: $border-color;
}
&.ant-radio-checked .ant-radio-inner {
background: $brand-primary;
border-color: $brand-primary;
&::after {
background-color: #1a1d23;
}
}
}
&.ant-radio-wrapper-checked {
border-color: $brand-primary;
background: rgba($brand-primary, 0.1);
}
}
}
.login-button {
height: 48px;
background: $brand-primary !important;
border: none !important;
border-radius: $radius-md;
font-size: $font-size-base;
font-weight: $font-weight-semibold;
color: #000000 !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
text-align: center !important;
transition: all 0.2s ease;
:deep(span) {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
&:hover:not(:disabled) {
background: $brand-hover !important;
border-color: $brand-hover !important;
color: #000000 !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba($brand-primary, 0.3);
}
&:active:not(:disabled) {
background: $brand-pressed !important;
border-color: $brand-pressed !important;
color: #000000 !important;
transform: translateY(0);
}
&:focus:not(:disabled) {
background: $brand-primary !important;
border-color: $brand-primary !important;
color: #000000 !important;
box-shadow: 0 0 0 2px rgba($brand-primary, 0.2);
}
&:disabled {
background: rgba($brand-primary, 0.7) !important;
border-color: rgba($brand-primary, 0.7) !important;
color: #000000 !important;
box-shadow: none;
cursor: not-allowed;
opacity: 0.8;
}
}
}
.auth-switch {
display: flex;
align-items: center;
justify-content: center;
gap: $space-sm;
margin-top: $space-md;
.switch-text {
color: $text-secondary;
font-size: $font-size-sm;
}
.switch-link {
padding: 6px 18px;
border-radius: 999px;
border: 1px solid rgba($brand-primary, 0.6);
background: rgba($brand-primary, 0.1);
color: $brand-primary;
font-size: $font-size-sm;
font-weight: $font-weight-semibold;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba($brand-primary, 0.2);
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba($brand-primary, 0.25);
}
&:active {
transform: translateY(0);
background: rgba($brand-primary, 0.25);
}
}
}
.alternative-login {
margin-top: $space-lg;
.alt-login-button {
height: 48px;
background: $bg-secondary;
border: 1px solid $border-color;
border-radius: $radius-md;
color: $text-white;
font-size: $font-size-base;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
&:hover:not(:disabled) {
background: $bg-tertiary;
border-color: rgba($brand-primary, 0.3);
}
&:disabled {
opacity: 0.5;
}
}
.dingtalk-login-button {
background: #0070f3;
border-color: #0070f3;
color: #ffffff;
&:hover:not(:disabled) {
background: #0051cc;
border-color: #0051cc;
}
.dingtalk-logo {
width: 20px;
height: 20px;
margin-right: 8px;
vertical-align: middle;
cursor: not-allowed;
}
}
}
</style>