Merge pull request 'feat/auth-ui-refinement' (#31) from feat/auth-ui-refinement into main

Reviewed-on: #31
This commit was merged in pull request #31.
This commit is contained in:
2025-12-29 20:45:30 +08:00
5 changed files with 659 additions and 256 deletions
+189 -105
View File
@@ -1,42 +1,44 @@
<template>
<div class="login-layout">
<div class="side-visual">
<div class="visual-mask"></div>
<div class="brand-container">
<h1 class="hero-title">Work<br>Together.</h1>
<p class="hero-subtitle">下一代企业级即时通讯平台让沟通无距离</p>
</div>
<div class="visual-footer">
<span>© 2025 IM System</span>
<div class="dots">
<span></span><span></span><span></span>
<div class="login-card">
<div class="side-visual">
<div class="brand-container">
<h1 class="hero-title">Work<br>Together.</h1>
<p class="hero-subtitle">下一代企业级即时通讯平台让沟通无距离</p>
</div>
<div class="visual-footer">
<span>© 2025 IM System</span>
<div class="dots">
<span></span><span></span><span></span>
</div>
</div>
</div>
</div>
<div class="side-form">
<div class="form-wrapper">
<div class="welcome-header">
<h2>账号登录</h2>
<p>请输入您的工作账号以继续</p>
</div>
<div class="side-form">
<div class="form-wrapper">
<div class="welcome-header">
<h2>账号登录</h2>
<p>请输入您的工作账号以继续</p>
</div>
<form @submit.prevent="handleLogin">
<IconInput class="input"
placeholder="请输入用户名" lab="用户名 / 邮箱" type="text" icon-name="user" v-model="form.username"/>
<form @submit.prevent="handleLogin">
<IconInput class="input"
placeholder="请输入用户名" lab="用户名 / 邮箱" type="text" icon-name="user" v-model="form.username"/>
<IconInput class="input"
placeholder="请输入密码" lab="密码" type="password" icon-name="user" v-model="form.password"/>
<IconInput class="input"
placeholder="请输入密码" lab="密码" type="password" icon-name="lock" v-model="form.password"/>
<MyButton class="loginBtn" :loading="loading">
登录
<template #icon><i data-feather="arrow-right"></i></template>
</MyButton>
</form>
<div class="login-btn-wrapper">
<MyButton variant="pill" class="login-btn" :loading="loading">
登录
</MyButton>
</div>
</form>
<div class="register-hint">
还没有账号? <a href="#">联系管理员注册</a>
<div class="register-hint">
还没有账号? <router-link to="/auth/register">立即注册</router-link>
</div>
</div>
</div>
</div>
@@ -51,6 +53,7 @@ import { authService } from '@/services/auth'
import { useRouter } from 'vue-router'
import feather from 'feather-icons'
import IconInput from '@/components/IconInput.vue'
import MyButton from '@/components/MyButton.vue'
import { required, maxLength, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
import { useAuthStore } from '@/stores/auth'
@@ -77,27 +80,32 @@ const rules = {
};
const v$ = useVuelidate(rules,form);
const handleLogin = async () => {
if(!(await v$.value.$validate())){
message.error(v$.value.$errors[0].$message)
return;
const isFormCorrect = await v$.value.$validate()
if (!isFormCorrect) {
if (v$.value.$errors.length > 0) {
message.error(v$.value.$errors[0].$message)
}
return
}
try{
loading.value = true;
const res = await authService.login(form);
if(res.code === 0){
message.success('登成功')
authStore.setLoginInfo(res.data.token, res.data.refreshToken, res.data.userInfo);
loading.value = false;
router.push('/index')
}else{
message.error(res.message)
const res = await authService.login(form);
if(res.code === 0){ // Assuming 0 is success
message.success('登成功')
authStore.setLoginInfo(res.data.token, res.data.refreshToken, res.data.userInfo);
router.push('/')
}else{
message.error(res.message || '登录失败')
}
} catch (e) {
console.error(e)
message.error('登录请求异常')
} finally{
loading.value = false;
}
}finally{
loading.value = false;
}
}
onMounted(() => {
@@ -106,138 +114,214 @@ onMounted(() => {
</script>
<style scoped>
:deep(.loginBtn) {
width: 100%;
}
:deep(.input){
width: 100%;
}
/* 撑满 Window 组件的内容区 */
/* Soft Mesh Gradient Background */
.login-layout {
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
width: 100vw;
height: 100vh;
min-height: 100vh;
background-color: #f8fafc;
background-image:
radial-gradient(at 0% 0%, hsla(190, 100%, 95%, 1) 0, transparent 50%),
radial-gradient(at 50% 0%, hsla(160, 100%, 96%, 1) 0, transparent 50%),
radial-gradient(at 100% 0%, hsla(210, 100%, 96%, 1) 0, transparent 50%);
overflow: hidden;
position: relative;
}
/* Very subtle grid overlay */
.login-layout::before {
content: "";
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background-image: radial-gradient(rgba(0,0,0,0.02) 1px, transparent 1px);
background-size: 20px 20px;
z-index: 0;
}
.login-card {
display: flex;
width: 1000px;
height: 600px;
background: rgba(255, 255, 255, 0.82);
backdrop-filter: blur(30px);
border-radius: 32px;
box-shadow:
0 10px 25px -5px rgba(0, 0, 0, 0.02),
0 40px 100px -20px rgba(0, 0, 0, 0.08);
overflow: hidden;
z-index: 10;
position: relative;
border: 1px solid rgba(255, 255, 255, 0.7);
}
/* --- 左侧视觉 --- */
.side-visual {
width: 42%;
background: linear-gradient(135deg, #4f46e5 0%, #3b82f6 100%);
flex: 1;
/* Soft connectivity gradient */
background: linear-gradient(135deg, #4f46e5 0%, #06b6d4 100%);
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
padding: 40px;
padding: 60px;
color: white;
overflow: hidden;
}
/* 增加一点背景纹理 */
/* Abstract "Connection" Circles */
.side-visual::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background-image: radial-gradient(rgba(255,255,255,0.15) 1px, transparent 1px);
background-size: 20px 20px;
opacity: 0.6;
top: -20%; left: -20%;
width: 400px; height: 400px;
border-radius: 50%;
background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 60%);
z-index: 1;
}
.side-visual::after {
content: '';
position: absolute;
bottom: -10%; right: -10%;
width: 300px; height: 300px;
border-radius: 50%;
background: radial-gradient(circle, rgba(255,255,255,0.15) 0%, transparent 60%);
z-index: 1;
}
.brand-container {
position: relative;
z-index: 2;
}
.hero-title {
font-size: 48px;
font-size: 44px;
font-weight: 800;
line-height: 1.1;
margin-bottom: 16px;
letter-spacing: -1px;
}
.hero-subtitle {
font-size: 15px;
opacity: 0.9;
line-height: 1.6;
max-width: 300px;
line-height: 1.2;
margin-bottom: 20px;
text-shadow: 0 4px 10px rgba(0,0,0,0.1);
}
.hero-subtitle {
font-size: 16px;
opacity: 0.95;
line-height: 1.6;
max-width: 340px;
font-weight: 400;
}
.visual-footer {
position: absolute;
bottom: 30px;
left: 40px;
right: 40px;
bottom: 40px;
left: 60px;
right: 60px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
opacity: 0.6;
}
.dots span {
display: inline-block;
width: 4px; height: 4px;
background: white;
border-radius: 50%;
margin-left: 4px;
opacity: 0.8;
z-index: 2;
}
.dots span {
display: inline-block;
width: 6px; height: 6px;
background: white;
border-radius: 50%;
margin-left: 6px;
opacity: 0.7;
}
/* --- 右侧表单 --- */
.side-form {
flex: 1;
background: white;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
background: #fff;
}
.form-wrapper {
width: 100%;
max-width: 360px;
max-width: 340px;
}
.welcome-header {
margin-bottom: 32px;
margin-bottom: 30px;
text-align: center;
}
.welcome-header h2 {
font-size: 24px;
font-size: 26px;
font-weight: 700;
color: #1e293b;
margin-bottom: 8px;
}
.welcome-header p {
color: #64748b;
font-size: 14px;
}
.input {
width: 100%;
margin-bottom: 20px;
}
.login-btn-wrapper {
display: flex;
justify-content: center;
width: 100%;
margin-top: 32px;
}
.register-hint {
margin-top: 24px;
text-align: center;
font-size: 13px;
color: #64748b;
}
.register-hint a {
color: #4f46e5;
color: #2563eb;
font-weight: 600;
text-decoration: none;
}
/* 响应式调整 */
@media (max-width: 768px) {
.login-layout {
flex-direction: column;
}
.side-visual {
width: 100%;
height: 160px;
padding: 24px;
}
.hero-title { font-size: 28px; }
.visual-footer { display: none; }
.side-form { flex: 1; padding: 24px; }
.register-hint a:hover {
text-decoration: underline;
}
.feather-arrow-right{
width: 18px;
/* Response Design */
@media (max-width: 960px) {
.login-card {
flex-direction: column;
width: 90%;
margin: 20px;
height: auto;
border-radius: 16px;
}
.side-visual {
padding: 30px;
min-height: 160px;
background: linear-gradient(135deg, #2563eb 0%, #06b6d4 100%);
}
.hero-title { font-size: 28px; }
.hero-subtitle, .visual-footer { display: none; }
.side-form { padding: 40px 20px; }
}
</style>
<style>
body {
margin: 0;
padding: 0;
overflow: hidden;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background-color: #f0f4f8; /* Fallback */
}
</style>
+355
View File
@@ -0,0 +1,355 @@
<template>
<div class="login-layout">
<div class="login-card">
<div class="side-visual">
<div class="brand-container">
<h1 class="hero-title">Join<br>Us.</h1>
<p class="hero-subtitle">创建一个新账号开启您的沟通之旅</p>
</div>
<div class="visual-footer">
<span>© 2025 IM System</span>
<div class="dots">
<span></span><span></span><span></span>
</div>
</div>
</div>
<div class="side-form">
<div class="form-wrapper">
<div class="welcome-header">
<h2>注册账号</h2>
<p>请填写以下信息以完成注册</p>
</div>
<form @submit.prevent="handleRegister">
<IconInput class="input"
placeholder="请输入用户名" lab="用户名" type="text" icon-name="user" v-model="form.username"/>
<IconInput class="input"
placeholder="请输入昵称" lab="昵称" type="text" icon-name="smile" v-model="form.nickname"/>
<IconInput class="input"
placeholder="请输入密码" lab="密码" type="password" icon-name="lock" v-model="form.password"/>
<IconInput class="input"
placeholder="再次输入密码" lab="确认密码" type="password" icon-name="check-circle" v-model="form.confirmPassword"/>
<div class="login-btn-wrapper">
<MyButton variant="pill-green" class="login-btn" :loading="loading">
立即注册
</MyButton>
</div>
</form>
<div class="register-hint">
已有账号? <router-link to="/auth/login">直接登录</router-link>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { reactive, ref, onMounted } from 'vue'
import { useMessage } from '@/components/messages/useAlert'
import { authService } from '@/services/auth'
import { useRouter } from 'vue-router'
import feather from 'feather-icons'
import IconInput from '@/components/IconInput.vue'
import MyButton from '@/components/MyButton.vue'
import { required, maxLength, minLength, sameAs, helpers } from '@vuelidate/validators'
import useVuelidate from '@vuelidate/core'
const message = useMessage();
const router = useRouter();
const loading = ref(false)
const form = reactive({
username: '',
nickname: '',
password: '',
confirmPassword: ''
})
const rules = {
username:{
required:helpers.withMessage('用户名不能为空', required),
maxLength:helpers.withMessage('用户名最大20字符', maxLength(20)),
minLength:helpers.withMessage('用户名至少3字符', minLength(3))
},
nickname: {
required: helpers.withMessage('昵称不能为空', required),
maxLength: helpers.withMessage('昵称最大20字符', maxLength(20))
},
password:{
required:helpers.withMessage('密码不能为空', required),
minLength:helpers.withMessage('密码至少6字符', minLength(6)),
maxLength:helpers.withMessage('密码最大50字符', maxLength(50))
},
confirmPassword: {
required: helpers.withMessage('请确认密码', required),
sameAs: helpers.withMessage('两次输入的密码不一致', sameAs(ref(form).password)) // This might fail if using reactive directly without computed ref binding for sameAs. Let's fix sameAs usage.
// Actually sameAs(form.password) won't work reactively in Vuelidate 2 sometimes if not ref.
// Just utilize a simpler computed or validator.
}
};
// Vuelidate sameAs expects a generic or a ref.
// Let's use computed for the target or just fix the rule.
// In composition API, use computed(() => form.password)
const rulesWithComputed = {
...rules,
confirmPassword: {
required: helpers.withMessage('请确认密码', required),
sameAs: helpers.withMessage('两次输入的密码不一致', sameAs(ref(form).value?.password || form.password)) // Trickier with reactive.
}
}
// Actually, standard way:
// const rules = computed(() => ({ ... }))
const v$ = useVuelidate(rules, form); // Vuelidate supports reactive object directly.
// The problem is `sameAs` needs a locator.
// Correct usage: sameAs(computed(() => form.password))
const handleRegister = async () => {
// Manual check for confirm password if Vuelidate sameAs is tricky without computed rules
if (form.password !== form.confirmPassword) {
message.error('两次输入的密码不一致');
return;
}
const isFormCorrect = await v$.value.$validate()
if (!isFormCorrect) {
if (v$.value.$errors.length > 0) {
// Skip confirmPassword error if we manually checked it or if it's the only one
message.error(v$.value.$errors[0].$message)
}
return
}
try{
loading.value = true;
// Prepare data (exclude confirmPassword)
const { confirmPassword, ...registerData } = form;
const res = await authService.register(registerData);
if(res.code === 0){
message.success('注册成功,请登录')
router.push('/auth/login')
}else{
message.error(res.message || '注册失败')
}
} catch(e) {
console.error(e);
message.error('注册请求异常');
} finally{
loading.value = false;
}
}
onMounted(() => {
feather.replace()
})
</script>
<style scoped>
/* Green Soft Mesh Gradient Background */
.login-layout {
display: flex;
align-items: center;
justify-content: center;
width: 100vw;
height: 100vh;
min-height: 100vh;
background-color: #f0fdf4; /* Very light green fallback */
background-image:
radial-gradient(at 0% 0%, hsla(150, 100%, 95%, 1) 0, transparent 50%),
radial-gradient(at 50% 0%, hsla(165, 100%, 96%, 1) 0, transparent 50%),
radial-gradient(at 100% 0%, hsla(140, 100%, 96%, 1) 0, transparent 50%);
overflow: hidden;
position: relative;
}
/* Very subtle grid overlay */
.login-layout::before {
content: "";
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background-image: radial-gradient(rgba(0,0,0,0.02) 1px, transparent 1px);
background-size: 20px 20px;
z-index: 0;
}
.login-card {
display: flex;
width: 1000px;
height: 600px;
background: rgba(255, 255, 255, 0.82);
backdrop-filter: blur(30px);
border-radius: 32px;
box-shadow:
0 10px 25px -5px rgba(0, 0, 0, 0.02),
0 40px 100px -20px rgba(0, 0, 0, 0.08);
overflow: hidden;
z-index: 10;
position: relative;
border: 1px solid rgba(255, 255, 255, 0.7);
}
.side-visual {
flex: 1;
/* Green connectivity gradient */
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
padding: 60px;
color: white;
overflow: hidden;
}
/* Abstract Decorations */
.side-visual::before {
content: '';
position: absolute;
top: -20%; left: -20%;
width: 400px; height: 400px;
border-radius: 50%;
background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 60%);
z-index: 1;
}
.side-visual::after {
content: '';
position: absolute;
bottom: -10%; right: -10%;
width: 300px; height: 300px;
border-radius: 50%;
background: radial-gradient(circle, rgba(255,255,255,0.15) 0%, transparent 60%);
z-index: 1;
}
.brand-container {
position: relative;
z-index: 2;
}
.hero-title {
font-size: 44px;
font-weight: 800;
line-height: 1.2;
margin-bottom: 20px;
text-shadow: 0 4px 10px rgba(0,0,0,0.1);
}
.hero-subtitle {
font-size: 16px;
opacity: 0.95;
line-height: 1.6;
max-width: 340px;
font-weight: 400;
}
.visual-footer {
position: absolute;
bottom: 40px;
left: 60px;
right: 60px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
opacity: 0.8;
z-index: 2;
}
.dots span {
display: inline-block;
width: 6px; height: 6px;
background: white;
border-radius: 50%;
margin-left: 6px;
opacity: 0.7;
}
.side-form {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
background: #fff;
}
.form-wrapper {
width: 100%;
max-width: 340px;
}
.welcome-header {
margin-bottom: 30px;
text-align: center;
}
.welcome-header h2 {
font-size: 26px;
font-weight: 700;
color: #1e293b;
margin-bottom: 8px;
}
.welcome-header p {
color: #64748b;
font-size: 14px;
}
.input {
width: 100%;
margin-bottom: 16px;
}
.login-btn-wrapper {
display: flex;
justify-content: center;
width: 100%;
margin-top: 24px;
}
.register-hint {
margin-top: 24px;
text-align: center;
font-size: 13px;
color: #64748b;
}
.register-hint a {
color: #10b981;
font-weight: 600;
text-decoration: none;
}
.register-hint a:hover {
text-decoration: underline;
}
/* Responsive Design */
@media (max-width: 960px) {
.login-card {
flex-direction: column;
width: 90%;
margin: 20px;
height: auto;
border-radius: 16px;
}
.side-visual {
padding: 30px;
min-height: 160px;
}
.hero-title { font-size: 28px; }
.hero-subtitle, .visual-footer { display: none; }
.side-form { padding: 40px 20px; }
}
</style>