Merge pull request 'add(components): 添加MyInput组件' (#24) from feature-nxdev into main

Reviewed-on: #24
This commit is contained in:
西街长安 2025-12-12 22:43:59 +08:00
commit d55fc351ad
17 changed files with 721 additions and 498 deletions

View File

@ -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())
;
}

View File

@ -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;

View File

@ -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>
/// 私聊

View File

@ -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);
}

View 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
}
}

View File

@ -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;
}
}
}

View File

@ -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 =>

View 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;
}
}
}

View File

@ -10,7 +10,7 @@
"Key": "YourSuperSecretKey123456784124214190!",
"Issuer": "IMDemo",
"Audience": "IMClients",
"AccessTokenMinutes": 15,
"AccessTokenMinutes": 30,
"RefreshTokenDays": 30
},
"ConnectionStrings": {

View File

@ -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": {

View File

@ -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"

View File

@ -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>

View 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 元素上设置了 colorSVG 会自动继承 */
.field-wrap input:focus ~ .icon {
color: #4f46e5; /* 聚焦时的颜色 */
}
</style>

View File

@ -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>

View File

@ -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({

View 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>

View File

@ -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>