Merge pull request 'feature-nxdev' (#18) from feature-nxdev into main
Reviewed-on: #18
This commit is contained in:
commit
0675e1807b
26
backend/IM_API/Dtos/ClearConversationsDto.cs
Normal file
26
backend/IM_API/Dtos/ClearConversationsDto.cs
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
namespace IM_API.Dtos
|
||||||
|
{
|
||||||
|
public class ClearConversationsDto
|
||||||
|
{
|
||||||
|
public int UserId { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// 聊天类型
|
||||||
|
/// </summary>
|
||||||
|
public ChatType ChatType { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// 目标ID,聊天类型为群则为群id,私聊为用户id
|
||||||
|
/// </summary>
|
||||||
|
public int TargetId { get; set; }
|
||||||
|
}
|
||||||
|
public enum ChatType
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 私聊
|
||||||
|
/// </summary>
|
||||||
|
single = 0,
|
||||||
|
/// <summary>
|
||||||
|
/// 私聊
|
||||||
|
/// </summary>
|
||||||
|
group = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
25
backend/IM_API/Dtos/HandleFriendRequestDto.cs
Normal file
25
backend/IM_API/Dtos/HandleFriendRequestDto.cs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
namespace IM_API.Dtos
|
||||||
|
{
|
||||||
|
public class HandleFriendRequestDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 好友请求Id
|
||||||
|
/// </summary>
|
||||||
|
public int RequestId { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// 处理操作
|
||||||
|
/// </summary>
|
||||||
|
public HandleFriendRequestAction Action { get; set; }
|
||||||
|
}
|
||||||
|
public enum HandleFriendRequestAction
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 同意
|
||||||
|
/// </summary>
|
||||||
|
Accept = 0,
|
||||||
|
/// <summary>
|
||||||
|
/// 拒绝
|
||||||
|
/// </summary>
|
||||||
|
Reject = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,6 +10,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.21" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.21">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.21">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
@ -19,7 +20,9 @@
|
|||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" />
|
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" />
|
||||||
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.3" />
|
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.3" />
|
||||||
|
<PackageReference Include="StackExchange.Redis" Version="2.9.32" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
21
backend/IM_API/Interface/Services/IConversationService.cs
Normal file
21
backend/IM_API/Interface/Services/IConversationService.cs
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
using IM_API.Dtos;
|
||||||
|
using IM_API.Models;
|
||||||
|
|
||||||
|
namespace IM_API.Interface.Services
|
||||||
|
{
|
||||||
|
public interface IConversationService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 清除消息会话
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="clearConversationsDto"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
Task<bool> ClearConversationsAsync(ClearConversationsDto clearConversationsDto);
|
||||||
|
/// <summary>
|
||||||
|
/// 获取用户当前消息会话
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userId">用户id</param>
|
||||||
|
/// <returns></returns>
|
||||||
|
Task<Conversation> GetConversationsAsync(int userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -28,5 +28,37 @@ namespace IM_API.Interface.Services
|
|||||||
/// <param name="limit"></param>
|
/// <param name="limit"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
Task<FriendRequest> GetFriendRequestListAsync(int userId,bool isReceived,int page,int limit);
|
Task<FriendRequest> GetFriendRequestListAsync(int userId,bool isReceived,int page,int limit);
|
||||||
|
/// <summary>
|
||||||
|
/// 处理好友请求
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="requestDto"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
Task<bool> HandleFriendRequestAsync(HandleFriendRequestDto requestDto);
|
||||||
|
/// <summary>
|
||||||
|
/// 通过用户Id删除好友关系
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userId">操作用户Id</param>
|
||||||
|
/// <param name="toUserId">被删除用户ID</param>
|
||||||
|
/// <returns></returns>
|
||||||
|
Task<bool> DeleteFriendByUserIdAsync(int userId,int toUserId);
|
||||||
|
/// <summary>
|
||||||
|
/// 通过好友关系Id删除好友关系
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="friendId">好友关系id</param>
|
||||||
|
/// <returns></returns>
|
||||||
|
Task<bool> DeleteFriendAsync(int friendId);
|
||||||
|
/// <summary>
|
||||||
|
/// 通过用户Id拉黑好友关系
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userId">操作用户Id</param>
|
||||||
|
/// <param name="toUserId">被拉黑用户ID</param>
|
||||||
|
/// <returns></returns>
|
||||||
|
Task<bool> BlockFriendByUserIdAsync(int userId, int toUserId);
|
||||||
|
/// <summary>
|
||||||
|
/// 通过好友关系Id拉黑好友关系
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="friendId">好友关系id</param>
|
||||||
|
/// <returns></returns>
|
||||||
|
Task<bool> BlockeFriendAsync(int friendId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
21
backend/IM_API/Interface/Services/IJWTService.cs
Normal file
21
backend/IM_API/Interface/Services/IJWTService.cs
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace IM_API.Interface.Services
|
||||||
|
{
|
||||||
|
public interface IJWTService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 生成用户凭证
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="claims">负载</param>
|
||||||
|
/// <param name="expiresAt">过期时间</param>
|
||||||
|
/// <returns></returns>
|
||||||
|
string GenerateAccessToken(IEnumerable<Claim> claims, DateTime expiresAt);
|
||||||
|
/// <summary>
|
||||||
|
/// 创建用户凭证
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userId"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
(string token, DateTime expiresAt) CreateAccessTokenForUser(int userId,string username,string role);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
backend/IM_API/Interface/Services/IRefreshTokenService.cs
Normal file
27
backend/IM_API/Interface/Services/IRefreshTokenService.cs
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
namespace IM_API.Interface.Services
|
||||||
|
{
|
||||||
|
public interface IRefreshTokenService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 创建刷新令牌
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userId"></param>
|
||||||
|
/// <param name="ct"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
Task<string> CreateRefreshTokenAsync(int userId, CancellationToken ct = default);
|
||||||
|
/// <summary>
|
||||||
|
/// 验证刷新令牌
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="token">刷新令牌</param>
|
||||||
|
/// <param name="ct"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
Task<(bool ok, int userId)> ValidateRefreshTokenAsync(string token, CancellationToken ct = default);
|
||||||
|
/// <summary>
|
||||||
|
/// 删除更新令牌
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="token">刷新令牌</param>
|
||||||
|
/// <param name="ct"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
Task RevokeRefreshTokenAsync(string token, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,7 +1,11 @@
|
|||||||
|
|
||||||
using IM_API.Configs;
|
using IM_API.Configs;
|
||||||
using IM_API.Models;
|
using IM_API.Models;
|
||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using StackExchange.Redis;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
namespace IM_API
|
namespace IM_API
|
||||||
{
|
{
|
||||||
@ -16,14 +20,74 @@ namespace IM_API
|
|||||||
.SetBasePath(Directory.GetCurrentDirectory())
|
.SetBasePath(Directory.GetCurrentDirectory())
|
||||||
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
|
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
|
||||||
.Build();
|
.Build();
|
||||||
string conStr = builder.Configuration.GetConnectionString("DefaultConnection")!;
|
string conStr = configuration.GetConnectionString("DefaultConnection")!;
|
||||||
|
string redisConStr = configuration.GetConnectionString("Redis");
|
||||||
|
//注入数据库上下文
|
||||||
builder.Services.AddDbContext<ImContext>(options =>
|
builder.Services.AddDbContext<ImContext>(options =>
|
||||||
{
|
{
|
||||||
options.UseMySql(conStr,ServerVersion.AutoDetect(conStr));
|
options.UseMySql(conStr,ServerVersion.AutoDetect(conStr));
|
||||||
});
|
});
|
||||||
|
//注入redis
|
||||||
|
var redis = ConnectionMultiplexer.Connect(redisConStr);
|
||||||
|
builder.Services.AddSingleton<IConnectionMultiplexer>(redis);
|
||||||
|
|
||||||
builder.Services.AddAllService(configuration);
|
builder.Services.AddAllService(configuration);
|
||||||
|
|
||||||
|
builder.Services.AddSignalR();
|
||||||
|
//允许所有来源(跨域)
|
||||||
|
builder.Services.AddCors(options =>
|
||||||
|
{
|
||||||
|
options.AddDefaultPolicy(policy =>
|
||||||
|
{
|
||||||
|
policy.AllowAnyHeader()
|
||||||
|
.AllowAnyMethod()
|
||||||
|
.AllowAnyHeader()
|
||||||
|
.AllowAnyOrigin()
|
||||||
|
.AllowAnyOrigin();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
//凭证处理
|
||||||
|
builder.Services.AddAuthentication(options =>
|
||||||
|
{
|
||||||
|
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||||
|
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||||
|
})
|
||||||
|
.AddJwtBearer(options =>
|
||||||
|
{
|
||||||
|
//https非必须
|
||||||
|
options.RequireHttpsMetadata = false;
|
||||||
|
//保存token
|
||||||
|
options.SaveToken = true;
|
||||||
|
options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
|
||||||
|
{
|
||||||
|
//验证签发者
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidIssuer = configuration["Jwt:Issuer"],
|
||||||
|
//验证受众
|
||||||
|
ValidateAudience = true,
|
||||||
|
ValidAudience = configuration["Jwt:Audience"],
|
||||||
|
//验证签名密钥
|
||||||
|
ValidateIssuerSigningKey = true,
|
||||||
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["Jwt:Key"])),
|
||||||
|
//时间偏差容忍
|
||||||
|
ValidateLifetime = true,
|
||||||
|
ClockSkew = TimeSpan.FromSeconds(30)
|
||||||
|
|
||||||
|
|
||||||
|
};
|
||||||
|
//websocket token凭证处理
|
||||||
|
options.Events = new JwtBearerEvents {
|
||||||
|
OnMessageReceived = context =>
|
||||||
|
{
|
||||||
|
var accessToken = context.Request.Query["access_token"];
|
||||||
|
if (!string.IsNullOrEmpty(accessToken))
|
||||||
|
{
|
||||||
|
context.Token = accessToken;
|
||||||
|
}
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers();
|
||||||
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
|
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
@ -41,6 +105,7 @@ namespace IM_API
|
|||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
app.UseAuthentication();
|
||||||
|
|
||||||
|
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
namespace IM_API.Services
|
using IM_API.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace IM_API.Services
|
||||||
{
|
{
|
||||||
public class FriendService
|
public class FriendService
|
||||||
{
|
{
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
55
backend/IM_API/Services/JWTService.cs
Normal file
55
backend/IM_API/Services/JWTService.cs
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
using IM_API.Interface.Services;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace IM_API.Services
|
||||||
|
{
|
||||||
|
public class JWTService : IJWTService
|
||||||
|
{
|
||||||
|
private readonly IConfiguration _config;
|
||||||
|
private readonly string _key;
|
||||||
|
private readonly string _issuer;
|
||||||
|
private readonly string _audience;
|
||||||
|
private readonly int _accessMinutes;
|
||||||
|
|
||||||
|
public JWTService(IConfiguration config)
|
||||||
|
{
|
||||||
|
_config = config;
|
||||||
|
_key = _config["Jwt:Key"]!;
|
||||||
|
_issuer = _config["Jwt:Issuer"]!;
|
||||||
|
_audience = _config["Jwt:Audience"]!;
|
||||||
|
_accessMinutes = int.Parse(_config["Jwt:AccessTokenMinutes"] ?? "15");
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GenerateAccessToken(IEnumerable<Claim> claims, DateTime expiresAt)
|
||||||
|
{
|
||||||
|
var keyBytes = Encoding.UTF8.GetBytes(_key);
|
||||||
|
var creds = new SigningCredentials(new SymmetricSecurityKey(keyBytes), SecurityAlgorithms.HmacSha256);
|
||||||
|
|
||||||
|
var token = new JwtSecurityToken(
|
||||||
|
issuer: _issuer,
|
||||||
|
audience: _audience,
|
||||||
|
claims: claims,
|
||||||
|
expires: expiresAt,
|
||||||
|
signingCredentials: creds
|
||||||
|
);
|
||||||
|
|
||||||
|
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public (string token, DateTime expiresAt) CreateAccessTokenForUser(int userId, string username, string role)
|
||||||
|
{
|
||||||
|
var expiresAt = DateTime.UtcNow.AddMinutes(_accessMinutes);
|
||||||
|
var claims = new[]
|
||||||
|
{
|
||||||
|
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||||
|
new Claim(ClaimTypes.Name, username),
|
||||||
|
new Claim(ClaimTypes.Role, role)
|
||||||
|
};
|
||||||
|
var token = GenerateAccessToken(claims, expiresAt);
|
||||||
|
return (token, expiresAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
66
backend/IM_API/Services/RedisRefreshTokenService.cs
Normal file
66
backend/IM_API/Services/RedisRefreshTokenService.cs
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
using IM_API.Interface.Services;
|
||||||
|
using Microsoft.AspNetCore.Connections;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using StackExchange.Redis;
|
||||||
|
using System.Numerics;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace IM_API.Services
|
||||||
|
{
|
||||||
|
public class RedisRefreshTokenService : IRefreshTokenService
|
||||||
|
{
|
||||||
|
private readonly ILogger<RedisRefreshTokenService> _logger;
|
||||||
|
//redis数据库
|
||||||
|
private readonly IDatabase _db;
|
||||||
|
private IConfiguration configuration;
|
||||||
|
//过期时长
|
||||||
|
private readonly TimeSpan _refreshTTL;
|
||||||
|
public RedisRefreshTokenService(ILogger<RedisRefreshTokenService> logger, IConnectionMultiplexer multiplexer, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_db = multiplexer.GetDatabase();
|
||||||
|
this.configuration = configuration;
|
||||||
|
//设置refresh过期时间
|
||||||
|
var days = int.Parse(this.configuration["Jwt:RefreshTokenDays"] ?? "30");
|
||||||
|
_refreshTTL = TimeSpan.FromDays(days);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GenerateTokenStr()
|
||||||
|
{
|
||||||
|
var bytes = RandomNumberGenerator.GetBytes(32);
|
||||||
|
return Convert.ToBase64String(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> CreateRefreshTokenAsync(int userId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
string token = GenerateTokenStr();
|
||||||
|
var payload = new { UserId = userId,CreateAt = DateTime.Now};
|
||||||
|
string json = JsonConvert.SerializeObject(payload);
|
||||||
|
//token写入redis
|
||||||
|
await _db.StringSetAsync(token,json,_refreshTTL);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RevokeRefreshTokenAsync(string token, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await _db.KeyDeleteAsync(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(bool ok, int userId)> ValidateRefreshTokenAsync(string token, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var json = await _db.StringGetAsync(token);
|
||||||
|
if (json.IsNullOrEmpty) return (false,-1);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var doc = JsonConvert.DeserializeObject<JsonElement>(json);
|
||||||
|
var userId = doc.GetProperty("UserId").GetInt32();
|
||||||
|
return (true,userId);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return (false,-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,7 +6,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
|
"Jwt": {
|
||||||
|
"Key": "change_this_super_secret_key_in_prod",
|
||||||
|
"Issuer": "IMDemo",
|
||||||
|
"Audience": "IMClients",
|
||||||
|
"AccessTokenMinutes": 15,
|
||||||
|
"RefreshTokenDays": 30
|
||||||
|
},
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"DefaultConnection": "Server=frp-era.com;Port=26582;Database=IM;User=product;Password=12345678;"
|
"DefaultConnection": "Server=frp-era.com;Port=26582;Database=IM;User=product;Password=12345678;",
|
||||||
|
"Redis": "192.168.5.100:6379"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,10 +3,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<WindowLayout>
|
<WindowLayout>
|
||||||
|
|
||||||
<RouterView></RouterView>
|
<RouterView></RouterView>
|
||||||
</WindowLayout>
|
</WindowLayout>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@ -23,12 +23,25 @@
|
|||||||
</button>
|
</button>
|
||||||
<button class="control-btn maximize" @click="maximize" title="最大化">
|
<button class="control-btn maximize" @click="maximize" title="最大化">
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12">
|
<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" />
|
<rect
|
||||||
|
x="2"
|
||||||
|
y="2"
|
||||||
|
width="8"
|
||||||
|
height="8"
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="none"
|
||||||
|
stroke-width="1.2"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="control-btn close" @click="close" title="关闭">
|
<button class="control-btn close" @click="close" title="关闭">
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12">
|
<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" />
|
<path
|
||||||
|
d="M2 2l8 8M10 2L2 10"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -42,9 +55,15 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
function minimize() { console.log('最小化') }
|
function minimize() {
|
||||||
function maximize() { console.log('最大化') }
|
console.log('最小化')
|
||||||
function close() { console.log('关闭') }
|
}
|
||||||
|
function maximize() {
|
||||||
|
console.log('最大化')
|
||||||
|
}
|
||||||
|
function close() {
|
||||||
|
console.log('关闭')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@ -32,12 +32,7 @@
|
|||||||
|
|
||||||
<div class="search-box">
|
<div class="search-box">
|
||||||
<i class="fas fa-search search-icon"></i>
|
<i class="fas fa-search search-icon"></i>
|
||||||
<input
|
<input type="text" placeholder="搜索..." v-model="searchText" @input="handleSearch" />
|
||||||
type="text"
|
|
||||||
placeholder="搜索..."
|
|
||||||
v-model="searchText"
|
|
||||||
@input="handleSearch"
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
@ -48,7 +43,7 @@
|
|||||||
v-for="chat in filteredChats"
|
v-for="chat in filteredChats"
|
||||||
:key="chat.id"
|
:key="chat.id"
|
||||||
@click="selectChat(chat)"
|
@click="selectChat(chat)"
|
||||||
:class="{active: currentChat?.id === chat.id}"
|
:class="{ active: currentChat?.id === chat.id }"
|
||||||
>
|
>
|
||||||
<div class="chat-avatar">{{ getInitials(chat.name) }}</div>
|
<div class="chat-avatar">{{ getInitials(chat.name) }}</div>
|
||||||
<div class="info">
|
<div class="info">
|
||||||
@ -130,7 +125,9 @@
|
|||||||
:key="msg.id"
|
:key="msg.id"
|
||||||
:class="['msg', msg.from === 'me' ? 'me' : 'them']"
|
:class="['msg', msg.from === 'me' ? 'me' : 'them']"
|
||||||
>
|
>
|
||||||
<div class="msg-avatar">{{ getInitials(msg.from === 'me' ? '我' : currentChat.name) }}</div>
|
<div class="msg-avatar">
|
||||||
|
{{ getInitials(msg.from === 'me' ? '我' : currentChat.name) }}
|
||||||
|
</div>
|
||||||
<div class="msg-content">
|
<div class="msg-content">
|
||||||
<div class="bubble">{{ msg.text }}</div>
|
<div class="bubble">{{ msg.text }}</div>
|
||||||
<div class="msg-time">{{ msg.time }}</div>
|
<div class="msg-time">{{ msg.time }}</div>
|
||||||
@ -156,7 +153,10 @@
|
|||||||
|
|
||||||
<!-- 空聊天状态 -->
|
<!-- 空聊天状态 -->
|
||||||
<aside class="chat-area" v-else>
|
<aside class="chat-area" v-else>
|
||||||
<div class="empty-state" style="height: 100%; display: flex; flex-direction: column; justify-content: center;">
|
<div
|
||||||
|
class="empty-state"
|
||||||
|
style="height: 100%; display: flex; flex-direction: column; justify-content: center"
|
||||||
|
>
|
||||||
<i class="fas fa-comments icon"></i>
|
<i class="fas fa-comments icon"></i>
|
||||||
<h3>选择一个对话开始聊天</h3>
|
<h3>选择一个对话开始聊天</h3>
|
||||||
<p>在左侧列表中选择联系人开始对话</p>
|
<p>在左侧列表中选择联系人开始对话</p>
|
||||||
@ -172,18 +172,32 @@ import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
|
|||||||
const menus = ref([
|
const menus = ref([
|
||||||
{ key: 'chat', label: '消息', icon: 'fas fa-comment-dots', notification: 3 },
|
{ key: 'chat', label: '消息', icon: 'fas fa-comment-dots', notification: 3 },
|
||||||
{ key: 'friends', label: '联系人', icon: 'fas fa-user-friends' },
|
{ key: 'friends', label: '联系人', icon: 'fas fa-user-friends' },
|
||||||
{ key: 'groups', label: '群聊', icon: 'fas fa-users' }
|
{ key: 'groups', label: '群聊', icon: 'fas fa-users' },
|
||||||
])
|
])
|
||||||
|
|
||||||
const currentTab = ref('chat')
|
const currentTab = ref('chat')
|
||||||
const searchText = ref('')
|
const searchText = ref('')
|
||||||
|
|
||||||
const chats = ref([
|
const chats = ref([
|
||||||
{ id: 1, name: '张三', lastMsg: '今晚一起吃饭吗?', lastTime: '10:30', unread: 2, status: 'online' },
|
{
|
||||||
|
id: 1,
|
||||||
|
name: '张三',
|
||||||
|
lastMsg: '今晚一起吃饭吗?',
|
||||||
|
lastTime: '10:30',
|
||||||
|
unread: 2,
|
||||||
|
status: 'online',
|
||||||
|
},
|
||||||
{ id: 2, name: '李四', lastMsg: '收到文件了吗?', lastTime: '昨天', unread: 0, status: 'online' },
|
{ id: 2, name: '李四', lastMsg: '收到文件了吗?', lastTime: '昨天', unread: 0, status: 'online' },
|
||||||
{ id: 3, name: '王五', lastMsg: '项目进展如何?', lastTime: '周三', unread: 1, status: 'offline' },
|
{
|
||||||
|
id: 3,
|
||||||
|
name: '王五',
|
||||||
|
lastMsg: '项目进展如何?',
|
||||||
|
lastTime: '周三',
|
||||||
|
unread: 1,
|
||||||
|
status: 'offline',
|
||||||
|
},
|
||||||
{ id: 4, name: '赵六', lastMsg: '周末有空吗?', lastTime: '周一', unread: 0, status: 'online' },
|
{ id: 4, name: '赵六', lastMsg: '周末有空吗?', lastTime: '周一', unread: 0, status: 'online' },
|
||||||
{ id: 5, name: '钱七', lastMsg: '会议改期了', lastTime: '3月15日', unread: 0, status: 'online' }
|
{ id: 5, name: '钱七', lastMsg: '会议改期了', lastTime: '3月15日', unread: 0, status: 'online' },
|
||||||
])
|
])
|
||||||
|
|
||||||
const friends = ref([
|
const friends = ref([
|
||||||
@ -193,7 +207,7 @@ const friends = ref([
|
|||||||
{ id: 4, name: '赵六', status: 'online' },
|
{ id: 4, name: '赵六', status: 'online' },
|
||||||
{ id: 5, name: '钱七', status: 'online' },
|
{ id: 5, name: '钱七', status: 'online' },
|
||||||
{ id: 6, name: '孙八', status: 'online' },
|
{ id: 6, name: '孙八', status: 'online' },
|
||||||
{ id: 7, name: '周九', status: 'offline' }
|
{ id: 7, name: '周九', status: 'offline' },
|
||||||
])
|
])
|
||||||
|
|
||||||
const currentChat = ref(null)
|
const currentChat = ref(null)
|
||||||
@ -205,22 +219,23 @@ const messageInput = ref(null)
|
|||||||
// 计算属性
|
// 计算属性
|
||||||
const filteredChats = computed(() => {
|
const filteredChats = computed(() => {
|
||||||
if (!searchText.value) return chats.value
|
if (!searchText.value) return chats.value
|
||||||
return chats.value.filter(chat =>
|
return chats.value.filter(
|
||||||
chat.name.toLowerCase().includes(searchText.value.toLowerCase()) ||
|
(chat) =>
|
||||||
chat.lastMsg.toLowerCase().includes(searchText.value.toLowerCase())
|
chat.name.toLowerCase().includes(searchText.value.toLowerCase()) ||
|
||||||
|
chat.lastMsg.toLowerCase().includes(searchText.value.toLowerCase()),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const filteredFriends = computed(() => {
|
const filteredFriends = computed(() => {
|
||||||
if (!searchText.value) return friends.value
|
if (!searchText.value) return friends.value
|
||||||
return friends.value.filter(friend =>
|
return friends.value.filter((friend) =>
|
||||||
friend.name.toLowerCase().includes(searchText.value.toLowerCase())
|
friend.name.toLowerCase().includes(searchText.value.toLowerCase()),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 方法
|
// 方法
|
||||||
function getCurrentTabTitle() {
|
function getCurrentTabTitle() {
|
||||||
const menu = menus.value.find(m => m.key === currentTab.value)
|
const menu = menus.value.find((m) => m.key === currentTab.value)
|
||||||
return menu ? menu.label : '消息'
|
return menu ? menu.label : '消息'
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -237,24 +252,22 @@ function formatTime(date) {
|
|||||||
function simulateReply() {
|
function simulateReply() {
|
||||||
if (!currentChat.value) return
|
if (!currentChat.value) return
|
||||||
|
|
||||||
const replies = [
|
const replies = ['好的,我明白了', '听起来不错', '让我考虑一下', '没问题,就这么办']
|
||||||
'好的,我明白了',
|
|
||||||
'听起来不错',
|
|
||||||
'让我考虑一下',
|
|
||||||
'没问题,就这么办'
|
|
||||||
]
|
|
||||||
|
|
||||||
const randomReply = replies[Math.floor(Math.random() * replies.length)]
|
const randomReply = replies[Math.floor(Math.random() * replies.length)]
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(
|
||||||
messages.value.push({
|
() => {
|
||||||
id: Date.now(),
|
messages.value.push({
|
||||||
from: 'them',
|
id: Date.now(),
|
||||||
text: randomReply,
|
from: 'them',
|
||||||
time: formatTime(new Date())
|
text: randomReply,
|
||||||
})
|
time: formatTime(new Date()),
|
||||||
nextTick(scrollBottom)
|
})
|
||||||
}, 1000 + Math.random() * 2000)
|
nextTick(scrollBottom)
|
||||||
|
},
|
||||||
|
1000 + Math.random() * 2000,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectChat(chat) {
|
function selectChat(chat) {
|
||||||
@ -263,7 +276,7 @@ function selectChat(chat) {
|
|||||||
{ id: 1, from: 'them', text: `你好,我是 ${chat.name},很高兴认识你!`, time: '09:15' },
|
{ id: 1, from: 'them', text: `你好,我是 ${chat.name},很高兴认识你!`, time: '09:15' },
|
||||||
{ id: 2, from: 'me', text: '你好呀,也很高兴认识你~', time: '09:16' },
|
{ id: 2, from: 'me', text: '你好呀,也很高兴认识你~', time: '09:16' },
|
||||||
{ id: 3, from: 'them', text: '最近在忙什么呢?', time: '09:20' },
|
{ id: 3, from: 'them', text: '最近在忙什么呢?', time: '09:20' },
|
||||||
{ id: 4, from: 'me', text: '在做一个新项目,挺有意思的。', time: '09:22' }
|
{ id: 4, from: 'me', text: '在做一个新项目,挺有意思的。', time: '09:22' },
|
||||||
]
|
]
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
scrollBottom()
|
scrollBottom()
|
||||||
@ -284,7 +297,7 @@ function send() {
|
|||||||
id: Date.now(),
|
id: Date.now(),
|
||||||
from: 'me',
|
from: 'me',
|
||||||
text: input.value,
|
text: input.value,
|
||||||
time: formatTime(new Date())
|
time: formatTime(new Date()),
|
||||||
})
|
})
|
||||||
input.value = ''
|
input.value = ''
|
||||||
nextTick(scrollBottom)
|
nextTick(scrollBottom)
|
||||||
@ -483,13 +496,15 @@ onUnmounted(() => {
|
|||||||
padding: 0 20px 20px;
|
padding: 0 20px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-list ul, .friend-list ul {
|
.chat-list ul,
|
||||||
|
.friend-list ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-list li, .friend-list li {
|
.chat-list li,
|
||||||
|
.friend-list li {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@ -500,7 +515,8 @@ onUnmounted(() => {
|
|||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-list li:hover, .friend-list li:hover {
|
.chat-list li:hover,
|
||||||
|
.friend-list li:hover {
|
||||||
background: #f7fafc;
|
background: #f7fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -529,7 +545,9 @@ onUnmounted(() => {
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.name-row, .last-row, .status-row {
|
.name-row,
|
||||||
|
.last-row,
|
||||||
|
.status-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@ -6,10 +6,22 @@
|
|||||||
<div class="brand">
|
<div class="brand">
|
||||||
<div class="brand-logo" aria-hidden="true">
|
<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">
|
<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" />
|
<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"/>
|
<path
|
||||||
<circle cx="11" cy="9" r="1.3" fill="white"/>
|
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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="brand-name">即时通讯</div>
|
<div class="brand-name">即时通讯</div>
|
||||||
@ -35,18 +47,54 @@
|
|||||||
<form class="login-form" @submit.prevent="handleLogin" autocomplete="on" novalidate>
|
<form class="login-form" @submit.prevent="handleLogin" autocomplete="on" novalidate>
|
||||||
<div class="input-container" :class="{ focused: usernameFocused || username }">
|
<div class="input-container" :class="{ focused: usernameFocused || username }">
|
||||||
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
|
<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"/>
|
<path
|
||||||
<circle cx="12" cy="7" r="4" fill="none" stroke="currentColor" stroke-width="1.6"/>
|
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>
|
</svg>
|
||||||
<input id="username" type="text" v-model="username" @focus="usernameFocused = true" @blur="usernameFocused = false" placeholder="用户名 / 邮箱" required />
|
<input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
v-model="username"
|
||||||
|
@focus="usernameFocused = true"
|
||||||
|
@blur="usernameFocused = false"
|
||||||
|
placeholder="用户名 / 邮箱"
|
||||||
|
required
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="input-container" :class="{ focused: passwordFocused || password }">
|
<div class="input-container" :class="{ focused: passwordFocused || password }">
|
||||||
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
|
<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"/>
|
<rect
|
||||||
<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"/>
|
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>
|
</svg>
|
||||||
<input id="password" type="password" v-model="password" @focus="passwordFocused = true" @blur="passwordFocused = false" placeholder="密码" required />
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
v-model="password"
|
||||||
|
@focus="passwordFocused = true"
|
||||||
|
@blur="passwordFocused = false"
|
||||||
|
placeholder="密码"
|
||||||
|
required
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row-between">
|
<div class="row-between">
|
||||||
@ -126,9 +174,9 @@ const handleLogin = () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(248,250,252,0.98));
|
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);
|
box-shadow: 0 18px 40px rgba(16, 24, 40, 0.08);
|
||||||
border: 1px solid rgba(15,23,42,0.04);
|
border: 1px solid rgba(15, 23, 42, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 左侧品牌区域 */
|
/* 左侧品牌区域 */
|
||||||
@ -136,129 +184,271 @@ const handleLogin = () => {
|
|||||||
width: 44%;
|
width: 44%;
|
||||||
min-width: 280px;
|
min-width: 280px;
|
||||||
padding: 28px 22px;
|
padding: 28px 22px;
|
||||||
background: linear-gradient(160deg, rgba(59,130,246,0.06), rgba(99,102,241,0.04));
|
background: linear-gradient(160deg, rgba(59, 130, 246, 0.06), rgba(99, 102, 241, 0.04));
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand { display:flex; align-items:center; gap:12px; margin-bottom:12px; }
|
.brand {
|
||||||
.brand-logo { width:56px; height:56px; display:flex; align-items:center; justify-content:center; color: #3b82f6; background: rgba(59,130,246,0.08); border-radius:12px; }
|
display: flex;
|
||||||
.brand-name { font-size:18px; font-weight:700; color:#0f172a; }
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.brand-logo {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #3b82f6;
|
||||||
|
background: rgba(59, 130, 246, 0.08);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.brand-name {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
.side-desc { color:#475569; font-size:13px; line-height:1.5; max-width:210px; margin-bottom:18px; }
|
.side-desc {
|
||||||
|
color: #475569;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
max-width: 210px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
/* 简单的装饰圆块 */
|
/* 简单的装饰圆块 */
|
||||||
.side-illu { position:absolute; right:-30px; bottom:-20px; }
|
.side-illu {
|
||||||
.bubble { border-radius:50%; opacity:0.12; }
|
position: absolute;
|
||||||
.b1 { width:120px; height:120px; background: linear-gradient(135deg,#60a5fa,#7c3aed); transform: rotate(10deg); margin:8px; }
|
right: -30px;
|
||||||
.b2 { width:72px; height:72px; background: linear-gradient(135deg,#a78bfa,#60a5fa); margin:8px; }
|
bottom: -20px;
|
||||||
.b3 { width:40px; height:40px; background: linear-gradient(135deg,#93c5fd,#a78bfa); margin:8px; }
|
}
|
||||||
|
.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 {
|
.login-body {
|
||||||
width: 56%;
|
width: 56%;
|
||||||
padding: 30px 34px;
|
padding: 30px 34px;
|
||||||
display:flex;
|
display: flex;
|
||||||
flex-direction:column;
|
flex-direction: column;
|
||||||
justify-content:space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-header { text-align:left; margin-bottom:6px; }
|
.login-header {
|
||||||
.login-title { font-size:20px; margin:0 0 6px; color:#0f172a; font-weight:700; }
|
text-align: left;
|
||||||
.login-subtitle { margin:0; font-size:13px; color:#64748b; font-weight:500; }
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.login-title {
|
||||||
|
font-size: 20px;
|
||||||
|
margin: 0 0 6px;
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.login-subtitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
/* 表单 */
|
/* 表单 */
|
||||||
.login-form { margin-top:10px; display:flex; flex-direction:column; gap:14px; }
|
.login-form {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
/* 输入容器 */
|
/* 输入容器 */
|
||||||
.input-container {
|
.input-container {
|
||||||
position:relative;
|
position: relative;
|
||||||
display:flex;
|
display: flex;
|
||||||
align-items:center;
|
align-items: center;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border-radius:10px;
|
border-radius: 10px;
|
||||||
padding:12px 12px 12px 44px;
|
padding: 12px 12px 12px 44px;
|
||||||
border:1px solid rgba(15,23,42,0.06);
|
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||||
box-shadow: 0 6px 18px rgba(15,23,42,0.02);
|
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.02);
|
||||||
transition: all .22s ease;
|
transition: all 0.22s ease;
|
||||||
}
|
}
|
||||||
.input-container .icon {
|
.input-container .icon {
|
||||||
position:absolute;
|
position: absolute;
|
||||||
left:12px;
|
left: 12px;
|
||||||
width:18px; height:18px;
|
width: 18px;
|
||||||
color:#94a3b8;
|
height: 18px;
|
||||||
opacity:0.95;
|
color: #94a3b8;
|
||||||
|
opacity: 0.95;
|
||||||
}
|
}
|
||||||
.input-container input {
|
.input-container input {
|
||||||
width:100%;
|
width: 100%;
|
||||||
border:none;
|
border: none;
|
||||||
outline:none;
|
outline: none;
|
||||||
font-size:14px;
|
font-size: 14px;
|
||||||
color:#0f172a;
|
color: #0f172a;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
padding:0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 聚焦态 */
|
/* 聚焦态 */
|
||||||
.input-container.focused {
|
.input-container.focused {
|
||||||
border-color: rgba(99,102,241,0.9);
|
border-color: rgba(99, 102, 241, 0.9);
|
||||||
box-shadow: 0 8px 20px rgba(99,102,241,0.08);
|
box-shadow: 0 8px 20px rgba(99, 102, 241, 0.08);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
.input-container.focused .icon { color: rgba(99,102,241,0.95); }
|
.input-container.focused .icon {
|
||||||
|
color: rgba(99, 102, 241, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
/* 行内布局 */
|
/* 行内布局 */
|
||||||
.row-between { display:flex; justify-content:space-between; align-items:center; font-size:13px; margin-top:2px; }
|
.row-between {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
/* 记住我 checkbox */
|
/* 记住我 checkbox */
|
||||||
.remember { display:flex; align-items:center; gap:8px; color:#475569; cursor:pointer; user-select:none; }
|
.remember {
|
||||||
.remember input { position:absolute; opacity:0; pointer-events:none; }
|
display: flex;
|
||||||
.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 .14s ease; }
|
align-items: center;
|
||||||
.remember input:checked + .box { background: linear-gradient(90deg,#6366f1,#3b82f6); border-color:transparent; box-shadow:none; }
|
gap: 8px;
|
||||||
.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); }
|
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 {
|
||||||
.link:hover { text-decoration:underline; color:#2563eb; }
|
color: #3b82f6;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
/* 主操作按钮 */
|
/* 主操作按钮 */
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
width:100%;
|
width: 100%;
|
||||||
padding:12px 14px;
|
padding: 12px 14px;
|
||||||
border-radius:10px;
|
border-radius: 10px;
|
||||||
border:none;
|
border: none;
|
||||||
background: linear-gradient(90deg,#6366f1,#3b82f6);
|
background: linear-gradient(90deg, #6366f1, #3b82f6);
|
||||||
color:white;
|
color: white;
|
||||||
font-weight:700;
|
font-weight: 700;
|
||||||
font-size:15px;
|
font-size: 15px;
|
||||||
cursor:pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 8px 20px rgba(59,130,246,0.18);
|
box-shadow: 0 8px 20px rgba(59, 130, 246, 0.18);
|
||||||
transition: transform .12s ease, box-shadow .12s ease, opacity .12s;
|
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;
|
||||||
}
|
}
|
||||||
.btn-primary:active { transform: translateY(0); }
|
|
||||||
.btn-primary:disabled { opacity:0.6; cursor:default; box-shadow:none; }
|
|
||||||
|
|
||||||
/* 错误提示 */
|
/* 错误提示 */
|
||||||
.error {
|
.error {
|
||||||
color:#b91c1c;
|
color: #b91c1c;
|
||||||
background: #fff5f5;
|
background: #fff5f5;
|
||||||
border-left:4px solid #f87171;
|
border-left: 4px solid #f87171;
|
||||||
padding:10px 12px;
|
padding: 10px 12px;
|
||||||
border-radius:8px;
|
border-radius: 8px;
|
||||||
font-weight:600;
|
font-weight: 600;
|
||||||
font-size:13px;
|
font-size: 13px;
|
||||||
margin-top:6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 页脚注册 */
|
/* 页脚注册 */
|
||||||
.login-footer { display:flex; gap:8px; align-items:center; justify-content:flex-end; font-size:13px; color:#475569; }
|
.login-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
/* 响应式 */
|
/* 响应式 */
|
||||||
@media (max-width: 820px) {
|
@media (max-width: 820px) {
|
||||||
.login-card { flex-direction:column; height:auto; width:100%; }
|
.login-card {
|
||||||
.login-side { width:100%; min-height:140px; order:1; }
|
flex-direction: column;
|
||||||
.login-body { width:100%; order:2; padding:22px; }
|
height: auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.login-side {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 140px;
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
.login-body {
|
||||||
|
width: 100%;
|
||||||
|
order: 2;
|
||||||
|
padding: 22px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user