add(IFriendService):完善好友关系接口
This commit is contained in:
parent
76619c7785
commit
e9feb6e036
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
|
||||
}
|
||||
}
|
||||
@ -28,5 +28,37 @@ namespace IM_API.Interface.Services
|
||||
/// <param name="limit"></param>
|
||||
/// <returns></returns>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vite App</title>
|
||||
</head>
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import WindowLayout from './components/Layout.vue'
|
||||
import WindowLayout from './components/Window.vue'
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@ -1,262 +0,0 @@
|
||||
<template>
|
||||
<div class="window-container">
|
||||
<div class="window">
|
||||
<!-- Windows 风格标题栏 -->
|
||||
<div class="window-header">
|
||||
<div class="window-title-area">
|
||||
<div class="window-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.5 1.5H1.5V6.5H6.5V1.5Z" fill="currentColor"/>
|
||||
<path d="M14.5 1.5H9.5V6.5H14.5V1.5Z" fill="currentColor"/>
|
||||
<path d="M6.5 9.5H1.5V14.5H6.5V9.5Z" fill="currentColor"/>
|
||||
<path d="M14.5 9.5H9.5V14.5H14.5V9.5Z" fill="currentColor"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="window-title">我的窗口</div>
|
||||
</div>
|
||||
<div class="window-controls">
|
||||
<button class="control-btn minimize" @click="minimize" title="最小化">
|
||||
<span class="control-icon"></span>
|
||||
</button>
|
||||
<button class="control-btn maximize" @click="maximize" title="最大化">
|
||||
<span class="control-icon"></span>
|
||||
</button>
|
||||
<button class="control-btn close" @click="close" title="关闭">
|
||||
<span class="control-icon"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 窗口内容 -->
|
||||
<div class="window-content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
function minimize() {
|
||||
console.log('最小化')
|
||||
}
|
||||
|
||||
function maximize() {
|
||||
console.log('最大化/还原')
|
||||
}
|
||||
|
||||
function close() {
|
||||
console.log('关闭窗口')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 外层容器,居中 - 保持不变 */
|
||||
.window-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background-color: #f0f0f0;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 窗口主体 - 保持不变 */
|
||||
.window {
|
||||
width: 800px;
|
||||
height: 550px;
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 窗口顶部栏 - 优化按钮样式 */
|
||||
.window-header {
|
||||
height: 42px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
background: linear-gradient(135deg, #1a73e8 0%, #4285f4 100%);
|
||||
color: #fff;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-weight: 600;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 添加顶栏的微光效果 */
|
||||
.window-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.4) 20%,
|
||||
rgba(255, 255, 255, 0.7) 50%,
|
||||
rgba(255, 255, 255, 0.4) 80%,
|
||||
transparent 100%);
|
||||
}
|
||||
|
||||
/* 标题区域 */
|
||||
.window-title-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 窗口图标 */
|
||||
.window-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
/* 窗口标题 */
|
||||
.window-title {
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.3px;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 标题栏按钮容器 - 增加间距 */
|
||||
.window-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 控制按钮通用样式 - 优化尺寸和交互 */
|
||||
.control-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.control-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0);
|
||||
border-radius: 6px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.control-btn:hover::before {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.control-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* 控制按钮图标 - 使用CSS绘制精致图标 */
|
||||
.control-icon {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 最小化按钮图标 */
|
||||
.minimize .control-icon {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.minimize .control-icon::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 2px;
|
||||
background: currentColor;
|
||||
border-radius: 1px;
|
||||
bottom: 2px;
|
||||
}
|
||||
|
||||
/* 最大化按钮图标 */
|
||||
.maximize .control-icon {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.maximize .control-icon::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border: 1.5px solid currentColor;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* 关闭按钮图标 */
|
||||
.close .control-icon {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.close .control-icon::before,
|
||||
.close .control-icon::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 1.5px;
|
||||
background: currentColor;
|
||||
border-radius: 1px;
|
||||
top: 4px;
|
||||
left: -1px;
|
||||
}
|
||||
|
||||
.close .control-icon::before {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.close .control-icon::after {
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
/* 关闭按钮特殊样式 */
|
||||
.close:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.close:hover::before {
|
||||
background: rgba(232, 17, 35, 0.9);
|
||||
}
|
||||
|
||||
/* 窗口内容 - 保持不变 */
|
||||
.window-content {
|
||||
flex: 1;
|
||||
overflow-y: hidden;
|
||||
background-color: #fdfdfd;
|
||||
}
|
||||
</style>
|
||||
136
frontend/web/src/components/Window.vue
Normal file
136
frontend/web/src/components/Window.vue
Normal file
@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<div class="window-container">
|
||||
<div class="window">
|
||||
<div class="window-header">
|
||||
<div class="window-left">
|
||||
<div class="window-icon">
|
||||
<!-- 应用图标 -->
|
||||
<svg viewBox="0 0 24 24" width="18" height="18">
|
||||
<rect x="3" y="3" width="8" height="8" rx="2" fill="currentColor" />
|
||||
<rect x="13" y="3" width="8" height="8" rx="2" fill="currentColor" opacity="0.8" />
|
||||
<rect x="3" y="13" width="8" height="8" rx="2" fill="currentColor" opacity="0.6" />
|
||||
<rect x="13" y="13" width="8" height="8" rx="2" fill="currentColor" opacity="0.4" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="window-title">即时通讯</span>
|
||||
</div>
|
||||
|
||||
<div class="window-controls">
|
||||
<button class="control-btn minimize" @click="minimize" title="最小化">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12">
|
||||
<rect x="2" y="5.5" width="8" height="1" fill="currentColor" />
|
||||
</svg>
|
||||
</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" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="control-btn close" @click="close" title="关闭">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12">
|
||||
<path d="M2 2l8 8M10 2L2 10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="window-content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
function minimize() { console.log('最小化') }
|
||||
function maximize() { console.log('最大化') }
|
||||
function close() { console.log('关闭') }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.window-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(145deg, #eef2ff, #f9fafb);
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.window {
|
||||
width: clamp(640px, 85vw, 1100px);
|
||||
height: clamp(520px, 80vh, 900px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.window-header {
|
||||
height: 46px;
|
||||
background: linear-gradient(90deg, #4f46e5, #3b82f6);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 12px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.window-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.window-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.window-title {
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.window-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.control-btn.close:hover {
|
||||
background: rgba(239, 68, 68, 0.85);
|
||||
}
|
||||
|
||||
.window-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px;
|
||||
min-height: 0;
|
||||
background: #f9fafb;
|
||||
}
|
||||
</style>
|
||||
@ -1,6 +1,11 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import MainView from '@/views/Main.vue'
|
||||
|
||||
const routes = [{ path: '/auth/login', component: () => import('@/views/auth/Login.vue') }]
|
||||
const routes = [
|
||||
{ path: '/auth/login', component: () => import('@/views/auth/Login.vue') },
|
||||
{ path: '/', component: MainView },
|
||||
{ path: '/index', component: MainView },
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
|
||||
829
frontend/web/src/views/Main.vue
Normal file
829
frontend/web/src/views/Main.vue
Normal file
@ -0,0 +1,829 @@
|
||||
<template>
|
||||
<div class="im-container">
|
||||
<!-- 左侧导航栏 -->
|
||||
<nav class="sidebar">
|
||||
<div class="avatar">我</div>
|
||||
|
||||
<div class="menu">
|
||||
<button
|
||||
v-for="item in menus"
|
||||
:key="item.key"
|
||||
@click="currentTab = item.key"
|
||||
:class="['menu-btn', { active: currentTab === item.key }]"
|
||||
:title="item.label"
|
||||
>
|
||||
<i :class="item.icon" class="menu-icon"></i>
|
||||
<span v-if="item.notification" class="notification-badge">{{ item.notification }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bottom-menu">
|
||||
<button class="menu-btn" title="设置" @click="currentTab = 'settings'">
|
||||
<i class="fas fa-cog menu-icon"></i>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 中间内容区 -->
|
||||
<main class="content">
|
||||
<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"
|
||||
>
|
||||
</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}"
|
||||
>
|
||||
<div class="chat-avatar">{{ getInitials(chat.name) }}</div>
|
||||
<div class="info">
|
||||
<div class="name-row">
|
||||
<span class="name">{{ chat.name }}</span>
|
||||
<span class="time">{{ chat.lastTime }}</span>
|
||||
</div>
|
||||
<div class="last-row">
|
||||
<span class="last">{{ chat.lastMsg }}</span>
|
||||
<span v-if="chat.unread" class="unread-count">{{ chat.unread }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 联系人 -->
|
||||
<div v-else-if="currentTab === 'friends'" class="friend-list">
|
||||
<ul>
|
||||
<li v-for="f in filteredFriends" :key="f.id">
|
||||
<div class="chat-avatar">{{ getInitials(f.name) }}</div>
|
||||
<div class="info">
|
||||
<div class="name-row">
|
||||
<span class="name">{{ f.name }}</span>
|
||||
</div>
|
||||
<div class="status-row">
|
||||
<span :class="['status', f.status === 'offline' ? 'offline' : '']">
|
||||
{{ f.status === 'online' ? '在线' : '离线' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 群聊 -->
|
||||
<div v-else-if="currentTab === 'groups'" class="group-list">
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-users icon"></i>
|
||||
<h3>暂无群聊</h3>
|
||||
<p>创建或加入群组开始群聊</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设置 -->
|
||||
<div v-else class="settings">
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-cog icon"></i>
|
||||
<h3>设置中心</h3>
|
||||
<p>可以在这里修改昵称、主题、通知开关等</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 右侧聊天区域 -->
|
||||
<aside class="chat-area" v-if="currentChat">
|
||||
<header class="chat-header">
|
||||
<div class="chat-user">
|
||||
<div class="chat-user-avatar">{{ getInitials(currentChat.name) }}</div>
|
||||
<div class="chat-user-info">
|
||||
<div class="name">{{ currentChat.name }}</div>
|
||||
<div class="status">{{ currentChat.status === 'online' ? '在线' : '离线' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-actions">
|
||||
<button title="视频通话">
|
||||
<i class="fas fa-video"></i>
|
||||
</button>
|
||||
<button title="语音通话">
|
||||
<i class="fas fa-phone-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="chat-body" ref="chatBody">
|
||||
<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-content">
|
||||
<div class="bubble">{{ msg.text }}</div>
|
||||
<div class="msg-time">{{ msg.time }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="chat-input">
|
||||
<div class="input-area">
|
||||
<textarea
|
||||
v-model="input"
|
||||
placeholder="输入消息..."
|
||||
@keydown.enter.prevent="send"
|
||||
rows="1"
|
||||
ref="messageInput"
|
||||
></textarea>
|
||||
<button class="send-btn" @click="send">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</aside>
|
||||
|
||||
<!-- 空聊天状态 -->
|
||||
<aside class="chat-area" v-else>
|
||||
<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>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
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' }
|
||||
])
|
||||
|
||||
const currentTab = ref('chat')
|
||||
const searchText = ref('')
|
||||
|
||||
const chats = ref([
|
||||
{ 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: 4, name: '赵六', lastMsg: '周末有空吗?', lastTime: '周一', unread: 0, status: 'online' },
|
||||
{ id: 5, name: '钱七', lastMsg: '会议改期了', lastTime: '3月15日', unread: 0, status: 'online' }
|
||||
])
|
||||
|
||||
const friends = ref([
|
||||
{ id: 1, name: '张三', status: 'online' },
|
||||
{ id: 2, name: '李四', status: 'online' },
|
||||
{ id: 3, name: '王五', status: 'offline' },
|
||||
{ id: 4, name: '赵六', status: 'online' },
|
||||
{ id: 5, name: '钱七', status: 'online' },
|
||||
{ id: 6, name: '孙八', status: 'online' },
|
||||
{ id: 7, name: '周九', status: 'offline' }
|
||||
])
|
||||
|
||||
const currentChat = ref(null)
|
||||
const messages = ref([])
|
||||
const input = ref('')
|
||||
const chatBody = ref(null)
|
||||
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())
|
||||
)
|
||||
})
|
||||
|
||||
const filteredFriends = computed(() => {
|
||||
if (!searchText.value) return friends.value
|
||||
return friends.value.filter(friend =>
|
||||
friend.name.toLowerCase().includes(searchText.value.toLowerCase())
|
||||
)
|
||||
})
|
||||
|
||||
// 方法
|
||||
function getCurrentTabTitle() {
|
||||
const menu = menus.value.find(m => m.key === currentTab.value)
|
||||
return menu ? menu.label : '消息'
|
||||
}
|
||||
|
||||
function getInitials(name) {
|
||||
return name.substring(0, 1)
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
function formatTime(date) {
|
||||
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
// 模拟回复
|
||||
function simulateReply() {
|
||||
if (!currentChat.value) return
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
function selectChat(chat) {
|
||||
currentChat.value = chat
|
||||
messages.value = [
|
||||
{ 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' }
|
||||
]
|
||||
nextTick(() => {
|
||||
scrollBottom()
|
||||
focusInput()
|
||||
})
|
||||
|
||||
// 清除未读消息
|
||||
if (chat.unread) {
|
||||
chat.unread = 0
|
||||
menus.value[0].notification = Math.max(0, menus.value[0].notification - 1)
|
||||
}
|
||||
}
|
||||
|
||||
function send() {
|
||||
if (!input.value.trim()) return
|
||||
|
||||
messages.value.push({
|
||||
id: Date.now(),
|
||||
from: 'me',
|
||||
text: input.value,
|
||||
time: formatTime(new Date())
|
||||
})
|
||||
input.value = ''
|
||||
nextTick(scrollBottom)
|
||||
|
||||
// 模拟回复
|
||||
simulateReply()
|
||||
}
|
||||
|
||||
function scrollBottom() {
|
||||
if (chatBody.value) {
|
||||
chatBody.value.scrollTop = chatBody.value.scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
function focusInput() {
|
||||
if (messageInput.value) {
|
||||
messageInput.value.focus()
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
// 搜索逻辑已经在计算属性中处理
|
||||
}
|
||||
|
||||
// 窗口大小变化处理
|
||||
function handleResize() {
|
||||
// 这里可以添加响应式布局的调整逻辑
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
// 默认选择第一个聊天
|
||||
if (chats.value.length > 0) {
|
||||
selectChat(chats.value[0])
|
||||
}
|
||||
|
||||
// 监听窗口大小变化
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.im-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
min-width: 800px; /* 最小宽度防止过度压缩 */
|
||||
}
|
||||
|
||||
/* 左侧导航栏 */
|
||||
.sidebar {
|
||||
width: 80px;
|
||||
min-width: 80px;
|
||||
background: #2d3748;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 24px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: #4a5568;
|
||||
margin-bottom: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.bottom-menu {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.menu-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #a0aec0;
|
||||
cursor: pointer;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.menu-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.menu-btn.active {
|
||||
background: #4a5568;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.notification-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: #e53e3e;
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 10px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 中间内容区 */
|
||||
.content {
|
||||
flex: 1;
|
||||
background: white;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 300px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content-header {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.content-header h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #2d3748;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
margin: 16px 20px;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
width: 100%;
|
||||
padding: 12px 16px 12px 40px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
background: #f7fafc;
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.search-box input:focus {
|
||||
border-color: #4299e1;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
|
||||
.chat-list ul, .friend-list ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chat-list li, .friend-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.chat-list li:hover, .friend-list li:hover {
|
||||
background: #f7fafc;
|
||||
}
|
||||
|
||||
.chat-list li.active {
|
||||
background: #ebf8ff;
|
||||
}
|
||||
|
||||
.chat-avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: #e2e8f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #4a5568;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.name-row, .last-row, .status-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 600;
|
||||
color: #2d3748;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 12px;
|
||||
color: #a0aec0;
|
||||
font-weight: normal;
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.last {
|
||||
font-size: 14px;
|
||||
color: #718096;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.unread-count {
|
||||
background: #e53e3e;
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
padding: 2px 6px;
|
||||
font-size: 11px;
|
||||
margin-left: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 12px;
|
||||
color: #38a169;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status.offline {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
/* 聊天区 */
|
||||
.chat-area {
|
||||
width: 400px;
|
||||
min-width: 350px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-user {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-user-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: #e2e8f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #4a5568;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-user-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-user-info .name {
|
||||
font-size: 16px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.chat-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-actions button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #718096;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-actions button:hover {
|
||||
background: #f7fafc;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.chat-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
background: #f7fafc;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.msg {
|
||||
display: flex;
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
.them {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.me {
|
||||
align-self: flex-end;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.msg-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
align-self: flex-end;
|
||||
margin: 0 8px;
|
||||
background: #e2e8f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #4a5568;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.msg-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
padding: 12px 16px;
|
||||
border-radius: 16px;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.them .bubble {
|
||||
background: white;
|
||||
color: #2d3748;
|
||||
border-top-left-radius: 4px;
|
||||
}
|
||||
|
||||
.me .bubble {
|
||||
background: #4299e1;
|
||||
color: white;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
|
||||
.msg-time {
|
||||
font-size: 11px;
|
||||
color: #a0aec0;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.them .msg-time {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
display: flex;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
background: white;
|
||||
padding: 16px 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.input-area {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input-area textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
border-radius: 8px;
|
||||
background: #f7fafc;
|
||||
height: 44px;
|
||||
line-height: 1.4;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.input-area textarea:focus {
|
||||
background: white;
|
||||
box-shadow: 0 0 0 1px #4299e1;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
background: #4299e1;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.send-btn:hover {
|
||||
background: #3182ce;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.empty-state .icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 8px;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1024px) {
|
||||
.im-container {
|
||||
min-width: 700px;
|
||||
}
|
||||
|
||||
.chat-area {
|
||||
width: 350px;
|
||||
min-width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.im-container {
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
.chat-area {
|
||||
width: 300px;
|
||||
min-width: 250px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,58 +1,76 @@
|
||||
<template>
|
||||
<div class="login-content">
|
||||
<div class="login-header">
|
||||
<h2 class="login-title">欢迎登录</h2>
|
||||
<p class="login-subtitle">请输入您的账号信息</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleLogin" class="login-form">
|
||||
<div class="input-group">
|
||||
<div class="input-container">
|
||||
<input id="username" type="text" v-model="username" placeholder=" " required>
|
||||
<label for="username" class="floating-label">用户名</label>
|
||||
<div class="input-icon">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 21V19C20 16.7909 18.2091 15 16 15H8C5.79086 15 4 16.7909 4 19V21" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<circle cx="12" cy="7" r="4" stroke="currentColor" stroke-width="2"/>
|
||||
<div class="login-wrap" @keyup.enter="handleLogin">
|
||||
<div class="login-card" role="form" aria-label="登录表单">
|
||||
<!-- 左侧品牌/装饰 -->
|
||||
<div class="login-side">
|
||||
<div class="brand">
|
||||
<div class="brand-logo" aria-hidden="true">
|
||||
<!-- 圆形头像徽标 -->
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="2" width="20" height="20" rx="5" fill="currentColor" />
|
||||
<path d="M8 14c0-1.657 1.343-3 3-3s3 1.343 3 3" stroke="white" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="11" cy="9" r="1.3" fill="white"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="brand-name">即时通讯</div>
|
||||
</div>
|
||||
|
||||
<p class="side-desc">与朋友、同事保持联络 — 轻量、快速、私密。</p>
|
||||
|
||||
<div class="side-illu" aria-hidden="true">
|
||||
<!-- 简单装饰圆块,避免加载外部图片 -->
|
||||
<div class="bubble b1"></div>
|
||||
<div class="bubble b2"></div>
|
||||
<div class="bubble b3"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<div class="input-container">
|
||||
<input id="password" type="password" v-model="password" placeholder=" " required>
|
||||
<label for="password" class="floating-label">密码</label>
|
||||
<div class="input-icon">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M7 11V7C7 4.23858 9.23858 2 12 2C14.7614 2 17 4.23858 17 7V11" stroke="currentColor" stroke-width="2"/>
|
||||
|
||||
<!-- 右侧表单 -->
|
||||
<div class="login-body">
|
||||
<header class="login-header">
|
||||
<h2 class="login-title">欢迎回来</h2>
|
||||
<p class="login-subtitle">使用账号登录以继续</p>
|
||||
</header>
|
||||
|
||||
<form class="login-form" @submit.prevent="handleLogin" autocomplete="on" novalidate>
|
||||
<div class="input-container" :class="{ focused: usernameFocused || username }">
|
||||
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M20 21V19C20 16.7909 18.2091 15 16 15H8C5.79086 15 4 16.7909 4 19V21" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
|
||||
<circle cx="12" cy="7" r="4" fill="none" stroke="currentColor" stroke-width="1.6"/>
|
||||
</svg>
|
||||
<input id="username" type="text" v-model="username" @focus="usernameFocused = true" @blur="usernameFocused = false" placeholder="用户名 / 邮箱" required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-container" :class="{ focused: passwordFocused || password }">
|
||||
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" fill="none" stroke="currentColor" stroke-width="1.6"/>
|
||||
<path d="M7 11V7C7 4.23858 9.23858 2 12 2C14.7614 2 17 4.23858 17 7V11" fill="none" stroke="currentColor" stroke-width="1.6"/>
|
||||
</svg>
|
||||
<input id="password" type="password" v-model="password" @focus="passwordFocused = true" @blur="passwordFocused = false" placeholder="密码" required />
|
||||
</div>
|
||||
|
||||
<div class="row-between">
|
||||
<label class="remember">
|
||||
<input type="checkbox" v-model="remember" />
|
||||
<span class="box" aria-hidden="true"></span>
|
||||
记住我
|
||||
</label>
|
||||
<a class="link" href="#" @click.prevent>忘记密码?</a>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary" :disabled="loading" aria-disabled="loading">
|
||||
<span v-if="!loading">登录</span>
|
||||
<span v-else>登录中...</span>
|
||||
</button>
|
||||
|
||||
<p v-if="errorMsg" class="error">{{ errorMsg }}</p>
|
||||
</form>
|
||||
|
||||
<footer class="login-footer">
|
||||
<span>还没有账号?</span>
|
||||
<a class="link" href="#" @click.prevent>注册</a>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div class="options-row">
|
||||
<label class="remember-me">
|
||||
<input type="checkbox">
|
||||
<span class="checkmark"></span>
|
||||
记住我
|
||||
</label>
|
||||
<a href="#" class="forgot-link">忘记密码?</a>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="login-btn">
|
||||
<span>登录</span>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5 12H19M19 12L12 5M19 12L12 19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<p v-if="errorMsg" class="error">{{ errorMsg }}</p>
|
||||
</form>
|
||||
|
||||
<div class="login-footer">
|
||||
<p>还没有账号? <a href="#" class="register-link">立即注册</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -62,325 +80,185 @@ import { ref } from 'vue'
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const remember = ref(false)
|
||||
const errorMsg = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const usernameFocused = ref(false)
|
||||
const passwordFocused = ref(false)
|
||||
|
||||
const handleLogin = () => {
|
||||
if (!username.value || !password.value) {
|
||||
errorMsg.value = ''
|
||||
if (!username.value.trim() || !password.value) {
|
||||
errorMsg.value = '用户名或密码不能为空'
|
||||
return
|
||||
}
|
||||
|
||||
if (username.value === 'admin' && password.value === '123456') {
|
||||
alert('登录成功!')
|
||||
errorMsg.value = ''
|
||||
// TODO: 跳转到IM主界面
|
||||
} else {
|
||||
errorMsg.value = '用户名或密码错误'
|
||||
}
|
||||
loading.value = true
|
||||
// 模拟登录延迟(客户端示例),生产中替换为 API 调用
|
||||
setTimeout(() => {
|
||||
loading.value = false
|
||||
if (username.value === 'admin' && password.value === '123456') {
|
||||
errorMsg.value = ''
|
||||
alert('登录成功!')
|
||||
// TODO: 跳转到 IM 主界面
|
||||
} else {
|
||||
errorMsg.value = '用户名或密码错误'
|
||||
}
|
||||
}, 700)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-content {
|
||||
padding: 40px 30px;
|
||||
/* 容器与居中 */
|
||||
.login-wrap {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 登录卡片:左右两栏 */
|
||||
.login-card {
|
||||
width: 740px;
|
||||
max-width: calc(100% - 48px);
|
||||
height: 460px;
|
||||
display: flex;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(248,250,252,0.98));
|
||||
box-shadow: 0 18px 40px rgba(16,24,40,0.08);
|
||||
border: 1px solid rgba(15,23,42,0.04);
|
||||
}
|
||||
|
||||
/* 左侧品牌区域 */
|
||||
.login-side {
|
||||
width: 44%;
|
||||
min-width: 280px;
|
||||
padding: 28px 22px;
|
||||
background: linear-gradient(160deg, rgba(59,130,246,0.06), rgba(99,102,241,0.04));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
border-radius: 0 0 8px 8px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 添加背景装饰元素 */
|
||||
.login-content::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
right: -20%;
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.05) 0%, rgba(37, 99, 235, 0.1) 100%);
|
||||
z-index: 0;
|
||||
.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-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;
|
||||
}
|
||||
|
||||
.login-content::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -30%;
|
||||
left: -10%;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.05) 0%, rgba(37, 99, 235, 0.08) 100%);
|
||||
z-index: 0;
|
||||
}
|
||||
.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: center;
|
||||
margin-bottom: 32px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
max-width: 320px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 输入组 */
|
||||
.input-group {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
/* 表单 */
|
||||
.login-form { margin-top:10px; display:flex; flex-direction:column; gap:14px; }
|
||||
|
||||
/* 输入容器 */
|
||||
.input-container {
|
||||
position: relative;
|
||||
margin-top: 8px;
|
||||
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;
|
||||
}
|
||||
.input-container .icon {
|
||||
position:absolute;
|
||||
left:12px;
|
||||
width:18px; height:18px;
|
||||
color:#94a3b8;
|
||||
opacity:0.95;
|
||||
}
|
||||
|
||||
.input-container input {
|
||||
width: 100%;
|
||||
padding: 16px 16px 16px 48px;
|
||||
border: 1.5px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
outline: none;
|
||||
transition: all 0.3s ease;
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width:100%;
|
||||
border:none;
|
||||
outline:none;
|
||||
font-size:14px;
|
||||
color:#0f172a;
|
||||
background: transparent;
|
||||
padding:0;
|
||||
}
|
||||
|
||||
.input-container input:focus {
|
||||
border-color: #3b82f6;
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1), 0 2px 6px rgba(59, 130, 246, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.input-container input:focus + .floating-label,
|
||||
.input-container input:not(:placeholder-shown) + .floating-label {
|
||||
top: -8px;
|
||||
left: 48px;
|
||||
font-size: 12px;
|
||||
color: #3b82f6;
|
||||
background: linear-gradient(180deg, #f8fafc 50%, #ffffff 50%);
|
||||
padding: 0 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.floating-label {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 48px;
|
||||
font-size: 15px;
|
||||
color: #64748b;
|
||||
pointer-events: none;
|
||||
transition: all 0.3s ease;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #94a3b8;
|
||||
transition: color 0.3s ease;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.input-container input:focus ~ .input-icon {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
/* 选项行 */
|
||||
.options-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: 10px 0 5px;
|
||||
}
|
||||
|
||||
.remember-me {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.remember-me input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.checkmark {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid #cbd5e1;
|
||||
border-radius: 4px;
|
||||
margin-right: 8px;
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.remember-me:hover .checkmark {
|
||||
border-color: #94a3b8;
|
||||
}
|
||||
|
||||
.remember-me input:checked + .checkmark {
|
||||
background-color: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.remember-me input:checked + .checkmark::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
top: 2px;
|
||||
width: 4px;
|
||||
height: 8px;
|
||||
border: solid white;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.forgot-link {
|
||||
font-size: 14px;
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
transition: color 0.3s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.forgot-link:hover {
|
||||
color: #2563eb;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 按钮 */
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.25);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||
transition: left 0.5s;
|
||||
}
|
||||
|
||||
.login-btn:hover {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
box-shadow: 0 6px 12px rgba(59, 130, 246, 0.35);
|
||||
/* 聚焦态 */
|
||||
.input-container.focused {
|
||||
border-color: rgba(99,102,241,0.9);
|
||||
box-shadow: 0 8px 20px rgba(99,102,241,0.08);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.input-container.focused .icon { color: rgba(99,102,241,0.95); }
|
||||
|
||||
.login-btn:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
/* 行内布局 */
|
||||
.row-between { display:flex; justify-content:space-between; align-items:center; font-size:13px; margin-top:2px; }
|
||||
|
||||
.login-btn:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.4);
|
||||
/* 记住我 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); }
|
||||
|
||||
/* 链接样式 */
|
||||
.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;
|
||||
}
|
||||
.btn-primary:active { transform: translateY(0); }
|
||||
.btn-primary:disabled { opacity:0.6; cursor:default; box-shadow:none; }
|
||||
|
||||
/* 错误提示 */
|
||||
.error {
|
||||
color: #ef4444;
|
||||
margin-top: 16px;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
background-color: #fef2f2;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #ef4444;
|
||||
box-shadow: 0 2px 4px rgba(239, 68, 68, 0.1);
|
||||
font-weight: 500;
|
||||
color:#b91c1c;
|
||||
background: #fff5f5;
|
||||
border-left:4px solid #f87171;
|
||||
padding:10px 12px;
|
||||
border-radius:8px;
|
||||
font-weight:600;
|
||||
font-size:13px;
|
||||
margin-top:6px;
|
||||
}
|
||||
|
||||
/* 登录页脚 */
|
||||
.login-footer {
|
||||
margin-top: 24px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
/* 页脚注册 */
|
||||
.login-footer { display:flex; gap:8px; align-items:center; justify-content:flex-end; font-size:13px; color:#475569; }
|
||||
|
||||
.register-link {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
transition: color 0.3s ease;
|
||||
position: relative;
|
||||
/* 响应式 */
|
||||
@media (max-width: 820px) {
|
||||
.login-card { flex-direction:column; height:auto; width:100%; }
|
||||
.login-side { width:100%; min-height:140px; order:1; }
|
||||
.login-body { width:100%; order:2; padding:22px; }
|
||||
}
|
||||
|
||||
.register-link::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 2px;
|
||||
background: #3b82f6;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.register-link:hover {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.register-link:hover::after {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user