add(refreshTokenService):完善刷新令牌服务
This commit is contained in:
commit
e934bedd97
@ -10,6 +10,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.21" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.21">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
@ -19,7 +20,9 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<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="StackExchange.Redis" Version="2.9.32" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
|
||||
</ItemGroup>
|
||||
|
||||
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.Models;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using StackExchange.Redis;
|
||||
using System.Text;
|
||||
|
||||
namespace IM_API
|
||||
{
|
||||
@ -16,14 +20,74 @@ namespace IM_API
|
||||
.SetBasePath(Directory.GetCurrentDirectory())
|
||||
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
|
||||
.Build();
|
||||
string conStr = builder.Configuration.GetConnectionString("DefaultConnection")!;
|
||||
|
||||
string conStr = configuration.GetConnectionString("DefaultConnection")!;
|
||||
string redisConStr = configuration.GetConnectionString("Redis");
|
||||
//注入数据库上下文
|
||||
builder.Services.AddDbContext<ImContext>(options =>
|
||||
{
|
||||
options.UseMySql(conStr,ServerVersion.AutoDetect(conStr));
|
||||
});
|
||||
//注入redis
|
||||
var redis = ConnectionMultiplexer.Connect(redisConStr);
|
||||
builder.Services.AddSingleton<IConnectionMultiplexer>(redis);
|
||||
|
||||
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();
|
||||
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
@ -41,6 +105,7 @@ namespace IM_API
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
app.UseAuthorization();
|
||||
app.UseAuthentication();
|
||||
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
namespace IM_API.Services
|
||||
using IM_API.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace IM_API.Services
|
||||
{
|
||||
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": "*",
|
||||
"Jwt": {
|
||||
"Key": "change_this_super_secret_key_in_prod",
|
||||
"Issuer": "IMDemo",
|
||||
"Audience": "IMClients",
|
||||
"AccessTokenMinutes": 15,
|
||||
"RefreshTokenDays": 30
|
||||
},
|
||||
"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>
|
||||
<div id="app">
|
||||
<WindowLayout>
|
||||
|
||||
<RouterView></RouterView>
|
||||
</WindowLayout>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@ -23,12 +23,25 @@
|
||||
</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" />
|
||||
<rect
|
||||
x="2"
|
||||
y="2"
|
||||
width="8"
|
||||
height="8"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-width="1.2"
|
||||
/>
|
||||
</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" />
|
||||
<path
|
||||
d="M2 2l8 8M10 2L2 10"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@ -42,9 +55,15 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
function minimize() { console.log('最小化') }
|
||||
function maximize() { console.log('最大化') }
|
||||
function close() { console.log('关闭') }
|
||||
function minimize() {
|
||||
console.log('最小化')
|
||||
}
|
||||
function maximize() {
|
||||
console.log('最大化')
|
||||
}
|
||||
function close() {
|
||||
console.log('关闭')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@ -29,26 +29,21 @@
|
||||
<div class="content-header">
|
||||
<h2>{{ getCurrentTabTitle() }}</h2>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="search-box">
|
||||
<i class="fas fa-search search-icon"></i>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索..."
|
||||
v-model="searchText"
|
||||
@input="handleSearch"
|
||||
>
|
||||
<input type="text" placeholder="搜索..." v-model="searchText" @input="handleSearch" />
|
||||
</div>
|
||||
|
||||
|
||||
<div class="tab-content">
|
||||
<!-- 消息 -->
|
||||
<div v-if="currentTab === 'chat'" class="chat-list">
|
||||
<ul>
|
||||
<li
|
||||
v-for="chat in filteredChats"
|
||||
:key="chat.id"
|
||||
@click="selectChat(chat)"
|
||||
:class="{active: currentChat?.id === chat.id}"
|
||||
<li
|
||||
v-for="chat in filteredChats"
|
||||
:key="chat.id"
|
||||
@click="selectChat(chat)"
|
||||
:class="{ active: currentChat?.id === chat.id }"
|
||||
>
|
||||
<div class="chat-avatar">{{ getInitials(chat.name) }}</div>
|
||||
<div class="info">
|
||||
@ -125,12 +120,14 @@
|
||||
</header>
|
||||
|
||||
<div class="chat-body" ref="chatBody">
|
||||
<div
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
<div
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
: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="bubble">{{ msg.text }}</div>
|
||||
<div class="msg-time">{{ msg.time }}</div>
|
||||
@ -140,10 +137,10 @@
|
||||
|
||||
<footer class="chat-input">
|
||||
<div class="input-area">
|
||||
<textarea
|
||||
v-model="input"
|
||||
placeholder="输入消息..."
|
||||
@keydown.enter.prevent="send"
|
||||
<textarea
|
||||
v-model="input"
|
||||
placeholder="输入消息..."
|
||||
@keydown.enter.prevent="send"
|
||||
rows="1"
|
||||
ref="messageInput"
|
||||
></textarea>
|
||||
@ -153,10 +150,13 @@
|
||||
</div>
|
||||
</footer>
|
||||
</aside>
|
||||
|
||||
|
||||
<!-- 空聊天状态 -->
|
||||
<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>
|
||||
<h3>选择一个对话开始聊天</h3>
|
||||
<p>在左侧列表中选择联系人开始对话</p>
|
||||
@ -172,18 +172,32 @@ import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
const menus = ref([
|
||||
{ key: 'chat', label: '消息', icon: 'fas fa-comment-dots', notification: 3 },
|
||||
{ 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 searchText = 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: 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: 5, name: '钱七', lastMsg: '会议改期了', lastTime: '3月15日', unread: 0, status: 'online' }
|
||||
{ id: 5, name: '钱七', lastMsg: '会议改期了', lastTime: '3月15日', unread: 0, status: 'online' },
|
||||
])
|
||||
|
||||
const friends = ref([
|
||||
@ -193,7 +207,7 @@ const friends = ref([
|
||||
{ id: 4, name: '赵六', status: 'online' },
|
||||
{ id: 5, name: '钱七', status: 'online' },
|
||||
{ id: 6, name: '孙八', status: 'online' },
|
||||
{ id: 7, name: '周九', status: 'offline' }
|
||||
{ id: 7, name: '周九', status: 'offline' },
|
||||
])
|
||||
|
||||
const currentChat = ref(null)
|
||||
@ -205,22 +219,23 @@ const messageInput = ref(null)
|
||||
// 计算属性
|
||||
const filteredChats = computed(() => {
|
||||
if (!searchText.value) return chats.value
|
||||
return chats.value.filter(chat =>
|
||||
chat.name.toLowerCase().includes(searchText.value.toLowerCase()) ||
|
||||
chat.lastMsg.toLowerCase().includes(searchText.value.toLowerCase())
|
||||
return chats.value.filter(
|
||||
(chat) =>
|
||||
chat.name.toLowerCase().includes(searchText.value.toLowerCase()) ||
|
||||
chat.lastMsg.toLowerCase().includes(searchText.value.toLowerCase()),
|
||||
)
|
||||
})
|
||||
|
||||
const filteredFriends = computed(() => {
|
||||
if (!searchText.value) return friends.value
|
||||
return friends.value.filter(friend =>
|
||||
friend.name.toLowerCase().includes(searchText.value.toLowerCase())
|
||||
return friends.value.filter((friend) =>
|
||||
friend.name.toLowerCase().includes(searchText.value.toLowerCase()),
|
||||
)
|
||||
})
|
||||
|
||||
// 方法
|
||||
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 : '消息'
|
||||
}
|
||||
|
||||
@ -236,25 +251,23 @@ function formatTime(date) {
|
||||
// 模拟回复
|
||||
function simulateReply() {
|
||||
if (!currentChat.value) return
|
||||
|
||||
const replies = [
|
||||
'好的,我明白了',
|
||||
'听起来不错',
|
||||
'让我考虑一下',
|
||||
'没问题,就这么办'
|
||||
]
|
||||
|
||||
|
||||
const replies = ['好的,我明白了', '听起来不错', '让我考虑一下', '没问题,就这么办']
|
||||
|
||||
const randomReply = replies[Math.floor(Math.random() * replies.length)]
|
||||
|
||||
setTimeout(() => {
|
||||
messages.value.push({
|
||||
id: Date.now(),
|
||||
from: 'them',
|
||||
text: randomReply,
|
||||
time: formatTime(new Date())
|
||||
})
|
||||
nextTick(scrollBottom)
|
||||
}, 1000 + Math.random() * 2000)
|
||||
|
||||
setTimeout(
|
||||
() => {
|
||||
messages.value.push({
|
||||
id: Date.now(),
|
||||
from: 'them',
|
||||
text: randomReply,
|
||||
time: formatTime(new Date()),
|
||||
})
|
||||
nextTick(scrollBottom)
|
||||
},
|
||||
1000 + Math.random() * 2000,
|
||||
)
|
||||
}
|
||||
|
||||
function selectChat(chat) {
|
||||
@ -263,13 +276,13 @@ function selectChat(chat) {
|
||||
{ id: 1, from: 'them', text: `你好,我是 ${chat.name},很高兴认识你!`, time: '09:15' },
|
||||
{ id: 2, from: 'me', text: '你好呀,也很高兴认识你~', time: '09:16' },
|
||||
{ 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(() => {
|
||||
scrollBottom()
|
||||
focusInput()
|
||||
})
|
||||
|
||||
|
||||
// 清除未读消息
|
||||
if (chat.unread) {
|
||||
chat.unread = 0
|
||||
@ -279,16 +292,16 @@ function selectChat(chat) {
|
||||
|
||||
function send() {
|
||||
if (!input.value.trim()) return
|
||||
|
||||
|
||||
messages.value.push({
|
||||
id: Date.now(),
|
||||
from: 'me',
|
||||
text: input.value,
|
||||
time: formatTime(new Date())
|
||||
time: formatTime(new Date()),
|
||||
})
|
||||
input.value = ''
|
||||
nextTick(scrollBottom)
|
||||
|
||||
|
||||
// 模拟回复
|
||||
simulateReply()
|
||||
}
|
||||
@ -320,7 +333,7 @@ onMounted(() => {
|
||||
if (chats.value.length > 0) {
|
||||
selectChat(chats.value[0])
|
||||
}
|
||||
|
||||
|
||||
// 监听窗口大小变化
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
@ -483,13 +496,15 @@ onUnmounted(() => {
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
|
||||
.chat-list ul, .friend-list ul {
|
||||
.chat-list ul,
|
||||
.friend-list ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chat-list li, .friend-list li {
|
||||
.chat-list li,
|
||||
.friend-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
@ -500,7 +515,8 @@ onUnmounted(() => {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.chat-list li:hover, .friend-list li:hover {
|
||||
.chat-list li:hover,
|
||||
.friend-list li:hover {
|
||||
background: #f7fafc;
|
||||
}
|
||||
|
||||
@ -529,7 +545,9 @@ onUnmounted(() => {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.name-row, .last-row, .status-row {
|
||||
.name-row,
|
||||
.last-row,
|
||||
.status-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
@ -809,7 +827,7 @@ onUnmounted(() => {
|
||||
.im-container {
|
||||
min-width: 700px;
|
||||
}
|
||||
|
||||
|
||||
.chat-area {
|
||||
width: 350px;
|
||||
min-width: 300px;
|
||||
@ -820,10 +838,10 @@ onUnmounted(() => {
|
||||
.im-container {
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
|
||||
.chat-area {
|
||||
width: 300px;
|
||||
min-width: 250px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@ -6,10 +6,22 @@
|
||||
<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">
|
||||
<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"/>
|
||||
<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>
|
||||
@ -34,19 +46,55 @@
|
||||
|
||||
<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 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 />
|
||||
<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"/>
|
||||
<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 />
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
v-model="password"
|
||||
@focus="passwordFocused = true"
|
||||
@blur="passwordFocused = false"
|
||||
placeholder="密码"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="row-between">
|
||||
@ -126,9 +174,9 @@ const handleLogin = () => {
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
/* 左侧品牌区域 */
|
||||
@ -136,129 +184,271 @@ const handleLogin = () => {
|
||||
width: 44%;
|
||||
min-width: 280px;
|
||||
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;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.brand { display:flex; 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; }
|
||||
.brand {
|
||||
display: flex;
|
||||
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; }
|
||||
.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; }
|
||||
.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;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
justify-content:space-between;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.login-header { text-align:left; 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-header {
|
||||
text-align: left;
|
||||
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 {
|
||||
position:relative;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
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 .22s ease;
|
||||
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 {
|
||||
position:absolute;
|
||||
left:12px;
|
||||
width:18px; height:18px;
|
||||
color:#94a3b8;
|
||||
opacity:0.95;
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: #94a3b8;
|
||||
opacity: 0.95;
|
||||
}
|
||||
.input-container input {
|
||||
width:100%;
|
||||
border:none;
|
||||
outline:none;
|
||||
font-size:14px;
|
||||
color:#0f172a;
|
||||
width: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
color: #0f172a;
|
||||
background: transparent;
|
||||
padding:0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 聚焦态 */
|
||||
.input-container.focused {
|
||||
border-color: rgba(99,102,241,0.9);
|
||||
box-shadow: 0 8px 20px rgba(99,102,241,0.08);
|
||||
border-color: rgba(99, 102, 241, 0.9);
|
||||
box-shadow: 0 8px 20px rgba(99, 102, 241, 0.08);
|
||||
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 */
|
||||
.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 .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); }
|
||||
.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; }
|
||||
.link {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
/* 主操作按钮 */
|
||||
.btn-primary {
|
||||
width:100%;
|
||||
padding:12px 14px;
|
||||
border-radius:10px;
|
||||
border:none;
|
||||
background: linear-gradient(90deg,#6366f1,#3b82f6);
|
||||
color:white;
|
||||
font-weight:700;
|
||||
font-size:15px;
|
||||
cursor:pointer;
|
||||
box-shadow: 0 8px 20px rgba(59,130,246,0.18);
|
||||
transition: transform .12s ease, box-shadow .12s ease, opacity .12s;
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
background: linear-gradient(90deg, #6366f1, #3b82f6);
|
||||
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;
|
||||
}
|
||||
.btn-primary:active { transform: translateY(0); }
|
||||
.btn-primary:disabled { opacity:0.6; cursor:default; box-shadow:none; }
|
||||
|
||||
/* 错误提示 */
|
||||
.error {
|
||||
color:#b91c1c;
|
||||
color: #b91c1c;
|
||||
background: #fff5f5;
|
||||
border-left:4px solid #f87171;
|
||||
padding:10px 12px;
|
||||
border-radius:8px;
|
||||
font-weight:600;
|
||||
font-size:13px;
|
||||
margin-top:6px;
|
||||
border-left: 4px solid #f87171;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
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) {
|
||||
.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; }
|
||||
.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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user