add(refreshTokenService):完善刷新令牌服务

This commit is contained in:
西街长安 2025-11-04 16:19:04 +08:00
commit e934bedd97
12 changed files with 634 additions and 160 deletions

View File

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

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

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

View File

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

View File

@ -1,6 +1,10 @@
namespace IM_API.Services
using IM_API.Models;
using Microsoft.EntityFrameworkCore;
namespace IM_API.Services
{
public class FriendService
{
}
}

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

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

View File

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

View File

@ -3,10 +3,8 @@
<template>
<div id="app">
<WindowLayout>
<RouterView></RouterView>
</WindowLayout>
</div>
</template>

View File

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

View File

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

View File

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