add(components): 添加MyInput组件
This commit is contained in:
parent
d90366e304
commit
fe4a9173a4
@ -54,12 +54,14 @@ namespace IM_API.Configs
|
||||
;
|
||||
CreateMap<MessageBaseDto, Message>()
|
||||
.ForMember(dest => dest.Sender, opt => opt.MapFrom(src => src.SenderId))
|
||||
.ForMember(dest => dest.ChatTypeEnum,opt => opt.MapFrom(src => src.ChatType))
|
||||
.ForMember(dest => dest.MsgTypeEnum, opt => opt.MapFrom(src => src.Type))
|
||||
.ForMember(dest => dest.ChatTypeEnum,opt => opt.MapFrom(src => Enum.Parse<ChatType>(src.ChatType,true)))
|
||||
.ForMember(dest => dest.MsgTypeEnum, opt => opt.MapFrom(src => Enum.Parse<MessageMsgType>(src.Type,true)))
|
||||
.ForMember(dest => dest.Created, opt => opt.MapFrom(src => src.TimeStamp))
|
||||
.ForMember(dest => dest.Content, opt => opt.MapFrom(src => src.Content))
|
||||
.ForMember(dest => dest.Recipient, opt => opt.MapFrom(src => src.ReceiverId))
|
||||
.ForMember(dest => dest.StateEnum, opt => opt.MapFrom(src => MessageState.Sent))
|
||||
.ForMember(dest => dest.ChatType, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.MsgType, opt => opt.Ignore())
|
||||
;
|
||||
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ namespace IM_API.Configs
|
||||
services.AddTransient<IAuthService, AuthService>();
|
||||
services.AddTransient<IUserService, UserService>();
|
||||
services.AddTransient<IFriendSerivce, FriendService>();
|
||||
services.AddTransient<IMessageSevice, MessageService>();
|
||||
services.AddSingleton<IJWTService, JWTService>();
|
||||
services.AddSingleton<IRefreshTokenService,RedisRefreshTokenService>();
|
||||
return services;
|
||||
|
||||
@ -6,13 +6,13 @@
|
||||
/// <summary>
|
||||
/// 聊天类型
|
||||
/// </summary>
|
||||
public ChatType ChatType { get; set; }
|
||||
public MsgChatType ChatType { get; set; }
|
||||
/// <summary>
|
||||
/// 目标ID,聊天类型为群则为群id,私聊为用户id
|
||||
/// </summary>
|
||||
public int TargetId { get; set; }
|
||||
}
|
||||
public enum ChatType
|
||||
public enum MsgChatType
|
||||
{
|
||||
/// <summary>
|
||||
/// 私聊
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
namespace IM_API.Dtos
|
||||
{
|
||||
public record MessageBaseDto(
|
||||
string Type,string ChatType, string MsgId,int SenderId,int ReceiverId,string Content,DateTime TimeStamp);
|
||||
string Type,string ChatType, string? MsgId,int SenderId,int ReceiverId,string Content,DateTime TimeStamp);
|
||||
}
|
||||
|
||||
66
backend/IM_API/Dtos/SignalRResponseDto.cs
Normal file
66
backend/IM_API/Dtos/SignalRResponseDto.cs
Normal file
@ -0,0 +1,66 @@
|
||||
using IM_API.Tools;
|
||||
|
||||
namespace IM_API.Dtos
|
||||
{
|
||||
public class SignalRResponseDto
|
||||
{
|
||||
public string Type { get; set; }
|
||||
public string Message { get; set; }
|
||||
public int Code { get; set; }
|
||||
public string Status { get; set; }
|
||||
public SignalRResponseDto(SignalRResponseType type,CodeDefine codeDefine)
|
||||
{
|
||||
this.Type = type.ToString();
|
||||
this.Code = codeDefine.Code;
|
||||
this.Message = codeDefine.Message;
|
||||
this.Status = codeDefine.Message;
|
||||
}
|
||||
public SignalRResponseDto(SignalRResponseType type)
|
||||
{
|
||||
this.Type = type.ToString();
|
||||
this.Code = CodeDefine.SUCCESS.Code;
|
||||
this.Message = CodeDefine.SUCCESS.Message;
|
||||
this.Status = CodeDefine.SUCCESS.Message;
|
||||
}
|
||||
}
|
||||
|
||||
public enum SignalRResponseType
|
||||
{
|
||||
/// <summary>
|
||||
/// 消息
|
||||
/// </summary>
|
||||
MESSAGE = 0,
|
||||
/// <summary>
|
||||
/// 鉴权
|
||||
/// </summary>
|
||||
AUTH = 1,
|
||||
/// <summary>
|
||||
/// 心跳
|
||||
/// </summary>
|
||||
HEARTBEAT = 2,
|
||||
/// <summary>
|
||||
/// 消息回执
|
||||
/// </summary>
|
||||
MESSAGE_ACK = 3,
|
||||
/// <summary>
|
||||
/// 消息撤回
|
||||
/// </summary>
|
||||
MESSAGE_RECALL = 4,
|
||||
/// <summary>
|
||||
/// 好友请求
|
||||
/// </summary>
|
||||
FRIEND_REQUEST = 5,
|
||||
/// <summary>
|
||||
/// 群邀请
|
||||
/// </summary>
|
||||
GROUP_INVITE = 6,
|
||||
/// <summary>
|
||||
/// 系统通知
|
||||
/// </summary>
|
||||
SYSTEM_NOTICE = 7,
|
||||
/// <summary>
|
||||
/// 错误
|
||||
/// </summary>
|
||||
ERROR = 8
|
||||
}
|
||||
}
|
||||
@ -8,28 +8,35 @@ namespace IM_API.Hubs
|
||||
{
|
||||
public class ChatHub:Hub
|
||||
{
|
||||
private IJWTService _JWTService;
|
||||
public ChatHub(IJWTService jWTService)
|
||||
private IMessageSevice _messageService;
|
||||
public ChatHub(IMessageSevice messageService)
|
||||
{
|
||||
_JWTService = jWTService;
|
||||
_messageService = messageService;
|
||||
}
|
||||
|
||||
public async override Task OnConnectedAsync()
|
||||
{
|
||||
var userIdStr = Context.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if(userIdStr is null)
|
||||
if (!Context.User.Identity.IsAuthenticated)
|
||||
{
|
||||
await Clients.Caller.SendAsync("ReceiveMessage",new BaseResponse<object?>(CodeDefine.AUTH_FAILED));
|
||||
Context.Abort();
|
||||
return;
|
||||
}
|
||||
int userId = int.Parse(userIdStr);
|
||||
await base.OnConnectedAsync();
|
||||
}
|
||||
public async Task SendMessage(MessageBaseDto dto)
|
||||
public async Task SendPrivateMessage(MessageBaseDto dto)
|
||||
{
|
||||
|
||||
await Clients.Caller.SendAsync("ReceiveMessage", "qwfqwfqw","test");
|
||||
if (!Context.User.Identity.IsAuthenticated)
|
||||
{
|
||||
await Clients.Caller.SendAsync("ReceiveMessage", new BaseResponse<object?>(CodeDefine.AUTH_FAILED));
|
||||
Context.Abort();
|
||||
return;
|
||||
}
|
||||
var userIdStr = Context.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
await _messageService.SendPrivateMessageAsync(int.Parse(userIdStr),dto.ReceiverId,dto);
|
||||
await Clients.Caller.SendAsync("ReceiveMessage",userIdStr,"qfqwfqwfqw");
|
||||
await Clients.Users(dto.ReceiverId.ToString()).SendAsync("ReceiveMessage", userIdStr, dto.Content);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -73,6 +73,7 @@ namespace IM_API.Models
|
||||
{
|
||||
entity.Ignore(e => e.StateEnum);
|
||||
entity.Ignore(e => e.MsgTypeEnum);
|
||||
entity.Ignore(e => e.ChatTypeEnum);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Notification>(entity =>
|
||||
|
||||
74
backend/IM_API/Services/MessageService.cs
Normal file
74
backend/IM_API/Services/MessageService.cs
Normal file
@ -0,0 +1,74 @@
|
||||
using AutoMapper;
|
||||
using IM_API.Dtos;
|
||||
using IM_API.Exceptions;
|
||||
using IM_API.Interface.Services;
|
||||
using IM_API.Models;
|
||||
using IM_API.Tools;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace IM_API.Services
|
||||
{
|
||||
public class MessageService : IMessageSevice
|
||||
{
|
||||
private readonly ImContext _context;
|
||||
private readonly ILogger<MessageService> _logger;
|
||||
private readonly IMapper _mapper;
|
||||
public MessageService(ImContext context, ILogger<MessageService> logger, IMapper mapper)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
public Task<List<MessageBaseDto>> GetGroupMessagesAsync(int groupId, int page, int pageSize, bool desc)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<List<MessageBaseDto>> GetPrivateMessagesAsync(int userAId, int userBId, int page, int pageSize, bool desc)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<int> GetUnreadCountAsync(int userId)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<List<MessageBaseDto>> GetUnreadMessagesAsync(int userId)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<bool> MarkAsReadAsync(int userId, long messageId)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<bool> MarkConversationAsReadAsync(int userId, int? userBId, int? groupId)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<bool> RecallMessageAsync(int userId, int messageId)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task<bool> SendGroupMessageAsync(int senderId, int groupId, MessageBaseDto dto)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public async Task<bool> SendPrivateMessageAsync(int senderId, int receiverId, MessageBaseDto dto)
|
||||
{
|
||||
bool isExist = await _context.Friends.AnyAsync(x => x.FriendId == receiverId);
|
||||
if (!isExist) throw new BaseException(CodeDefine.FRIEND_RELATION_NOT_FOUND);
|
||||
var message = _mapper.Map<Message>(dto);
|
||||
message.Sender = senderId;
|
||||
_context.Messages.Add(message);
|
||||
await _context.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -10,7 +10,7 @@
|
||||
"Key": "YourSuperSecretKey123456784124214190!",
|
||||
"Issuer": "IMDemo",
|
||||
"Audience": "IMClients",
|
||||
"AccessTokenMinutes": 15,
|
||||
"AccessTokenMinutes": 30,
|
||||
"RefreshTokenDays": 30
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
|
||||
46
frontend/web/package-lock.json
generated
46
frontend/web/package-lock.json
generated
@ -8,6 +8,7 @@
|
||||
"name": "web",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"feather-icons": "^4.29.2",
|
||||
"pinia": "^3.0.3",
|
||||
"vue": "^3.5.22",
|
||||
"vue-router": "^4.5.1"
|
||||
@ -2826,6 +2827,12 @@
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/classnames": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@ -2896,6 +2903,17 @@
|
||||
"url": "https://github.com/sponsors/mesqueeb"
|
||||
}
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.47.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.47.0.tgz",
|
||||
"integrity": "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@ -3600,6 +3618,16 @@
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/feather-icons": {
|
||||
"version": "4.29.2",
|
||||
"resolved": "https://registry.npmjs.org/feather-icons/-/feather-icons-4.29.2.tgz",
|
||||
"integrity": "sha512-0TaCFTnBTVCz6U+baY2UJNKne5ifGh7sMG4ZC2LoBWCZdIyPa+y6UiR4lEYGws1JOFWdee8KAsAIvu0VcXqiqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"classnames": "^2.2.5",
|
||||
"core-js": "^3.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/figures": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz",
|
||||
@ -3740,9 +3768,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "10.4.5",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
||||
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@ -4125,9 +4153,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -5587,9 +5615,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.1.9",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz",
|
||||
"integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==",
|
||||
"version": "7.2.7",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz",
|
||||
"integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"feather-icons": "^4.29.2",
|
||||
"pinia": "^3.0.3",
|
||||
"vue": "^3.5.22",
|
||||
"vue-router": "^4.5.1"
|
||||
|
||||
@ -2,9 +2,13 @@
|
||||
|
||||
<template>
|
||||
<div id="app">
|
||||
<!--
|
||||
<WindowLayout>
|
||||
-->
|
||||
<RouterView></RouterView>
|
||||
<!---
|
||||
</WindowLayout>
|
||||
-->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -12,4 +16,17 @@
|
||||
import WindowLayout from './components/Window.vue'
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
<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);
|
||||
}
|
||||
</style>
|
||||
|
||||
155
frontend/web/src/components/MyInput.vue
Normal file
155
frontend/web/src/components/MyInput.vue
Normal file
@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<div id="InputGroup" class="input-group">
|
||||
<label> {{ props.lab }} </label>
|
||||
<div class="field-wrap">
|
||||
<i class="icon" :data-feather="props.iconName" ref="iconElement"></i>
|
||||
|
||||
<input :type="props.type" v-model="inputValue" :placeholder="props.placeholder" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, defineProps, onMounted, watch, computed } from 'vue';
|
||||
import feather from 'feather-icons';
|
||||
|
||||
const props = defineProps({
|
||||
'modelValue': {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
'lab': {
|
||||
type: String,
|
||||
default: '输入框'
|
||||
},
|
||||
'placeholder': {
|
||||
type: String
|
||||
},
|
||||
'type': {
|
||||
type: String,
|
||||
default: 'text'
|
||||
},
|
||||
'iconName': {
|
||||
type: String,
|
||||
required:true
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const inputValue = computed({
|
||||
get(){
|
||||
return props.modelValue;
|
||||
},
|
||||
set(newVal){
|
||||
emit('update:modelValue',newVal)
|
||||
}
|
||||
})
|
||||
|
||||
// ref 用于获取模板中的 DOM 元素
|
||||
const iconElement = ref(null);
|
||||
|
||||
// 定义渲染图标的函数
|
||||
const renderFeatherIcon = () => {
|
||||
// 确保在替换前先清空内容,防止重复渲染
|
||||
if (iconElement.value) {
|
||||
iconElement.value.innerHTML = '';
|
||||
}
|
||||
|
||||
// 仅替换当前元素
|
||||
// 确保只替换当前 <i class="icon"> 元素的内容,而不是整个 document
|
||||
// 通过 feather.icons[props.iconName].toSvg({...}) 来生成 SVG 并手动插入,可以实现更精确的控制。
|
||||
if (props.iconName && iconElement.value) {
|
||||
const svgString = feather.icons[props.iconName].toSvg({
|
||||
width: 18,
|
||||
height: 18,
|
||||
'stroke-width': 2,
|
||||
class: 'feather-icon' // 添加一个类名方便继承样式
|
||||
});
|
||||
iconElement.value.innerHTML = svgString;
|
||||
}
|
||||
};
|
||||
|
||||
// 1. 组件挂载后渲染图标
|
||||
onMounted(() => {
|
||||
renderFeatherIcon();
|
||||
});
|
||||
|
||||
// 2. 侦听 iconName 和 inputValue 变化,如果变化则重新渲染
|
||||
watch(() => props.iconName, () => {
|
||||
renderFeatherIcon();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 完整的 CSS 样式 */
|
||||
.input-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
/* 之前被省略的 .forgot-link 样式,如果不需要可以删除 */
|
||||
.forgot-link {
|
||||
font-size: 12px;
|
||||
color: #4f46e5;
|
||||
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; /* 默认图标颜色 */
|
||||
transition: color 0.2s;
|
||||
/* Feather icons 渲染后,SVG会继承这个颜色 */
|
||||
}
|
||||
|
||||
/* 确保渲染后的 SVG 元素颜色能够正确继承 */
|
||||
.field-wrap .icon > svg {
|
||||
/* 确保 SVG 自身不被其他默认样式影响 */
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
stroke: currentColor; /* 使用父元素的颜色 */
|
||||
transition: stroke 0.2s;
|
||||
}
|
||||
|
||||
.field-wrap input {
|
||||
width: 100%;
|
||||
padding: 12px 12px 12px 40px;
|
||||
background: #f1f5f9; /* 浅灰底色 */
|
||||
border: 2px solid transparent;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: #1e293b;
|
||||
outline: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* 聚焦交互 */
|
||||
.field-wrap input:focus {
|
||||
background: #fff;
|
||||
border-color: #4f46e5; /* 品牌色 */
|
||||
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.1);
|
||||
}
|
||||
|
||||
/* 🚨 关键:聚焦时图标颜色变化的选择器 */
|
||||
/* 当 input 聚焦时,选择它后面的所有兄弟元素 (~),其中类名为 .icon 的元素,改变它的颜色 */
|
||||
/* 由于我们在 .icon 元素上设置了 color,SVG 会自动继承 */
|
||||
.field-wrap input:focus ~ .icon {
|
||||
color: #4f46e5; /* 聚焦时的颜色 */
|
||||
}
|
||||
</style>
|
||||
@ -1,155 +1,166 @@
|
||||
<template>
|
||||
<div class="window-container">
|
||||
<div class="window">
|
||||
<div class="window-header">
|
||||
<div class="window-left">
|
||||
<div class="window-icon">
|
||||
<!-- 应用图标 -->
|
||||
<svg viewBox="0 0 24 24" width="18" height="18">
|
||||
<rect x="3" y="3" width="8" height="8" rx="2" fill="currentColor" />
|
||||
<rect x="13" y="3" width="8" height="8" rx="2" fill="currentColor" opacity="0.8" />
|
||||
<rect x="3" y="13" width="8" height="8" rx="2" fill="currentColor" opacity="0.6" />
|
||||
<rect x="13" y="13" width="8" height="8" rx="2" fill="currentColor" opacity="0.4" />
|
||||
<div class="desktop-container">
|
||||
<div class="app-window" :class="{ 'is-maximized': isMaximized }">
|
||||
|
||||
<header class="window-header" @dblclick="toggleMaximize">
|
||||
<div class="header-left">
|
||||
<div class="app-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="window-title">即时通讯</span>
|
||||
<span class="app-title">IM Connect</span>
|
||||
</div>
|
||||
|
||||
<div class="window-controls">
|
||||
<button class="control-btn minimize" @click="minimize" title="最小化">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12">
|
||||
<rect x="2" y="5.5" width="8" height="1" fill="currentColor" />
|
||||
<svg width="10" height="10" viewBox="0 0 10 1">
|
||||
<rect width="10" height="1" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="control-btn maximize" @click="maximize" title="最大化">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12">
|
||||
<rect
|
||||
x="2"
|
||||
y="2"
|
||||
width="8"
|
||||
height="8"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-width="1.2"
|
||||
/>
|
||||
<button class="control-btn maximize" @click="toggleMaximize" title="最大化/还原">
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.2">
|
||||
<rect x="1.5" y="1.5" width="7" height="7" v-if="!isMaximized"/>
|
||||
<path d="M3 1h6v6H3z M1 3h6v6H1z" v-else fill="currentColor" fill-opacity="0.5"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="control-btn close" @click="close" title="关闭">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12">
|
||||
<path
|
||||
d="M2 2l8 8M10 2L2 10"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round">
|
||||
<path d="M1 1L9 9M9 1L1 9" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="window-content">
|
||||
<main class="window-body">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const isMaximized = ref(false)
|
||||
|
||||
function minimize() {
|
||||
console.log('最小化')
|
||||
console.log('Window: Minimize')
|
||||
}
|
||||
function maximize() {
|
||||
console.log('最大化')
|
||||
function toggleMaximize() {
|
||||
isMaximized.value = !isMaximized.value
|
||||
console.log('Window: Maximize/Restore')
|
||||
}
|
||||
function close() {
|
||||
console.log('关闭')
|
||||
console.log('Window: Close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.window-container {
|
||||
/* 模拟桌面背景 */
|
||||
.desktop-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(145deg, #eef2ff, #f9fafb);
|
||||
/* 一个高级的模糊背景,衬托窗口 */
|
||||
background: radial-gradient(circle at 50% 30%, #eef2ff, #e2e8f0);
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.window {
|
||||
width: clamp(640px, 85vw, 1100px);
|
||||
height: clamp(520px, 80vh, 900px);
|
||||
/* 窗口主体 */
|
||||
.app-window {
|
||||
width: 960px;
|
||||
height: 600px;
|
||||
/* 响应式限制 */
|
||||
max-width: 95vw;
|
||||
max-height: 90vh;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.08);
|
||||
background: #ffffff;
|
||||
border-radius: 10px; /* 现代圆角 */
|
||||
/* 精致的窗口阴影:一层边框 + 两层阴影模拟浮起 */
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(0, 0, 0, 0.06),
|
||||
0 8px 20px rgba(0, 0, 0, 0.08),
|
||||
0 20px 50px rgba(0, 0, 0, 0.12);
|
||||
overflow: hidden;
|
||||
transition: width 0.3s, height 0.3s, border-radius 0.3s;
|
||||
}
|
||||
|
||||
/* 最大化状态 */
|
||||
.app-window.is-maximized {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* 头部 Header */
|
||||
.window-header {
|
||||
height: 46px;
|
||||
background: linear-gradient(90deg, #4f46e5, #3b82f6);
|
||||
color: #fff;
|
||||
height: 38px; /* 经典桌面应用高度 */
|
||||
background: #f8fafc; /* 极浅的灰,区分内容区 */
|
||||
border-bottom: 1px solid rgba(0,0,0,0.05);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 12px;
|
||||
padding: 0 0 0 12px;
|
||||
user-select: none;
|
||||
-webkit-app-region: drag; /* Electron/Tauri 拖拽支持 */
|
||||
}
|
||||
|
||||
.window-left {
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.window-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.window-title {
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.5px;
|
||||
gap: 10px;
|
||||
color: #475569;
|
||||
}
|
||||
.app-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #6366f1; /* 品牌色 */
|
||||
}
|
||||
.app-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 窗口控制按钮 */
|
||||
.window-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 100%;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
width: 46px; /* 较宽的点击区域,类似 Win10/11 */
|
||||
height: 100%;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
transition: background 0.15s ease;
|
||||
color: #64748b;
|
||||
cursor: default;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.control-btn.close:hover {
|
||||
background: rgba(239, 68, 68, 0.85);
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.window-content {
|
||||
/* 内容区 */
|
||||
.window-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px;
|
||||
min-height: 0;
|
||||
background: #f9fafb;
|
||||
position: relative;
|
||||
overflow: hidden; /* 确保内容不溢出圆角 */
|
||||
background: #fff;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@ -1,10 +1,12 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import MainView from '@/views/Main.vue'
|
||||
import TestView from '@/views/Test.vue'
|
||||
|
||||
const routes = [
|
||||
{ path: '/auth/login', component: () => import('@/views/auth/Login.vue') },
|
||||
{ path: '/', component: MainView },
|
||||
{ path: '/index', component: MainView },
|
||||
{ path: '/test', component: TestView },
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
|
||||
20
frontend/web/src/views/Test.vue
Normal file
20
frontend/web/src/views/Test.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<template>
|
||||
|
||||
<div id="Test">
|
||||
<MyInput icon-name="user"></MyInput>
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import MyInput from '@/components/MyInput.vue';
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<style scoped>
|
||||
|
||||
|
||||
</style>
|
||||
@ -1,454 +1,292 @@
|
||||
<template>
|
||||
<div class="login-wrap" @keyup.enter="handleLogin">
|
||||
<div class="login-card" role="form" aria-label="登录表单">
|
||||
<!-- 左侧品牌/装饰 -->
|
||||
<div class="login-side">
|
||||
<div class="brand">
|
||||
<div class="brand-logo" aria-hidden="true">
|
||||
<!-- 圆形头像徽标 -->
|
||||
<svg
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect x="2" y="2" width="20" height="20" rx="5" fill="currentColor" />
|
||||
<path
|
||||
d="M8 14c0-1.657 1.343-3 3-3s3 1.343 3 3"
|
||||
stroke="white"
|
||||
stroke-width="1.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle cx="11" cy="9" r="1.3" fill="white" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="brand-name">即时通讯</div>
|
||||
</div>
|
||||
|
||||
<p class="side-desc">与朋友、同事保持联络 — 轻量、快速、私密。</p>
|
||||
|
||||
<div class="side-illu" aria-hidden="true">
|
||||
<!-- 简单装饰圆块,避免加载外部图片 -->
|
||||
<div class="bubble b1"></div>
|
||||
<div class="bubble b2"></div>
|
||||
<div class="bubble b3"></div>
|
||||
</div>
|
||||
<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="login-body">
|
||||
<header class="login-header">
|
||||
<h2 class="login-title">欢迎回来</h2>
|
||||
<p class="login-subtitle">使用账号登录以继续</p>
|
||||
</header>
|
||||
|
||||
<form class="login-form" @submit.prevent="handleLogin" autocomplete="on" novalidate>
|
||||
<div class="input-container" :class="{ focused: usernameFocused || username }">
|
||||
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
d="M20 21V19C20 16.7909 18.2091 15 16 15H8C5.79086 15 4 16.7909 4 19V21"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.6"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<circle cx="12" cy="7" r="4" fill="none" stroke="currentColor" stroke-width="1.6" />
|
||||
</svg>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
v-model="username"
|
||||
@focus="usernameFocused = true"
|
||||
@blur="usernameFocused = false"
|
||||
placeholder="用户名 / 邮箱"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="input-container" :class="{ focused: passwordFocused || password }">
|
||||
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect
|
||||
x="3"
|
||||
y="11"
|
||||
width="18"
|
||||
height="11"
|
||||
rx="2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.6"
|
||||
/>
|
||||
<path
|
||||
d="M7 11V7C7 4.23858 9.23858 2 12 2C14.7614 2 17 4.23858 17 7V11"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.6"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
v-model="password"
|
||||
@focus="passwordFocused = true"
|
||||
@blur="passwordFocused = false"
|
||||
placeholder="密码"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="row-between">
|
||||
<label class="remember">
|
||||
<input type="checkbox" v-model="remember" />
|
||||
<span class="box" aria-hidden="true"></span>
|
||||
记住我
|
||||
</label>
|
||||
<a class="link" href="#" @click.prevent>忘记密码?</a>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary" :disabled="loading" aria-disabled="loading">
|
||||
<span v-if="!loading">登录</span>
|
||||
<span v-else>登录中...</span>
|
||||
</button>
|
||||
|
||||
<p v-if="errorMsg" class="error">{{ errorMsg }}</p>
|
||||
</form>
|
||||
|
||||
<footer class="login-footer">
|
||||
<span>还没有账号?</span>
|
||||
<a class="link" href="#" @click.prevent>注册</a>
|
||||
</footer>
|
||||
<div class="visual-footer">
|
||||
<span>© 2024 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="handleLogin">
|
||||
<div class="input-group">
|
||||
<label>用户名 / 邮箱</label>
|
||||
<div class="field-wrap">
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</svg>
|
||||
<input type="text" v-model="form.username" placeholder="name@company.com" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<div class="label-row">
|
||||
<label>密码</label>
|
||||
<a href="#" class="forgot-link">忘记密码?</a>
|
||||
</div>
|
||||
<div class="field-wrap">
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||||
</svg>
|
||||
<input type="password" v-model="form.password" placeholder="••••••••" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="submit-btn" :disabled="loading">
|
||||
{{ loading ? '登录中...' : '登 录' }}
|
||||
<svg v-if="!loading" viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round" class="arrow"><line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline></svg>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="register-hint">
|
||||
还没有账号? <a href="#">联系管理员注册</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { reactive, ref } from 'vue'
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const remember = ref(false)
|
||||
const errorMsg = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const usernameFocused = ref(false)
|
||||
const passwordFocused = ref(false)
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const handleLogin = () => {
|
||||
errorMsg.value = ''
|
||||
if (!username.value.trim() || !password.value) {
|
||||
errorMsg.value = '用户名或密码不能为空'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
// 模拟登录延迟(客户端示例),生产中替换为 API 调用
|
||||
setTimeout(() => {
|
||||
loading.value = false
|
||||
if (username.value === 'admin' && password.value === '123456') {
|
||||
errorMsg.value = ''
|
||||
alert('登录成功!')
|
||||
// TODO: 跳转到 IM 主界面
|
||||
} else {
|
||||
errorMsg.value = '用户名或密码错误'
|
||||
}
|
||||
}, 700)
|
||||
setTimeout(() => loading.value = false, 1500)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 容器与居中 */
|
||||
.login-wrap {
|
||||
/* 撑满 Window 组件的内容区 */
|
||||
.login-layout {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 登录卡片:左右两栏 */
|
||||
.login-card {
|
||||
width: 740px;
|
||||
max-width: calc(100% - 48px);
|
||||
height: 460px;
|
||||
display: flex;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.98));
|
||||
box-shadow: 0 18px 40px rgba(16, 24, 40, 0.08);
|
||||
border: 1px solid rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
/* 左侧品牌区域 */
|
||||
.login-side {
|
||||
width: 44%;
|
||||
min-width: 280px;
|
||||
padding: 28px 22px;
|
||||
background: linear-gradient(160deg, rgba(59, 130, 246, 0.06), rgba(99, 102, 241, 0.04));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
/* --- 左侧视觉 --- */
|
||||
.side-visual {
|
||||
width: 42%;
|
||||
background: linear-gradient(135deg, #4f46e5 0%, #3b82f6 100%);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
color: white;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
/* 增加一点背景纹理 */
|
||||
.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;
|
||||
}
|
||||
.brand-logo {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
|
||||
.brand-container {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
.hero-title {
|
||||
font-size: 48px;
|
||||
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;
|
||||
}
|
||||
|
||||
.visual-footer {
|
||||
position: absolute;
|
||||
bottom: 30px;
|
||||
left: 40px;
|
||||
right: 40px;
|
||||
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;
|
||||
}
|
||||
|
||||
/* --- 右侧表单 --- */
|
||||
.side-form {
|
||||
flex: 1;
|
||||
background: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #3b82f6;
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
border-radius: 12px;
|
||||
padding: 40px;
|
||||
}
|
||||
.brand-name {
|
||||
font-size: 18px;
|
||||
|
||||
.form-wrapper {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.welcome-header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.welcome-header h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
color: #1e293b;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.welcome-header p {
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.side-desc {
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
max-width: 210px;
|
||||
margin-bottom: 18px;
|
||||
.input-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* 简单的装饰圆块 */
|
||||
.side-illu {
|
||||
position: absolute;
|
||||
right: -30px;
|
||||
bottom: -20px;
|
||||
}
|
||||
.bubble {
|
||||
border-radius: 50%;
|
||||
opacity: 0.12;
|
||||
}
|
||||
.b1 {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background: linear-gradient(135deg, #60a5fa, #7c3aed);
|
||||
transform: rotate(10deg);
|
||||
margin: 8px;
|
||||
}
|
||||
.b2 {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
background: linear-gradient(135deg, #a78bfa, #60a5fa);
|
||||
margin: 8px;
|
||||
}
|
||||
.b3 {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: linear-gradient(135deg, #93c5fd, #a78bfa);
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
/* 右侧表单 */
|
||||
.login-body {
|
||||
width: 56%;
|
||||
padding: 30px 34px;
|
||||
.label-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: left;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.login-title {
|
||||
font-size: 20px;
|
||||
margin: 0 0 6px;
|
||||
color: #0f172a;
|
||||
font-weight: 700;
|
||||
}
|
||||
.login-subtitle {
|
||||
margin: 0;
|
||||
label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.forgot-link {
|
||||
font-size: 12px;
|
||||
color: #4f46e5;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* 表单 */
|
||||
.login-form {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* 输入容器 */
|
||||
.input-container {
|
||||
/* 输入框样式:现代填充风格 */
|
||||
.field-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #ffffff;
|
||||
border-radius: 10px;
|
||||
padding: 12px 12px 12px 44px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.02);
|
||||
transition: all 0.22s ease;
|
||||
}
|
||||
.input-container .icon {
|
||||
.field-wrap .icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: #94a3b8;
|
||||
opacity: 0.95;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.input-container input {
|
||||
.field-wrap input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 12px 12px 12px 40px;
|
||||
background: #f1f5f9; /* 浅灰底色 */
|
||||
border: 2px solid transparent;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: #0f172a;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
color: #1e293b;
|
||||
outline: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* 聚焦态 */
|
||||
.input-container.focused {
|
||||
border-color: rgba(99, 102, 241, 0.9);
|
||||
box-shadow: 0 8px 20px rgba(99, 102, 241, 0.08);
|
||||
transform: translateY(-2px);
|
||||
/* 聚焦交互 */
|
||||
.field-wrap input:focus {
|
||||
background: #fff;
|
||||
border-color: #4f46e5; /* 品牌色 */
|
||||
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.1);
|
||||
}
|
||||
.input-container.focused .icon {
|
||||
color: rgba(99, 102, 241, 0.95);
|
||||
.field-wrap input:focus + .icon,
|
||||
.field-wrap input:focus ~ .icon {
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
/* 行内布局 */
|
||||
.row-between {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* 记住我 checkbox */
|
||||
.remember {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.remember input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.remember .box {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: inline-block;
|
||||
border-radius: 6px;
|
||||
border: 1.5px solid rgba(15, 23, 42, 0.08);
|
||||
background: white;
|
||||
box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.03);
|
||||
transition: all 0.14s ease;
|
||||
}
|
||||
.remember input:checked + .box {
|
||||
background: linear-gradient(90deg, #6366f1, #3b82f6);
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
.remember input:checked + .box::after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 6px;
|
||||
height: 10px;
|
||||
border: 2px solid white;
|
||||
border-left: 0;
|
||||
border-top: 0;
|
||||
transform: translate(5px, 2px) rotate(45deg);
|
||||
}
|
||||
|
||||
/* 链接样式 */
|
||||
.link {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
/* 主操作按钮 */
|
||||
.btn-primary {
|
||||
/* 按钮 */
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
background: linear-gradient(90deg, #6366f1, #3b82f6);
|
||||
margin-top: 10px;
|
||||
padding: 12px;
|
||||
background: #4f46e5;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 20px rgba(59, 130, 246, 0.18);
|
||||
transition:
|
||||
transform 0.12s ease,
|
||||
box-shadow 0.12s ease,
|
||||
opacity 0.12s;
|
||||
}
|
||||
.btn-primary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* 错误提示 */
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
background: #fff5f5;
|
||||
border-left: 4px solid #f87171;
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* 页脚注册 */
|
||||
.login-footer {
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
transition: background 0.2s, transform 0.1s;
|
||||
}
|
||||
.submit-btn:hover {
|
||||
background: #4338ca;
|
||||
}
|
||||
.submit-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 820px) {
|
||||
.login-card {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
width: 100%;
|
||||
}
|
||||
.login-side {
|
||||
width: 100%;
|
||||
min-height: 140px;
|
||||
order: 1;
|
||||
}
|
||||
.login-body {
|
||||
width: 100%;
|
||||
order: 2;
|
||||
padding: 22px;
|
||||
}
|
||||
.register-hint {
|
||||
margin-top: 24px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
</style>
|
||||
.register-hint a {
|
||||
color: #4f46e5;
|
||||
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; }
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in New Issue
Block a user