Merge pull request 'main' (#32) from main into feature-nxdev
Reviewed-on: #32
This commit is contained in:
commit
d641cb3275
@ -1,5 +1,9 @@
|
||||
using IM_API.Interface.Services;
|
||||
using IM_API.Dtos;
|
||||
using IM_API.Interface.Services;
|
||||
using IM_API.Services;
|
||||
using IM_API.Tools;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace IM_API.Configs
|
||||
{
|
||||
@ -7,6 +11,7 @@ namespace IM_API.Configs
|
||||
{
|
||||
public static IServiceCollection AddAllService(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
|
||||
services.AddAutoMapper(typeof(MapperConfig));
|
||||
services.AddTransient<IAuthService, AuthService>();
|
||||
services.AddTransient<IUserService, UserService>();
|
||||
@ -14,8 +19,28 @@ namespace IM_API.Configs
|
||||
services.AddTransient<IMessageSevice, MessageService>();
|
||||
services.AddTransient<IConversationService, ConversationService>();
|
||||
services.AddSingleton<IJWTService, JWTService>();
|
||||
services.AddSingleton<IRefreshTokenService,RedisRefreshTokenService>();
|
||||
services.AddSingleton<IRefreshTokenService, RedisRefreshTokenService>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
public static IServiceCollection AddModelValidation(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.Configure<ApiBehaviorOptions>(options =>
|
||||
{
|
||||
options.InvalidModelStateResponseFactory = context =>
|
||||
{
|
||||
var errors = context.ModelState
|
||||
.Where(e => e.Value.Errors.Count > 0)
|
||||
.Select(e => new
|
||||
{
|
||||
Field = e.Key,
|
||||
Message = e.Value.Errors.First().ErrorMessage
|
||||
});
|
||||
|
||||
return new BadRequestObjectResult(new BaseResponse<object?>(CodeDefine.PARAMETER_ERROR.Code, errors.First().Message));
|
||||
};
|
||||
});
|
||||
return services;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,6 +28,20 @@ namespace IM_API.Controllers
|
||||
var res = new BaseResponse<List<Conversation>>(list);
|
||||
return Ok(res);
|
||||
}
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Clear()
|
||||
{
|
||||
var userIdStr = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
await _conversationSerivice.ClearConversationsAsync(int.Parse(userIdStr));
|
||||
return Ok(new BaseResponse<object?>());
|
||||
}
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Delete(int cid)
|
||||
{
|
||||
await _conversationSerivice.DeleteConversationAsync(cid);
|
||||
return Ok(new BaseResponse<object?>());
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -83,7 +83,7 @@ namespace IM_API.Controllers
|
||||
{
|
||||
var userIdStr = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
int userId = int.Parse(userIdStr);
|
||||
await _userService.ResetPasswordAsync(userId,dto.oldPassword,dto.Password);
|
||||
await _userService.ResetPasswordAsync(userId,dto.OldPassword,dto.Password);
|
||||
return Ok(new BaseResponse<object?>());
|
||||
}
|
||||
/// <summary>
|
||||
|
||||
@ -1,7 +1,31 @@
|
||||
using IM_API.Models;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace IM_API.Dtos
|
||||
{
|
||||
public record FriendInfoDto(int Id, int UserId,int FriendId,FriendStatus StatusEnum,DateTime Created,string RemarkName,string? Avatar);
|
||||
public record FriendRequestHandleDto(HandleFriendRequestAction action,string? remarkName);
|
||||
public record FriendInfoDto
|
||||
{
|
||||
public int Id { get; init; }
|
||||
|
||||
public int UserId { get; init; }
|
||||
|
||||
public int FriendId { get; init; }
|
||||
|
||||
public FriendStatus StatusEnum { get; init; }
|
||||
|
||||
public DateTime Created { get; init; }
|
||||
|
||||
public string RemarkName { get; init; } = string.Empty;
|
||||
|
||||
public string? Avatar { get; init; }
|
||||
}
|
||||
|
||||
public record FriendRequestHandleDto
|
||||
{
|
||||
[Required(ErrorMessage = "操作必填")]
|
||||
public HandleFriendRequestAction Action { get; init; }
|
||||
|
||||
[StringLength(20, ErrorMessage = "备注名最大20个字符")]
|
||||
public string? RemarkName { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,10 +1,15 @@
|
||||
namespace IM_API.Dtos
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace IM_API.Dtos
|
||||
{
|
||||
public class FriendRequestDto
|
||||
{
|
||||
public int FromUserId { get; set; }
|
||||
public int ToUserId { get; set; }
|
||||
[Required(ErrorMessage = "备注名必填")]
|
||||
[StringLength(20, ErrorMessage = "备注名不能超过20位字符")]
|
||||
public string? RemarkName { get; set; }
|
||||
[StringLength(50, ErrorMessage = "描述不能超过50字符")]
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,17 @@
|
||||
namespace IM_API.Dtos
|
||||
using IM_API.Tools;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace IM_API.Dtos
|
||||
{
|
||||
public class LoginRequestDto
|
||||
{
|
||||
[Required(ErrorMessage = "用户名不能为空")]
|
||||
[StringLength(20, ErrorMessage = "用户名不能超过20字符")]
|
||||
[RegularExpression(@"^[A-Za-z0-9]+$",ErrorMessage = "用户名只能为英文或数字")]
|
||||
public string Username { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "密码不能为空")]
|
||||
[StringLength(50, ErrorMessage = "密码不能超过50字符")]
|
||||
public string Password { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,19 @@
|
||||
namespace IM_API.Dtos
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace IM_API.Dtos
|
||||
{
|
||||
public class RegisterRequestDto
|
||||
{
|
||||
[Required(ErrorMessage = "用户名不能为空")]
|
||||
[MaxLength(20, ErrorMessage = "用户名不能超过20字符")]
|
||||
[RegularExpression(@"^[A-Za-z0-9]+$", ErrorMessage = "用户名只能为英文或数字")]
|
||||
public string Username { get; set; }
|
||||
[Required(ErrorMessage = "密码不能为空")]
|
||||
[MaxLength(50, ErrorMessage = "密码不能超过50字符")]
|
||||
public string Password { get; set; }
|
||||
[Required(ErrorMessage = "昵称不能为空")]
|
||||
[MaxLength(20, ErrorMessage = "昵称不能超过20字符")]
|
||||
|
||||
public string? NickName { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
namespace IM_API.Dtos
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace IM_API.Dtos
|
||||
{
|
||||
public class UpdateUserDto
|
||||
{
|
||||
[MaxLength(20, ErrorMessage = "昵称不能超过50字符")]
|
||||
public string? NickName { get; set; }
|
||||
public string? Avatar { get; set; }
|
||||
|
||||
|
||||
@ -1,7 +1,21 @@
|
||||
using IM_API.Models;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace IM_API.Dtos
|
||||
{
|
||||
public record PasswordResetDto(string oldPassword,string Password);
|
||||
public record OnlineStatusSetDto(UserOnlineStatus OnlineStatus);
|
||||
public record PasswordResetDto
|
||||
{
|
||||
[Required(ErrorMessage = "旧密码不能为空")]
|
||||
public string OldPassword { get; init; }
|
||||
|
||||
[Required(ErrorMessage = "新密码不能为空")]
|
||||
[MaxLength(50, ErrorMessage = "密码不能超过50个字符")]
|
||||
public string Password { get; init; }
|
||||
}
|
||||
|
||||
public record OnlineStatusSetDto
|
||||
{
|
||||
[Required]
|
||||
public UserOnlineStatus OnlineStatus { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
@ -102,6 +102,7 @@ namespace IM_API
|
||||
{
|
||||
options.Filters.Add<GlobalExceptionFilter>();
|
||||
});
|
||||
builder.Services.AddModelValidation(configuration);
|
||||
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
|
||||
@ -1,37 +1,21 @@
|
||||
<script setup>
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="app">
|
||||
<!--
|
||||
<WindowLayout>
|
||||
-->
|
||||
<RouterView></RouterView>
|
||||
<!---
|
||||
</WindowLayout>
|
||||
-->
|
||||
<RouterView></RouterView>
|
||||
<Alert></Alert>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import WindowLayout from './components/Window.vue'
|
||||
import Alert from '@/components/messages/Alert.vue';
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
padding: 150px;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
background: radial-gradient(circle at 50% 30%, #eef2ff, #e2e8f0);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -109,55 +109,57 @@ label {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* 输入框样式:现代填充风格 */
|
||||
/* 输入框样式:现代线框风格 */
|
||||
.field-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.field-wrap .icon {
|
||||
/* .icon 是 <i> 占位符,定位父容器 */
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: #94a3b8; /* 默认图标颜色 */
|
||||
color: #94a3b8;
|
||||
transition: color 0.2s;
|
||||
/* Feather icons 渲染后,SVG会继承这个颜色 */
|
||||
pointer-events: none; /* Icon shouldn't block input click */
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* 确保渲染后的 SVG 元素颜色能够正确继承 */
|
||||
.field-wrap .icon > svg {
|
||||
/* 确保 SVG 自身不被其他默认样式影响 */
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
stroke: currentColor; /* 使用父元素的颜色 */
|
||||
stroke: currentColor;
|
||||
transition: stroke 0.2s;
|
||||
}
|
||||
|
||||
.field-wrap input {
|
||||
padding: 12px 12px 12px 40px;
|
||||
background: #f1f5f9; /* 浅灰底色 */
|
||||
border: 2px solid transparent;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: #1e293b;
|
||||
outline: none;
|
||||
transition: all 0.2s ease;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 聚焦交互 */
|
||||
.field-wrap input:focus {
|
||||
background: #fff;
|
||||
border-color: #4f46e5; /* 品牌色 */
|
||||
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.1);
|
||||
border-color: #6366f1; /* 品牌色 */
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); /* 柔和光晕 */
|
||||
}
|
||||
|
||||
/* 错误状态 (Optional, if you pass a prop later) */
|
||||
.field-wrap input.error {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
/* 🚨 关键:聚焦时图标颜色变化的选择器 */
|
||||
/* 当 input 聚焦时,选择它后面的所有兄弟元素 (~),其中类名为 .icon 的元素,改变它的颜色 */
|
||||
/* 由于我们在 .icon 元素上设置了 color,SVG 会自动继承 */
|
||||
.field-wrap input:focus ~ .icon {
|
||||
color: #4f46e5; /* 聚焦时的颜色 */
|
||||
color: #6366f1; /* 聚焦时的颜色 */
|
||||
}
|
||||
</style>
|
||||
@ -2,21 +2,22 @@
|
||||
<div id="btn">
|
||||
<button
|
||||
class="submit-btn"
|
||||
:disabled="props.disabled || props.loading" v-bind="attrs"
|
||||
:class="[
|
||||
`variant-${props.variant}`,
|
||||
{ 'is-loading': props.loading }
|
||||
]"
|
||||
:data-variant="props.variant"
|
||||
:disabled="props.disabled || props.loading"
|
||||
v-bind="attrs"
|
||||
>
|
||||
<!-- Loading Spinner -->
|
||||
<span v-if="props.loading" class="spinner-icon"></span>
|
||||
|
||||
<span :class="{ 'is-hidden': props.loading }">
|
||||
<!-- Button Content -->
|
||||
<span class="btn-text" :class="{ 'is-hidden': props.loading }">
|
||||
<slot></slot>
|
||||
|
||||
</span>
|
||||
<div class="iconBox" v-show="!props.loading"><slot name="icon"></slot></div>
|
||||
|
||||
|
||||
|
||||
<!-- Icon Slot -->
|
||||
<div class="iconBox" v-if="$slots.icon && !props.loading">
|
||||
<slot name="icon"></slot>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@ -37,7 +38,8 @@ const props = defineProps({
|
||||
'variant': {
|
||||
type: String,
|
||||
default: 'primary',
|
||||
validator: (value) => ['primary', 'secondary', 'danger', 'text'].includes(value)
|
||||
|
||||
validator: (value) => ['primary', 'secondary', 'danger', 'text', 'pill', 'pill-green'].includes(value)
|
||||
},
|
||||
// 明确接收 disabled 属性,以便在脚本中控制和类型检查
|
||||
'disabled': {
|
||||
@ -57,153 +59,129 @@ const attrs = useAttrs()
|
||||
|
||||
<style scoped>
|
||||
/* ======================================= */
|
||||
/* 基础样式和布局 */
|
||||
/* Base Button Styling */
|
||||
/* ======================================= */
|
||||
.submit-btn {
|
||||
margin-top: 10px;
|
||||
padding: 12px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
|
||||
/* 布局:使用 Flex 确保图标和文本对齐 */
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px; /* 文本和图标之间的间距 */
|
||||
|
||||
transition: all 0.2s, transform 0.1s;
|
||||
gap: 8px;
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border-radius: 8px; /* Default */
|
||||
color: white;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 交互状态 */
|
||||
.submit-btn:active {
|
||||
transform: scale(0.98);
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.submit-btn:disabled,
|
||||
.submit-btn.is-loading {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed !important;
|
||||
pointer-events: none; /* 禁用点击事件 */
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
/* ======================================= */
|
||||
/* 样式变体 (Variants) */
|
||||
/* Style Variants */
|
||||
/* ======================================= */
|
||||
|
||||
/* --- 1. Primary (主色调) --- */
|
||||
.variant-primary {
|
||||
background: #4f46e5; /* Indigo 品牌蓝 */
|
||||
color: white;
|
||||
/* 1. Primary (Modern Blue) */
|
||||
.submit-btn[data-variant="primary"] {
|
||||
background: #3b82f6;
|
||||
}
|
||||
.variant-primary:hover {
|
||||
background: #4338ca;
|
||||
.submit-btn[data-variant="primary"]:hover {
|
||||
background: #2563eb;
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
/* --- 2. Secondary (次要/灰色调) --- */
|
||||
.variant-secondary {
|
||||
background: #e2e8f0; /* Slate 浅灰 */
|
||||
color: #1e293b;
|
||||
/* 2. Pill (Used for Login - Wider and Rounded) */
|
||||
.submit-btn[data-variant="pill"] {
|
||||
background: #3b82f6 !important;
|
||||
border-radius: 999px !important;
|
||||
min-width: 340px !important;
|
||||
height: 52px !important; /* Slightly taller for more impact */
|
||||
font-size: 16px !important;
|
||||
box-shadow: 0 6px 16px rgba(59, 130, 246, 0.2) !important;
|
||||
}
|
||||
.variant-secondary:hover {
|
||||
background: #cbd5e1;
|
||||
.submit-btn[data-variant="pill"]:hover {
|
||||
background: #2563eb !important;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 25px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
/* --- 3. Danger (危险/红色调) --- */
|
||||
.variant-danger {
|
||||
background: #ef4444; /* Red 红色 */
|
||||
color: white;
|
||||
/* 2.5 Pill-Green (Used for Register) */
|
||||
.submit-btn[data-variant="pill-green"] {
|
||||
background: #10b981 !important;
|
||||
border-radius: 999px !important;
|
||||
min-width: 340px !important;
|
||||
height: 52px !important;
|
||||
font-size: 16px !important;
|
||||
box-shadow: 0 6px 16px rgba(16, 185, 129, 0.2) !important;
|
||||
}
|
||||
.variant-danger:hover {
|
||||
.submit-btn[data-variant="pill-green"]:hover {
|
||||
background: #059669 !important;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 25px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
/* 3. Secondary */
|
||||
.submit-btn[data-variant="secondary"] {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
}
|
||||
.submit-btn[data-variant="secondary"]:hover {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
/* 4. Danger */
|
||||
.submit-btn[data-variant="danger"] {
|
||||
background: #ef4444;
|
||||
}
|
||||
.submit-btn[data-variant="danger"]:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
/* --- 4. Text (文本按钮/无背景) --- */
|
||||
.variant-text {
|
||||
/* 5. Text */
|
||||
.submit-btn[data-variant="text"] {
|
||||
background: transparent;
|
||||
color: #4f46e5;
|
||||
padding: 10px 12px; /* 减小 padding 以适应文本按钮 */
|
||||
color: #3b82f6;
|
||||
}
|
||||
.variant-text:hover {
|
||||
text-decoration: underline;
|
||||
background: transparent;
|
||||
color: #4338ca;
|
||||
}
|
||||
/* 按钮基础样式 (假设你已有一些基础样式) */
|
||||
.submit-btn {
|
||||
position: relative; /* 确保 spinner-icon 可以相对于按钮定位 */
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 20px;
|
||||
font-size: 16px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
.submit-btn[data-variant="text"]:hover {
|
||||
background: rgba(59, 130, 246, 0.05);
|
||||
}
|
||||
|
||||
/* =======================================
|
||||
1. 加载状态:is-loading
|
||||
======================================= */
|
||||
.submit-btn.is-loading {
|
||||
/* 视觉反馈:半透明、改变颜色或禁用 */
|
||||
opacity: 0.8;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none; /* 确保点击穿透 */
|
||||
}
|
||||
|
||||
/* =======================================
|
||||
2. 旋转图标样式:spinner-icon
|
||||
======================================= */
|
||||
.submit-btn .spinner-icon {
|
||||
display: inline-block;
|
||||
width: 1em; /* 旋转图标的宽度 */
|
||||
height: 1em; /* 旋转图标的高度 */
|
||||
border: 2px solid currentColor; /* 边框颜色继承自按钮文本颜色 */
|
||||
border-top-color: transparent; /* 顶部透明,形成旋转的缺口 */
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite; /* 应用旋转动画 */
|
||||
margin-right: 0.5em; /* 在图标和文本之间添加间距 */
|
||||
/* ======================================= */
|
||||
/* Internal Elements */
|
||||
/* ======================================= */
|
||||
.spinner-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.iconBox {
|
||||
display: flex;
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 在加载状态且有文本时,清除右边距 */
|
||||
.submit-btn.is-loading .spinner-icon {
|
||||
/* 如果按钮内容被隐藏了,这个 margin 应该根据你的布局决定是否清除 */
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
/* =======================================
|
||||
3. 文本隐藏样式
|
||||
======================================= */
|
||||
.is-hidden {
|
||||
/* 隐藏文本,但不移除它,保持布局稳定 */
|
||||
visibility: hidden;
|
||||
height: 0;
|
||||
width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* =======================================
|
||||
4. 旋转动画定义
|
||||
======================================= */
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
@ -38,6 +38,11 @@ api.interceptors.response.use(
|
||||
message.error('未登录,请登录后操作。');
|
||||
router.push('/auth/login')
|
||||
break;
|
||||
case 400:
|
||||
if (err.response.data && err.response.data.code == 1003) {
|
||||
message.error(err.response.data.message);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
message.error('请求错误,请检查网络。');
|
||||
break;
|
||||
|
||||
@ -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,31 @@ 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)
|
||||
} finally{
|
||||
loading.value = false;
|
||||
}
|
||||
}finally{
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@ -106,138 +113,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
frontend/web/src/views/auth/Register.vue
Normal file
355
frontend/web/src/views/auth/Register.vue
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user