初始化项目结构

This commit is contained in:
2025-12-03 12:56:57 +08:00
commit 320673dd34
51 changed files with 5361 additions and 0 deletions

25
.gitignore vendored Normal file
View File

@@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

2
.npmrc Normal file
View File

@@ -0,0 +1,2 @@
shamefully-hoist=true

87
PROJECT_STATUS.md Normal file
View File

@@ -0,0 +1,87 @@
# 项目完成状态
## ✅ 已完成
### 1. 项目初始化
- ✅ Vue 3 + TypeScript项目结构
- ✅ Vite配置
- ✅ Tailwind CSS 4配置暗色主题
- ✅ TypeScript配置
- ✅ 路由配置
### 2. API层
- ✅ Axios请求封装统一响应格式处理
- ✅ 所有Postman Collection中的API接口已对接
- ✅ 认证模块登录、注册、验证码、Token检查
- ✅ 用户管理(获取信息、列表、创建、更新、删除)
- ✅ 联系人管理(列表、搜索、添加、申请、分组)
- ✅ 房间管理(创建、获取)
- ✅ 消息管理(发送、历史消息)
- ✅ 附件管理(上传、获取、删除)
- ✅ 系统接口健康检查、ICE服务器、在线状态
### 3. WebSocket
- ✅ WebSocket连接管理
- ✅ 消息接收处理
- ✅ ClientID管理
- ✅ 自动重连机制
### 4. 状态管理Pinia
- ✅ 认证Store
- ✅ 聊天Store
- ✅ 联系人Store
- ✅ 右键菜单Store单例化
### 5. 组件
- ✅ 右键菜单组件(单例化,确保同时只显示一个)
- ✅ 头像组件
- ✅ 消息列表组件
- ✅ 消息气泡组件(文本、图片、语音、视频、文件)
- ✅ 消息输入组件(支持文本、文件、语音录制)
### 6. 视图页面
- ✅ 登录/注册页面
- ✅ 聊天主页面(三栏布局)
- ✅ 联系人页面(搜索、添加好友、申请管理)
### 7. 工具函数
- ✅ 存储工具Token、用户信息管理
- ✅ 格式化工具时间、文件大小、URL解析、颜色生成
## 🚧 待完善
### 1. WebRTC通话功能
- ⏳ 通话窗口组件(拖拽、最大化、最小化)
- ⏳ WebRTC信令处理
- ⏳ 音视频流处理
- ⏳ 通话控制(静音、关闭摄像头)
### 2. 功能增强
- ⏳ 文件上传和预览
- ⏳ 语音消息发送
- ⏳ 消息状态显示(发送中/已发送/已读)
- ⏳ 消息时间分组显示
- ⏳ 联系人分组管理UI
- ⏳ 用户资料编辑
### 3. UI优化
- ⏳ Toast提示组件
- ⏳ 加载状态(骨架屏)
- ⏳ 更多交互动画效果
- ⏳ 响应式优化
## 📝 使用说明
1. 安装依赖:`npm install`
2. 启动开发服务器:`npm run dev`
3. 确保后端服务运行在 `http://localhost:12080`
4. 访问 http://localhost:3000
## 🔧 技术要点
1. **统一响应格式**所有API返回 `{code, message, result, type, interface_info}`HTTP状态码统一200
2. **右键菜单单例化**使用Pinia store管理确保同时只显示一个菜单
3. **WebSocket管理**:自动连接、重连、消息分发
4. **类型安全**完整的TypeScript类型定义
5. **暗色主题**复刻HTML demo的UI风格

74
README.md Normal file
View File

@@ -0,0 +1,74 @@
# NL-IM Vue 3 + TypeScript 前端项目
基于Vue 3 + TypeScript构建的即时通讯系统前端应用。
## 技术栈
- Vue 3.4 + TypeScript
- Pinia (状态管理)
- Vue Router 4
- Axios (HTTP客户端)
- Tailwind CSS 4
- WebSocket (实时通信)
- WebRTC (音视频通话)
## 项目结构
```
nl-im-vue-ts/
├── src/
│ ├── api/ # API接口层
│ ├── components/ # 公共组件
│ ├── composables/ # 组合式函数
│ ├── stores/ # Pinia状态管理
│ ├── types/ # TypeScript类型定义
│ ├── utils/ # 工具函数
│ ├── views/ # 页面组件
│ └── router/ # 路由配置
```
## 开发
### 安装依赖
```bash
npm install
```
### 启动开发服务器
```bash
npm run dev
```
访问 http://localhost:3000
### 构建生产版本
```bash
npm run build
```
## 后端API
确保后端服务运行在 `http://localhost:12080`
WebSocket地址: `ws://localhost:12080/ws`
## 功能特性
- ✅ 用户登录/注册
- ✅ 联系人管理
- ✅ 实时消息聊天
- ✅ 多媒体消息(文本、图片、语音、视频、文件)
- ✅ 语音录制和发送
- ✅ 右键菜单(单例化)
- ✅ WebRTC音视频通话待完善
## 注意事项
1. 需要后端服务运行在 `http://localhost:12080`
2. 登录后Token会自动存储在localStorage
3. WebSocket连接会自动建立和管理
4. 所有API响应统一格式`{code, message, result, type, interface_info}`

15
index.html Normal file
View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="zh-CN" class="dark">
<head>
<meta charset="UTF-8">
<link rel="icon" type="image/svg+xml" href="/vite.svg">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NL-IM 即时通讯系统</title>
<!-- FontAwesome -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

28
package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "nl-im-vue-ts",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons-vue": "^7.0.0",
"axios": "^1.6.0",
"pinia": "^2.1.0",
"vue": "^3.4.0",
"vue-router": "^4.2.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"@vueuse/core": "^10.7.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"sass": "^1.94.2",
"tailwindcss": "^3.4.18",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vue-tsc": "^1.8.0"
}
}

2121
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

7
postcss.config.js Normal file
View File

@@ -0,0 +1,7 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

2
public/vite.svg Normal file
View File

@@ -0,0 +1,2 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.666 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

8
src/App.vue Normal file
View File

@@ -0,0 +1,8 @@
<template>
<router-view />
</template>
<script setup lang="ts">
// App root component
</script>

View File

@@ -0,0 +1,37 @@
import request from '../request'
import type { Attachment, PaginatedResponse } from '@/types/api'
/**
* 附件管理相关API
*/
// 上传附件
export function uploadAttachment(file: File, type: 'image' | 'video' | 'file') {
const formData = new FormData()
formData.append('file', file)
formData.append('type', type)
return request.post<Attachment>('/attachments/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
}
// 获取附件信息
export function getAttachment(id: number) {
return request.get<Attachment>(`/attachments/${id}`)
}
// 获取附件列表
export function getAttachments(type?: string, page = 1, pageSize = 20) {
return request.get<PaginatedResponse<Attachment>>('/attachments', {
params: { type, page, page_size: pageSize },
})
}
// 删除附件
export function deleteAttachment(id: number) {
return request.post<void>(`/attachments/delete/${id}`)
}

32
src/api/modules/auth.ts Normal file
View File

@@ -0,0 +1,32 @@
import request from '../request'
import type { LoginRequest, LoginResponse, RegisterRequest, SendCodeRequest } from '@/types/api'
/**
* 认证相关API
*/
// 用户登录
export function login(data: LoginRequest) {
return request.post<LoginResponse>('/login', data)
}
// 用户注册
export function register(data: RegisterRequest) {
return request.post<void>('/register', data)
}
// 发送邮箱验证码
export function sendEmailCode(data: SendCodeRequest) {
return request.post<void>('/send-email-code', data)
}
// 发送短信验证码
export function sendSmsCode(data: SendCodeRequest) {
return request.post<void>('/send-sms-code', data)
}
// 检查Token有效性
export function checkToken() {
return request.get<void>('/check-token')
}

View File

@@ -0,0 +1,79 @@
import request from '../request'
import type { Contact, User, FriendRequest, ContactGroup, PaginatedResponse } from '@/types/api'
/**
* 联系人管理相关API
*/
// 获取联系人列表
export function getContacts() {
return request.get<Contact[]>('/contacts')
}
// 搜索用户
export function searchUsers(keyword: string, limit = 20) {
return request.get<User[]>('/contacts/search', {
params: { keyword, limit },
})
}
// 添加好友(发送申请)
export function addFriend(data: { to_user_id: string; message?: string }) {
return request.post<void>('/contacts/add-friend', data)
}
// 获取好友申请列表
export function getFriendRequests() {
return request.get<FriendRequest[]>('/contacts/friend-requests')
}
// 接受好友申请
export function acceptFriendRequest(requestId: number) {
return request.post<void>('/contacts/accept-request', { request_id: requestId })
}
// 拒绝好友申请
export function rejectFriendRequest(requestId: number) {
return request.post<void>('/contacts/reject-request', { request_id: requestId })
}
// 获取分组列表
export function getGroups() {
return request.get<ContactGroup[]>('/contacts/groups')
}
// 创建分组
export function createGroup(data: { group_name: string }) {
return request.post<ContactGroup>('/contacts/groups', data)
}
// 更新分组
export function updateGroup(id: number, data: { group_name?: string; sort_order?: number }) {
return request.post<ContactGroup>(`/contacts/groups/update/${id}`, data)
}
// 删除分组
export function deleteGroup(id: number) {
return request.post<void>(`/contacts/groups/delete/${id}`)
}
// 获取好友详情
export function getContactDetail(id: string) {
return request.get<Contact>(`/contacts/${id}`)
}
// 更新好友信息
export function updateContact(id: string, data: {
remark_name?: string
group_id?: number
is_top?: boolean
is_muted?: boolean
}) {
return request.post<Contact>(`/contacts/update/${id}`, data)
}
// 删除好友
export function deleteContact(id: string) {
return request.post<void>(`/contacts/delete/${id}`)
}

View File

@@ -0,0 +1,26 @@
import request from '../request'
import type { ChatMessage, SendMessageRequest, PaginatedResponse } from '@/types/api'
/**
* 消息管理相关API
*/
// 发送消息
export function sendMessage(data: SendMessageRequest) {
return request.post<void>('/send', data)
}
// 获取历史消息
export function getMessages(roomId: string, page = 1, pageSize = 50) {
return request.get<PaginatedResponse<ChatMessage>>('/messages', {
params: { room_id: roomId, page, page_size: pageSize },
})
}
// 同步消息(兼容接口)
export function syncMessages(roomId: string, page = 1, pageSize = 50) {
return request.get<PaginatedResponse<ChatMessage>>('/messages/sync', {
params: { room_id: roomId, page, page_size: pageSize },
})
}

22
src/api/modules/room.ts Normal file
View File

@@ -0,0 +1,22 @@
import request from '../request'
import type { Room } from '@/types/api'
/**
* 房间管理相关API
*/
// 创建房间
export function createRoom(data: {
room_type: 'p2p' | 'group'
members: string[]
name?: string
avatar?: string
}) {
return request.post<Room>('/rooms', data)
}
// 获取房间信息
export function getRoom(id: string) {
return request.get<Room>(`/rooms/${id}`)
}

31
src/api/modules/system.ts Normal file
View File

@@ -0,0 +1,31 @@
import request from '../request'
import type { ICEServerConfig } from '@/types/api'
/**
* 系统接口相关API
*/
// 健康检查
export function healthCheck() {
return request.get<{ status: string; node: string; time: string }>('/health')
}
// 获取ICE服务器配置
export function getIceServers(userId: string) {
return request.get<ICEServerConfig[]>('/ice-servers', {
params: { user_id: userId },
})
}
// 检查用户在线状态
export function checkUserOnline(userId: string) {
return request.get<{ is_online: boolean }>('/check-user-online', {
params: { user_id: userId },
})
}
// 绑定ClientID和UserID
export function bindClient(data: { user_id: string; client_id: string }) {
return request.post<void>('/bind', data)
}

34
src/api/modules/user.ts Normal file
View File

@@ -0,0 +1,34 @@
import request from '../request'
import type { User, PaginatedResponse } from '@/types/api'
/**
* 用户管理相关API
*/
// 获取当前用户信息
export function getMyInfo() {
return request.get<User>('/user/my-info')
}
// 获取用户列表
export function getUserList(page = 1, pageSize = 20) {
return request.get<PaginatedResponse<User>>('/user/list', {
params: { page, page_size: pageSize },
})
}
// 创建用户(管理员功能)
export function createUser(data: Partial<User>) {
return request.post<User>('/user/create', data)
}
// 更新用户信息
export function updateUser(data: { id: string; updates: Partial<User> }) {
return request.post<User>('/user/update', data)
}
// 删除用户(管理员功能)
export function deleteUser(id: string) {
return request.post<void>('/user/delete', { id })
}

58
src/api/request/index.ts Normal file
View File

@@ -0,0 +1,58 @@
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios'
import type { ApiResponse } from './types'
// 创建axios实例
const request: AxiosInstance = axios.create({
baseURL: '/api',
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
})
// 请求拦截器
request.interceptors.request.use(
(config) => {
// 从localStorage获取token
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
// 从localStorage获取user_id用于需要X-User-ID header的接口
const userId = localStorage.getItem('user_id')
if (userId) {
config.headers['X-User-ID'] = userId
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器
request.interceptors.response.use(
(response: AxiosResponse<ApiResponse>) => {
const res = response.data
// 统一响应格式处理
// 后端所有接口返回HTTP 200错误通过code字段标识
if (res.code !== 0) {
// 错误处理
console.error('API Error:', res.message)
return Promise.reject(new Error(res.message || '请求失败'))
}
// 返回result字段的数据
return res.result
},
(error) => {
console.error('Request Error:', error)
return Promise.reject(error)
}
)
export default request

24
src/api/request/types.ts Normal file
View File

@@ -0,0 +1,24 @@
/**
* 统一API响应格式
*/
export interface ApiResponse<T = any> {
code: number
message: string
result: T
type: 'success' | 'error'
interface_info: {
result_time: string
ecs: string
}
}
/**
* 分页响应格式
*/
export interface PaginatedResponse<T> {
data: T[]
total: number
page: number
size: number
}

178
src/api/websocket/index.ts Normal file
View File

@@ -0,0 +1,178 @@
import type { ChatMessage } from '@/types/api'
import type { MessageType, CallStatus } from '@/types/message'
/**
* WebSocket连接管理
*/
export interface WebSocketMessage {
request_type?: string
clientId?: string
data?: ChatMessage
}
export type MessageHandler = (message: ChatMessage) => void
export type SignalHandler = (message: ChatMessage) => void
class WebSocketManager {
private ws: WebSocket | null = null
private clientId: string | null = null
private userId: string | null = null
private messageHandlers: MessageHandler[] = []
private signalHandlers: SignalHandler[] = []
private reconnectAttempts = 0
private maxReconnectAttempts = 5
private reconnectDelay = 3000
/**
* 连接WebSocket
*/
connect(userId: string): Promise<void> {
return new Promise((resolve, reject) => {
if (this.ws?.readyState === WebSocket.OPEN) {
resolve()
return
}
this.userId = userId
const wsUrl = `ws://localhost:12080/ws?user_id=${userId}`
try {
this.ws = new WebSocket(wsUrl)
this.ws.onopen = () => {
console.log('WebSocket connected')
this.reconnectAttempts = 0
resolve()
}
this.ws.onmessage = (event) => {
try {
const payload: WebSocketMessage = JSON.parse(event.data)
// 接收clientId
if (payload.clientId) {
this.clientId = payload.clientId
console.log('Received clientId:', this.clientId)
}
// 处理接收消息
if (payload.request_type === 'receive_message' && payload.data) {
this.handleMessage(payload.data)
}
} catch (error) {
console.error('WebSocket message parse error:', error)
}
}
this.ws.onerror = (error) => {
console.error('WebSocket error:', error)
reject(error)
}
this.ws.onclose = () => {
console.log('WebSocket closed')
this.ws = null
this.attemptReconnect()
}
} catch (error) {
reject(error)
}
})
}
/**
* 断开连接
*/
disconnect() {
if (this.ws) {
this.ws.close()
this.ws = null
}
this.clientId = null
this.userId = null
this.messageHandlers = []
this.signalHandlers = []
}
/**
* 获取ClientID
*/
getClientId(): string | null {
return this.clientId
}
/**
* 添加消息处理器
*/
onMessage(handler: MessageHandler) {
this.messageHandlers.push(handler)
}
/**
* 移除消息处理器
*/
offMessage(handler: MessageHandler) {
const index = this.messageHandlers.indexOf(handler)
if (index > -1) {
this.messageHandlers.splice(index, 1)
}
}
/**
* 添加信令处理器
*/
onSignal(handler: SignalHandler) {
this.signalHandlers.push(handler)
}
/**
* 移除信令处理器
*/
offSignal(handler: SignalHandler) {
const index = this.signalHandlers.indexOf(handler)
if (index > -1) {
this.signalHandlers.splice(index, 1)
}
}
/**
* 处理接收到的消息
*/
private handleMessage(message: ChatMessage) {
// 信令消息message_type = 6
if (message.message_type === 6) {
this.signalHandlers.forEach(handler => handler(message))
} else {
// 普通消息
this.messageHandlers.forEach(handler => handler(message))
}
}
/**
* 尝试重连
*/
private attemptReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('Max reconnect attempts reached')
return
}
if (!this.userId) {
return
}
this.reconnectAttempts++
console.log(`Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`)
setTimeout(() => {
if (this.userId) {
this.connect(this.userId).catch(console.error)
}
}, this.reconnectDelay)
}
}
// 单例
export const wsManager = new WebSocketManager()

106
src/assets/styles/main.scss Normal file
View File

@@ -0,0 +1,106 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background-color: #0f172a;
color: #f8fafc;
font-family: 'Segoe UI', system-ui, sans-serif;
overflow: hidden;
}
/* 滚动条美化 */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-thumb {
background: #475569;
border-radius: 3px;
}
::-webkit-scrollbar-track {
background: transparent;
}
/* 气泡尖角 */
.bubble-self {
border-top-right-radius: 2px;
}
.bubble-other {
border-top-left-radius: 2px;
}
/* 右键菜单 */
.context-menu {
position: fixed;
z-index: 9999;
background: #334155;
border: 1px solid #475569;
border-radius: 8px;
padding: 6px 0;
min-width: 140px;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
}
.menu-item {
padding: 8px 16px;
font-size: 13px;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
color: #e2e8f0;
transition: 0.2s;
}
.menu-item:hover {
background: #4f46e5;
color: white;
}
.menu-item.danger:hover {
background: #ef4444;
}
/* 移动端过渡 */
.slide-enter-active,
.slide-leave-active {
transition: transform 0.3s ease;
}
.slide-enter-from,
.slide-leave-to {
transform: translateX(100%);
}
/* 呼叫动画 */
.calling-avatar-anim {
animation: pulse-ring 2s infinite;
}
@keyframes pulse-ring {
0% {
box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.7);
}
70% {
box-shadow: 0 0 0 20px rgba(99, 102, 241, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(99, 102, 241, 0);
}
}
/* 录音按钮动画 */
.recording-btn {
transform: scale(1.2);
box-shadow: 0 0 0 10px rgba(239, 68, 68, 0.3);
}

View File

@@ -0,0 +1,35 @@
<template>
<div
class="px-3 py-2.5 rounded-2xl text-sm shadow-md transition-all relative group-hover:shadow-lg"
:class="message.isSelf ? 'bg-msg-self text-white bubble-self' : 'bg-msg-other text-gray-200 bubble-other'"
>
<!-- 文本消息 -->
<TextBubble v-if="message.message_type === 0" :message="message" />
<!-- 图片消息 -->
<ImageBubble v-else-if="message.message_type === 1" :message="message" />
<!-- 语音消息 -->
<AudioBubble v-else-if="message.message_type === 2" :message="message" />
<!-- 视频消息 -->
<VideoBubble v-else-if="message.message_type === 3" :message="message" />
<!-- 文件消息 -->
<FileBubble v-else-if="message.message_type === 8" :message="message" />
</div>
</template>
<script setup lang="ts">
import type { ChatMessage } from '@/types/api'
import TextBubble from './bubble/Text.vue'
import ImageBubble from './bubble/Image.vue'
import AudioBubble from './bubble/Audio.vue'
import VideoBubble from './bubble/Video.vue'
import FileBubble from './bubble/File.vue'
defineProps<{
message: ChatMessage
}>()
</script>

View File

@@ -0,0 +1,181 @@
<template>
<div class="p-4 bg-panel border-t border-gray-800 shrink-0 z-20">
<div class="flex gap-5 text-gray-400 text-lg mb-3 px-1 items-center">
<i
class="far fa-smile hover:text-primary cursor-pointer transition transform hover:scale-110"
title="表情"
@click="insertEmoji('😊')"
></i>
<i
class="far fa-image hover:text-primary cursor-pointer transition transform hover:scale-110"
@click="triggerFile(1)"
title="发送图片"
></i>
<i
class="fas fa-folder-plus hover:text-primary cursor-pointer transition transform hover:scale-110"
@click="triggerFile(8)"
title="发送文件"
></i>
<!-- 长按录音 -->
<div
class="relative flex items-center group cursor-pointer select-none"
@mousedown="handleRecordStart"
@touchstart.prevent="handleRecordStart"
@mouseup="handleRecordStop"
@touchend.prevent="handleRecordStop"
@mouseleave="handleRecordCancel"
>
<div
class="w-8 h-8 rounded-full flex items-center justify-center transition-all bg-gray-700 hover:bg-gray-600"
:class="{ 'bg-red-500 text-white recording-btn': isRecording }"
>
<i class="fas" :class="isRecording ? 'fa-microphone-lines' : 'fa-microphone'"></i>
</div>
<span
v-if="isRecording"
class="ml-2 text-xs text-red-500 font-bold animate-pulse absolute -top-8 bg-black/80 px-2 py-1 rounded text-white whitespace-nowrap"
>
松开发送...
</span>
</div>
</div>
<div class="bg-input rounded-xl p-2 flex items-end ring-1 ring-white/5 focus-within:ring-primary/50 transition shadow-inner">
<textarea
:value="modelValue"
class="w-full bg-transparent border-none text-white resize-none h-10 max-h-32 py-2 px-2 text-sm focus:outline-none scrollbar-thin placeholder-gray-500"
placeholder="输入消息 (Enter 发送Ctrl+V 粘贴)..."
@input="$emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
@keydown.enter.exact.prevent="$emit('send')"
@paste="handlePaste"
></textarea>
<div
class="bg-primary hover:bg-indigo-500 text-white w-9 h-9 rounded-lg flex items-center justify-center ml-2 transition shadow-lg shadow-indigo-500/30 active:scale-95 cursor-pointer"
@click="$emit('send')"
>
<i class="fas fa-paper-plane text-xs"></i>
</div>
</div>
<input
ref="fileInput"
type="file"
class="hidden"
@change="handleFileChange"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
interface Props {
modelValue: string
isRecording: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: string]
send: []
file: [type: number, file: File]
'record-start': []
'record-stop': [blob: Blob, duration: number]
'record-cancel': []
}>()
const fileInput = ref<HTMLInputElement>()
let mediaRecorder: MediaRecorder | null = null
let recordStartTime = 0
function insertEmoji(emoji: string) {
emit('update:modelValue', props.modelValue + emoji)
}
function triggerFile(type: number) {
if (!fileInput.value) return
fileInput.value.accept = type === 1 ? 'image/*' : type === 3 ? 'video/*' : '*/*'
fileInput.value.value = ''
fileInput.value.click()
}
function handleFileChange(e: Event) {
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (file) {
const type = file.type.startsWith('image/') ? 1 : file.type.startsWith('video/') ? 3 : 8
emit('file', type, file)
}
}
function handlePaste(e: ClipboardEvent) {
const items = e.clipboardData?.items
if (!items) return
for (const item of items) {
if (item.kind === 'file') {
const file = item.getAsFile()
if (file) {
const type = file.type.startsWith('image/') ? 1 : file.type.startsWith('video/') ? 3 : 8
emit('file', type, file)
}
}
}
}
async function handleRecordStart() {
if (props.isRecording) return
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
mediaRecorder = new MediaRecorder(stream)
const chunks: Blob[] = []
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) {
chunks.push(e.data)
}
}
mediaRecorder.onstop = () => {
const duration = Date.now() - recordStartTime
stream.getTracks().forEach((t) => t.stop())
if (duration < 1000) {
alert('说话时间太短')
return
}
const blob = new Blob(chunks, { type: 'audio/webm' })
const seconds = Math.ceil(duration / 1000)
emit('record-stop', blob, seconds)
}
mediaRecorder.start()
recordStartTime = Date.now()
emit('record-start')
} catch (error) {
console.error('Failed to start recording:', error)
alert('无法访问麦克风')
}
}
function handleRecordStop() {
if (!props.isRecording || !mediaRecorder) return
mediaRecorder.stop()
}
function handleRecordCancel() {
if (props.isRecording && mediaRecorder) {
mediaRecorder.onstop = null
mediaRecorder.stop()
if (mediaRecorder.stream) {
mediaRecorder.stream.getTracks().forEach((t) => t.stop())
}
emit('record-cancel')
}
}
</script>

View File

@@ -0,0 +1,61 @@
<template>
<div
id="msgListContainer"
class="flex-1 overflow-y-auto p-4 space-y-5"
@dragover.prevent
@drop.prevent
>
<div
v-for="(msg, index) in messages"
:key="index"
class="flex gap-3 msg-row group"
:class="{ 'flex-row-reverse': msg.isSelf }"
@contextmenu.stop="$emit('contextmenu', $event, msg)"
>
<!-- 头像 -->
<Avatar
:name="msg.isSelf ? currentUser.name : target.user?.name"
:avatar="msg.isSelf ? currentUser.avatar : target.user?.avatar"
:color="msg.isSelf ? generateColor(currentUser.id) : target.color"
size="sm"
class="cursor-pointer hover:opacity-80 transition self-start mt-1"
@click="$emit('avatar-click', msg.isSelf ? currentUser : target)"
/>
<!-- 消息内容区 -->
<div class="flex flex-col max-w-[75%] md:max-w-[60%]">
<div
class="text-[10px] text-gray-500 mb-1 px-1"
:class="{ 'text-right': msg.isSelf }"
>
{{ msg.isSelf ? 'Me' : (target.remark_name || target.user?.name) }}
</div>
<!-- 消息气泡 -->
<MessageBubble :message="msg" @click="$emit('bubble-click', $event, msg)" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { generateColor } from '@/utils/format'
import Avatar from '@/components/common/Avatar.vue'
import MessageBubble from '@/components/chat/MessageBubble.vue'
import type { ChatMessage, User, Contact } from '@/types/api'
interface Props {
messages: ChatMessage[]
currentUser: User
target: Contact
}
const props = defineProps<Props>()
defineEmits<{
'contextmenu': [event: MouseEvent, message: ChatMessage]
'avatar-click': [user: User | Contact]
'bubble-click': [event: MouseEvent, message: ChatMessage]
}>()
</script>

View File

@@ -0,0 +1,38 @@
<template>
<div
class="flex items-center gap-3 cursor-pointer select-none py-1 pr-4 min-w-[100px]"
@click="playAudio"
>
<i class="fas fa-play-circle text-2xl opacity-90 hover:opacity-100"></i>
<div class="flex flex-col">
<span class="text-xs font-bold">语音消息</span>
<span class="text-[10px] opacity-70">{{ duration }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ChatMessage } from '@/types/api'
const props = defineProps<{
message: ChatMessage
}>()
const extra = computed(() => {
return typeof props.message.extra === 'object' ? props.message.extra : {}
})
const duration = computed(() => {
return extra.value.duration || `00:${Math.ceil(props.message.duration || 10)}`
})
const audioUrl = computed(() => {
return extra.value.url || props.message.content
})
function playAudio() {
new Audio(audioUrl.value).play()
}
</script>

View File

@@ -0,0 +1,48 @@
<template>
<div
class="flex items-center gap-3 bg-black/10 p-2.5 rounded-lg cursor-pointer hover:bg-black/20 transition border border-white/5"
@click="downloadFile"
>
<div class="w-10 h-10 bg-blue-500/20 rounded-lg flex items-center justify-center text-blue-400 shrink-0">
<i class="fas fa-file-alt text-xl"></i>
</div>
<div class="min-w-0">
<div class="font-medium truncate text-xs">{{ fileName }}</div>
<div class="text-[10px] text-gray-400 mt-0.5">{{ fileSize }}</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ChatMessage } from '@/types/api'
import { formatSize } from '@/utils/format'
const props = defineProps<{
message: ChatMessage
}>()
const extra = computed(() => {
return typeof props.message.extra === 'object' ? props.message.extra : {}
})
const fileName = computed(() => {
return extra.value.name || '未知文件'
})
const fileSize = computed(() => {
return formatSize(extra.value.size || 0)
})
const fileUrl = computed(() => {
return extra.value.url || props.message.content
})
function downloadFile() {
const a = document.createElement('a')
a.href = fileUrl.value
a.download = fileName.value
a.click()
}
</script>

View File

@@ -0,0 +1,29 @@
<template>
<img
:src="imageUrl"
class="rounded-lg max-w-full cursor-zoom-in hover:brightness-90 transition border border-white/10"
@click="previewImage"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ChatMessage } from '@/types/api'
const props = defineProps<{
message: ChatMessage
}>()
const imageUrl = computed(() => {
const extra = typeof props.message.extra === 'object' ? props.message.extra : {}
return extra.url || props.message.content
})
function previewImage() {
const w = window.open('')
if (w) {
w.document.write(`<img src="${imageUrl.value}" style="max-width:100%">`)
}
}
</script>

View File

@@ -0,0 +1,47 @@
<template>
<div>
<div
v-html="parseText(message.content)"
class="whitespace-pre-wrap break-words leading-relaxed min-h-[20px]"
></div>
<!-- URL 卡片 -->
<div
v-if="extra?.url && extra?.title"
class="mt-2 bg-black/20 rounded-lg overflow-hidden cursor-pointer hover:bg-black/30 transition border border-white/10"
@click="openUrl(extra.url)"
>
<div class="flex h-16">
<div
v-if="extra.image"
class="w-16 bg-cover bg-center shrink-0"
:style="`background-image:url(${extra.image})`"
></div>
<div class="flex-1 p-2 min-w-0 flex flex-col justify-center">
<div class="font-bold text-xs truncate text-blue-200">{{ extra.title }}</div>
<div class="text-[10px] text-gray-400 line-clamp-1 mt-0.5">
{{ extra.description || extra.url }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ChatMessage } from '@/types/api'
import { parseText } from '@/utils/format'
const props = defineProps<{
message: ChatMessage
}>()
const extra = computed(() => {
return typeof props.message.extra === 'object' ? props.message.extra : {}
})
function openUrl(url: string) {
window.open(url, '_blank')
}
</script>

View File

@@ -0,0 +1,24 @@
<template>
<div class="relative group/video">
<video
:src="videoUrl"
controls
class="rounded-lg max-w-full bg-black max-h-60 border border-white/10"
></video>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ChatMessage } from '@/types/api'
const props = defineProps<{
message: ChatMessage
}>()
const videoUrl = computed(() => {
const extra = typeof props.message.extra === 'object' ? props.message.extra : {}
return extra.url || props.message.content
})
</script>

View File

@@ -0,0 +1,41 @@
<template>
<div
class="rounded-full flex items-center justify-center text-white font-bold shadow-md transition transform hover:opacity-80 cursor-pointer"
:class="sizeClass"
:style="{ background: color || generateColor(name || '') }"
@click="$emit('click')"
>
{{ avatar || name?.charAt(0).toUpperCase() || '?' }}
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { generateColor } from '@/utils/format'
interface Props {
avatar?: string
name?: string
color?: string
size?: 'sm' | 'md' | 'lg' | 'xl'
}
const props = withDefaults(defineProps<Props>(), {
size: 'md',
})
const sizeClass = computed(() => {
const sizes = {
sm: 'w-8 h-8 text-xs',
md: 'w-10 h-10 text-sm',
lg: 'w-12 h-12 text-base',
xl: 'w-16 h-16 text-xl',
}
return sizes[props.size]
})
defineEmits<{
click: []
}>()
</script>

View File

@@ -0,0 +1,70 @@
<template>
<div
v-if="show"
class="context-menu"
:style="{ top: y + 'px', left: x + 'px' }"
@click.stop
>
<div
v-for="(item, index) in items"
:key="index"
class="menu-item"
:class="{ danger: item.danger }"
@click="handleItemClick(item)"
>
<i v-if="item.icon" :class="item.icon" class="w-5 text-center opacity-70"></i>
<span>{{ item.label }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useContextMenuStore } from '@/stores/contextMenu'
const contextMenuStore = useContextMenuStore()
const show = computed(() => contextMenuStore.show)
const x = computed(() => contextMenuStore.x)
const y = computed(() => contextMenuStore.y)
const items = computed(() => contextMenuStore.items)
function handleItemClick(item: typeof items.value[0]) {
item.action()
contextMenuStore.hideMenu()
}
</script>
<style scoped>
.context-menu {
position: fixed;
z-index: 9999;
background: #334155;
border: 1px solid #475569;
border-radius: 8px;
padding: 6px 0;
min-width: 140px;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
}
.menu-item {
padding: 8px 16px;
font-size: 13px;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
color: #e2e8f0;
transition: 0.2s;
}
.menu-item:hover {
background: #4f46e5;
color: white;
}
.menu-item.danger:hover {
background: #ef4444;
}
</style>

View File

@@ -0,0 +1,47 @@
import { onMounted, onUnmounted } from 'vue'
import { useContextMenuStore } from '@/stores/contextMenu'
import type { MenuItem } from '@/stores/contextMenu'
/**
* 右键菜单Hook
*/
export function useContextMenu() {
const contextMenuStore = useContextMenuStore()
/**
* 显示右键菜单
*/
function showContextMenu(event: MouseEvent, menuItems: MenuItem[]) {
event.preventDefault()
event.stopPropagation()
contextMenuStore.showMenu(event, menuItems)
}
/**
* 点击外部区域关闭菜单
*/
function handleClickOutside(event: MouseEvent) {
if (contextMenuStore.show) {
const target = event.target as HTMLElement
if (!target.closest('.context-menu')) {
contextMenuStore.hideMenu()
}
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
document.addEventListener('contextmenu', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
document.removeEventListener('contextmenu', handleClickOutside)
})
return {
showContextMenu,
hideContextMenu: contextMenuStore.hideMenu,
}
}

13
src/main.ts Normal file
View File

@@ -0,0 +1,13 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './assets/styles/main.scss'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

47
src/router/index.ts Normal file
View File

@@ -0,0 +1,47 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '/',
redirect: '/chat',
},
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/LoginView.vue'),
},
{
path: '/chat',
name: 'Chat',
component: () => import('@/views/chat/ChatView.vue'),
meta: { requiresAuth: true },
},
{
path: '/contact',
name: 'Contact',
component: () => import('@/views/contact/ContactView.vue'),
meta: { requiresAuth: true },
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
// 路由守卫
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
if (to.meta.requiresAuth && !token) {
next('/login')
} else if (to.path === '/login' && token) {
next('/chat')
} else {
next()
}
})
export default router

101
src/stores/auth.ts Normal file
View File

@@ -0,0 +1,101 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import * as authApi from '@/api/modules/auth'
import * as userApi from '@/api/modules/user'
import { storage } from '@/utils/storage'
import type { User, LoginRequest } from '@/types/api'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(storage.getToken())
const user = ref<User | null>(storage.getUserInfo())
const isAuthenticated = ref(!!token.value)
/**
* 登录
*/
async function login(data: LoginRequest) {
try {
const response = await authApi.login(data)
token.value = response.token
user.value = response.user
isAuthenticated.value = true
// 存储到localStorage
storage.setToken(response.token)
storage.setUserId(response.user.id)
storage.setUserInfo(response.user)
if (data.remember) {
storage.setRemember(true)
}
return response
} catch (error) {
console.error('Login failed:', error)
throw error
}
}
/**
* 注册
*/
async function register(data: any) {
try {
await authApi.register(data)
} catch (error) {
console.error('Register failed:', error)
throw error
}
}
/**
* 登出
*/
function logout() {
token.value = null
user.value = null
isAuthenticated.value = false
storage.clear()
}
/**
* 检查Token并获取用户信息
*/
async function checkAuth() {
if (!token.value) {
return false
}
try {
await authApi.checkToken()
// Token有效获取最新用户信息
const userInfo = await userApi.getMyInfo()
user.value = userInfo
storage.setUserInfo(userInfo)
return true
} catch (error) {
// Token无效清除
logout()
return false
}
}
/**
* 更新用户信息
*/
function updateUserInfo(userInfo: User) {
user.value = userInfo
storage.setUserInfo(userInfo)
}
return {
token,
user,
isAuthenticated,
login,
register,
logout,
checkAuth,
updateUserInfo,
}
})

102
src/stores/chat.ts Normal file
View File

@@ -0,0 +1,102 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { ChatMessage, Contact } from '@/types/api'
import { generateColor } from '@/utils/format'
export const useChatStore = defineStore('chat', () => {
const currentTarget = ref<Contact | null>(null)
const messages = ref<Record<string, ChatMessage[]>>({})
const contacts = ref<Contact[]>([])
const totalUnread = computed(() =>
contacts.value.reduce((acc, c) => acc + (c.unread || 0), 0)
)
/**
* 设置当前聊天对象
*/
function setCurrentTarget(contact: Contact | null) {
currentTarget.value = contact
if (contact) {
contact.unread = 0
}
}
/**
* 添加消息
*/
function addMessage(roomId: string, message: ChatMessage) {
if (!messages.value[roomId]) {
messages.value[roomId] = []
}
messages.value[roomId].push(message)
}
/**
* 获取房间消息
*/
function getRoomMessages(roomId: string): ChatMessage[] {
return messages.value[roomId] || []
}
/**
* 设置房间消息
*/
function setRoomMessages(roomId: string, msgs: ChatMessage[]) {
messages.value[roomId] = msgs
}
/**
* 更新联系人列表
*/
function setContacts(newContacts: Contact[]) {
// 为每个联系人生成颜色
contacts.value = newContacts.map(contact => ({
...contact,
color: contact.color || generateColor(contact.user_id || contact.id),
}))
}
/**
* 更新联系人最后消息
*/
function updateContactLastMsg(contactId: string, lastMsg: string, lastTime: number) {
const contact = contacts.value.find(c => c.id === contactId || c.user_id === contactId)
if (contact) {
contact.lastMsg = lastMsg
contact.lastTime = lastTime
}
}
/**
* 增加联系人未读数
*/
function incrementUnread(contactId: string) {
const contact = contacts.value.find(c => c.id === contactId || c.user_id === contactId)
if (contact && contact.id !== currentTarget.value?.id) {
contact.unread = (contact.unread || 0) + 1
}
}
/**
* 清空房间消息
*/
function clearRoomMessages(roomId: string) {
messages.value[roomId] = []
}
return {
currentTarget,
messages,
contacts,
totalUnread,
setCurrentTarget,
addMessage,
getRoomMessages,
setRoomMessages,
setContacts,
updateContactLastMsg,
incrementUnread,
clearRoomMessages,
}
})

79
src/stores/contact.ts Normal file
View File

@@ -0,0 +1,79 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { FriendRequest, ContactGroup } from '@/types/api'
export const useContactStore = defineStore('contact', () => {
const friendRequests = ref<FriendRequest[]>([])
const groups = ref<ContactGroup[]>([])
/**
* 设置好友申请列表
*/
function setFriendRequests(requests: FriendRequest[]) {
friendRequests.value = requests
}
/**
* 添加好友申请
*/
function addFriendRequest(request: FriendRequest) {
friendRequests.value.push(request)
}
/**
* 移除好友申请
*/
function removeFriendRequest(requestId: number) {
const index = friendRequests.value.findIndex(r => r.id === requestId)
if (index > -1) {
friendRequests.value.splice(index, 1)
}
}
/**
* 设置分组列表
*/
function setGroups(newGroups: ContactGroup[]) {
groups.value = newGroups
}
/**
* 添加分组
*/
function addGroup(group: ContactGroup) {
groups.value.push(group)
}
/**
* 更新分组
*/
function updateGroup(group: ContactGroup) {
const index = groups.value.findIndex(g => g.id === group.id)
if (index > -1) {
groups.value[index] = group
}
}
/**
* 删除分组
*/
function removeGroup(groupId: number) {
const index = groups.value.findIndex(g => g.id === groupId)
if (index > -1) {
groups.value.splice(index, 1)
}
}
return {
friendRequests,
groups,
setFriendRequests,
addFriendRequest,
removeFriendRequest,
setGroups,
addGroup,
updateGroup,
removeGroup,
}
})

51
src/stores/contextMenu.ts Normal file
View File

@@ -0,0 +1,51 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface MenuItem {
label: string
icon?: string
action: () => void
danger?: boolean
}
export const useContextMenuStore = defineStore('contextMenu', () => {
const show = ref(false)
const x = ref(0)
const y = ref(0)
const items = ref<MenuItem[]>([])
/**
* 显示右键菜单
*/
function showMenu(event: MouseEvent, menuItems: MenuItem[]) {
// 关闭旧菜单
hideMenu()
// 计算菜单位置(防止超出屏幕)
const xPos = Math.min(event.clientX, window.innerWidth - 150)
const yPos = Math.min(event.clientY, window.innerHeight - 150)
x.value = xPos
y.value = yPos
items.value = menuItems
show.value = true
}
/**
* 隐藏右键菜单
*/
function hideMenu() {
show.value = false
items.value = []
}
return {
show,
x,
y,
items,
showMenu,
hideMenu,
}
})

143
src/types/api.ts Normal file
View File

@@ -0,0 +1,143 @@
/**
* API请求和响应的类型定义
*/
// 登录请求
export interface LoginRequest {
account: string // 邮箱或手机号
password: string
remember?: boolean
}
// 登录响应
export interface LoginResponse {
token: string
user: User
}
// 注册请求
export interface RegisterRequest {
email: string
phone: string
password: string
confirm_password: string
code: string
agree_terms: boolean
}
// 发送验证码请求
export interface SendCodeRequest {
target: string // 邮箱或手机号
type: 'email' | 'sms'
}
// 用户信息
export interface User {
id: string
email: string
phone: string
name: string
avatar: string
desc: string
region: string
created_at: string
updated_at: string
}
// 联系人信息
export interface Contact {
id: string
user_id: string
contact_user_id: string
remark_name?: string
group_id?: number
is_top: boolean
is_muted: boolean
user?: User // 关联的用户信息
last_msg?: string
last_time?: number
unread?: number
color?: string
}
// 好友申请
export interface FriendRequest {
id: number
from_user_id: string
to_user_id: string
message: string
status: 'pending' | 'accepted' | 'rejected'
created_at: string
from_user?: User
to_user?: User
}
// 分组
export interface ContactGroup {
id: number
user_id: string
group_name: string
sort_order: number
created_at: string
}
// 房间
export interface Room {
id: string
room_type: 'p2p' | 'group'
name?: string
avatar?: string
members: string[]
created_at: string
}
// 聊天消息
export interface ChatMessage {
id: number
room_id: string
sender_user_id: string
receiver_user_id?: string
message_type: number // 0:文本 1:图片 2:语音 3:视频 8:文件 6:信令
content: string
duration: number
extra?: string | Record<string, any>
created_at: string
isSelf?: boolean // 前端标记
}
// 发送消息请求
export interface SendMessageRequest {
sender_client_id?: string
receiver_user_id?: string
room_id: string
message_type: number
content: string
duration?: number
extra?: string
call_id?: string
call_status?: string
}
// 附件
export interface Attachment {
id: number
uploader_id: string
file_name: string
file_type: string
file_size: number
file_path: string
file_url: string
mime_type: string
width?: number
height?: number
duration?: number
created_at: string
}
// ICE服务器配置
export interface ICEServerConfig {
urls: string[]
username?: string
credential?: string
}

35
src/types/message.ts Normal file
View File

@@ -0,0 +1,35 @@
/**
* 消息相关类型定义
*/
export enum MessageType {
TEXT = 0, // 文本
IMAGE = 1, // 图片
AUDIO = 2, // 语音
VIDEO = 3, // 视频
SIGNAL = 6, // WebRTC信令
FILE = 8, // 文件
}
export enum CallStatus {
INVITE = 'invite',
ACCEPTED = 'accepted',
OFFER = 'offer',
ANSWER = 'answer',
CANDIDATE = 'candidate',
HANGUP = 'hangup',
ENDED = 'ended',
ANSWERED_ELSEWHERE = 'answered_elsewhere',
}
export interface MessageExtra {
url?: string
name?: string
size?: number
duration?: string
title?: string
description?: string
image?: string
type?: 'video' | 'audio'
}

20
src/types/user.ts Normal file
View File

@@ -0,0 +1,20 @@
/**
* 用户相关类型定义
*/
export interface UserInfo {
id: string
email: string
phone: string
name: string
avatar: string
desc: string
region: string
created_at: string
updated_at: string
}
export interface UserListItem extends UserInfo {
color?: string // 前端生成的颜色
}

77
src/utils/format.ts Normal file
View File

@@ -0,0 +1,77 @@
/**
* 格式化工具函数
*/
/**
* 格式化时间
*/
export function formatTime(timestamp: number | string): string {
const date = new Date(typeof timestamp === 'string' ? timestamp : timestamp)
const now = new Date()
const diff = now.getTime() - date.getTime()
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (days > 0) {
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
} else if (hours > 0) {
return `${hours}小时前`
} else if (minutes > 0) {
return `${minutes}分钟前`
} else {
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
}
/**
* 格式化文件大小
*/
export function formatSize(bytes: number): string {
if (!bytes) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
/**
* 格式化时长秒转MM:SS
*/
export function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
/**
* 解析文本中的URL
*/
export function parseText(text: string): string {
if (!text) return '&nbsp;'
const urlRegex = /(https?:\/\/[^\s]+)/g
return text.replace(urlRegex, '<a href="$1" target="_blank" class="text-blue-400 hover:underline pointer-events-auto">$1</a>')
}
/**
* 生成用户头像颜色
*/
export function generateColor(str: string): string {
const colors = [
'#3b82f6', // blue
'#ec4899', // pink
'#10b981', // green
'#f59e0b', // amber
'#6366f1', // indigo
'#ef4444', // red
'#8b5cf6', // purple
'#06b6d4', // cyan
]
let hash = 0
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash)
}
return colors[Math.abs(hash) % colors.length]
}

68
src/utils/storage.ts Normal file
View File

@@ -0,0 +1,68 @@
/**
* 本地存储工具函数
*/
const TOKEN_KEY = 'token'
const USER_ID_KEY = 'user_id'
const USER_INFO_KEY = 'user_info'
const REMEMBER_KEY = 'remember'
export const storage = {
// Token
setToken(token: string) {
localStorage.setItem(TOKEN_KEY, token)
},
getToken(): string | null {
return localStorage.getItem(TOKEN_KEY)
},
removeToken() {
localStorage.removeItem(TOKEN_KEY)
},
// User ID
setUserId(userId: string) {
localStorage.setItem(USER_ID_KEY, userId)
},
getUserId(): string | null {
return localStorage.getItem(USER_ID_KEY)
},
removeUserId() {
localStorage.removeItem(USER_ID_KEY)
},
// User Info
setUserInfo(userInfo: any) {
localStorage.setItem(USER_INFO_KEY, JSON.stringify(userInfo))
},
getUserInfo(): any | null {
const info = localStorage.getItem(USER_INFO_KEY)
return info ? JSON.parse(info) : null
},
removeUserInfo() {
localStorage.removeItem(USER_INFO_KEY)
},
// Remember
setRemember(remember: boolean) {
localStorage.setItem(REMEMBER_KEY, String(remember))
},
getRemember(): boolean {
return localStorage.getItem(REMEMBER_KEY) === 'true'
},
// Clear all
clear() {
this.removeToken()
this.removeUserId()
this.removeUserInfo()
this.setRemember(false)
},
}

502
src/views/chat/ChatView.vue Normal file
View File

@@ -0,0 +1,502 @@
<template>
<div class="h-screen w-screen flex text-sm sm:text-base bg-dark" @contextmenu.prevent>
<!-- 左侧功能导航 -->
<div class="w-16 bg-dark border-r border-gray-800 flex flex-col items-center py-6 gap-6 z-20 hidden md:flex shrink-0">
<Avatar
:name="authStore.user?.name"
:avatar="authStore.user?.avatar"
size="md"
class="cursor-pointer hover:ring-2 ring-white transition shadow-lg"
@click="showProfileModal = true"
/>
<div
class="text-gray-400 hover:text-primary cursor-pointer transition relative"
:class="{ 'text-primary': currentTab === 'chat' }"
@click="currentTab = 'chat'"
>
<i class="fas fa-comment-dots text-xl"></i>
<div v-if="chatStore.totalUnread > 0" class="absolute -top-1 -right-1 w-2 h-2 bg-red-500 rounded-full"></div>
</div>
<div
class="text-gray-400 hover:text-primary cursor-pointer transition"
:class="{ 'text-primary': currentTab === 'contact' }"
@click="currentTab = 'contact'"
>
<i class="fas fa-address-book text-xl"></i>
</div>
<div
class="mt-auto mb-2 text-gray-500 hover:text-red-500 cursor-pointer transition"
@click="handleLogout"
title="退出登录"
>
<i class="fas fa-sign-out-alt text-xl"></i>
</div>
</div>
<!-- 中间侧边栏 (联系人列表) -->
<div
v-show="currentTab === 'chat' && (!isMobile || !chatVisible)"
class="w-full md:w-72 bg-panel border-r border-gray-800 flex flex-col z-10 h-full shrink-0"
>
<div class="p-4 border-b border-gray-800">
<div class="relative group">
<i class="fas fa-search absolute left-3 top-2.5 text-gray-500 group-focus-within:text-primary transition"></i>
<input
v-model="searchQuery"
type="text"
class="w-full bg-input rounded-lg py-2 pl-10 pr-4 text-sm text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500"
placeholder="搜索联系人..."
/>
</div>
</div>
<div class="flex-1 overflow-y-auto">
<div
v-for="contact in filteredContacts"
:key="contact.id"
class="flex items-center p-3 cursor-pointer hover:bg-white/5 transition-colors relative group border-l-4 border-transparent"
:class="{
'bg-white/10 border-primary': chatStore.currentTarget?.id === contact.id,
'bg-black/20': contact.is_top,
}"
@click="selectChat(contact)"
@contextmenu.stop="showContactMenu($event, contact)"
>
<div class="relative shrink-0">
<Avatar
:name="contact.user?.name || contact.remark_name"
:avatar="contact.user?.avatar"
:color="contact.color"
size="md"
class="transition transform group-hover:scale-105"
/>
<div
v-if="(contact.unread || 0) > 0"
class="absolute -top-1.5 -right-1.5 bg-red-500 text-white text-[10px] min-w-[18px] h-[18px] flex items-center justify-center rounded-full border-2 border-panel font-bold shadow-sm"
>
{{ contact.unread }}
</div>
</div>
<div class="flex-1 min-w-0 ml-3">
<div class="flex justify-between items-center mb-0.5">
<span class="font-semibold truncate text-gray-200 text-sm">
{{ contact.remark_name || contact.user?.name || '未知' }}
</span>
<span class="text-xs text-gray-500">{{ formatTime(contact.last_time || Date.now()) }}</span>
</div>
<div class="text-xs text-gray-400 truncate flex items-center h-4">
<i v-if="contact.is_top" class="fas fa-thumbtack text-yellow-500 mr-1 text-[10px]"></i>
<span>{{ contact.lastMsg || contact.user?.desc || '' }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 右侧主聊天区 -->
<div
v-if="chatStore.currentTarget && (currentTab === 'chat' && (!isMobile || chatVisible))"
class="flex-1 flex flex-col bg-dark relative w-full h-full overflow-hidden"
>
<!-- 聊天头部 -->
<div class="h-16 border-b border-gray-800 flex justify-between items-center px-4 bg-panel/80 backdrop-blur shrink-0 z-20 shadow-sm">
<div class="flex items-center gap-3 cursor-pointer" @click="backToList">
<i class="fas fa-chevron-left md:hidden text-gray-400 text-lg p-2 -ml-2"></i>
<Avatar
:name="chatStore.currentTarget.user?.name"
:avatar="chatStore.currentTarget.user?.avatar"
:color="chatStore.currentTarget.color"
size="sm"
/>
<div>
<h3 class="font-bold text-sm md:text-base text-gray-100 flex items-center gap-2">
{{ chatStore.currentTarget.remark_name || chatStore.currentTarget.user?.name }}
<span class="w-2 h-2 bg-success rounded-full animate-pulse"></span>
</h3>
</div>
</div>
<div class="flex gap-2 text-gray-400">
<button
class="w-9 h-9 rounded-lg hover:bg-white/10 hover:text-white transition flex items-center justify-center"
title="语音通话"
@click="startCall('audio')"
>
<i class="fas fa-phone"></i>
</button>
<button
class="w-9 h-9 rounded-lg hover:bg-white/10 hover:text-white transition flex items-center justify-center"
title="视频通话"
@click="startCall('video')"
>
<i class="fas fa-video"></i>
</button>
<button
class="w-9 h-9 rounded-lg hover:bg-white/10 hover:text-white transition flex items-center justify-center"
@click.stop="showChatOptionsMenu($event, chatStore.currentTarget)"
>
<i class="fas fa-ellipsis-v"></i>
</button>
</div>
</div>
<!-- 消息列表 -->
<MessageList
:messages="currentMessages"
:current-user="authStore.user!"
:target="chatStore.currentTarget"
/>
<!-- 输入区 -->
<MessageInput
v-model="inputText"
:is-recording="isRecording"
@send="handleSendText"
@file="handleFileSelect"
@record-start="handleRecordStart"
@record-stop="handleRecordStop"
@record-cancel="handleRecordCancel"
/>
</div>
<!-- 空状态 -->
<div
v-else-if="currentTab === 'chat'"
class="flex-1 flex flex-col items-center justify-center bg-dark text-gray-500 hidden md:flex"
>
<div class="w-24 h-24 bg-panel rounded-full flex items-center justify-center mb-6 shadow-xl border border-gray-800">
<i class="fas fa-comments text-4xl text-primary opacity-80"></i>
</div>
<p class="text-xl font-medium text-gray-300">NL-IM</p>
<p class="text-sm mt-2 opacity-60">安全 · 极速 · 沉浸式体验</p>
</div>
<!-- 联系人页面 -->
<ContactView v-if="currentTab === 'contact'" />
<!-- 右键菜单 -->
<ContextMenu />
<!-- 用户资料模态框 -->
<div
v-if="showProfileModal && authStore.user"
class="fixed inset-0 bg-black/80 z-[60] flex items-center justify-center p-4 backdrop-blur-sm"
@click.self="showProfileModal = false"
>
<div class="bg-panel rounded-2xl w-80 shadow-2xl border border-gray-700 overflow-hidden">
<div class="h-24 bg-gradient-to-r from-indigo-600 to-purple-600"></div>
<div class="px-6 pb-6 text-center -mt-12">
<Avatar
:name="authStore.user.name"
:avatar="authStore.user.avatar"
size="xl"
class="mx-auto border-4 border-panel shadow-xl"
/>
<h2 class="text-xl font-bold mt-3 text-white">{{ authStore.user.name }}</h2>
<p class="text-sm text-gray-400 mt-1">{{ authStore.user.desc || '暂无签名' }}</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useChatStore } from '@/stores/chat'
import { useContextMenu } from '@/composables/useContextMenu'
import { useContextMenuStore } from '@/stores/contextMenu'
import { wsManager } from '@/api/websocket'
import * as contactApi from '@/api/modules/contact'
import * as messageApi from '@/api/modules/message'
import { formatTime } from '@/utils/format'
import type { Contact, ChatMessage } from '@/types/api'
import Avatar from '@/components/common/Avatar.vue'
import ContextMenu from '@/components/common/ContextMenu.vue'
import MessageList from '@/components/chat/MessageList.vue'
import MessageInput from '@/components/chat/MessageInput.vue'
import ContactView from '@/views/contact/ContactView.vue'
const router = useRouter()
const authStore = useAuthStore()
const chatStore = useChatStore()
const contextMenuStore = useContextMenuStore()
const { showContextMenu } = useContextMenu()
const currentTab = ref<'chat' | 'contact'>('chat')
const searchQuery = ref('')
const inputText = ref('')
const chatVisible = ref(false)
const isMobile = ref(window.innerWidth < 768)
const showProfileModal = ref(false)
const isRecording = ref(false)
const filteredContacts = computed(() => {
const query = searchQuery.value.toLowerCase()
return chatStore.contacts.filter(contact => {
const name = (contact.remark_name || contact.user?.name || '').toLowerCase()
return name.includes(query)
})
})
const currentMessages = computed(() => {
if (!chatStore.currentTarget) return []
const roomId = getRoomId(chatStore.currentTarget)
return chatStore.getRoomMessages(roomId)
})
function getRoomId(contact: Contact): string {
const userIds = [authStore.user!.id, contact.user_id || contact.id].sort()
return userIds.join('_')
}
async function selectChat(contact: Contact) {
chatStore.setCurrentTarget(contact)
chatVisible.value = true
const roomId = getRoomId(contact)
// 加载历史消息
if (chatStore.getRoomMessages(roomId).length === 0) {
try {
const response = await messageApi.getMessages(roomId, 1, 50)
const msgs = response.data.map((msg: ChatMessage) => ({
...msg,
isSelf: msg.sender_user_id === authStore.user!.id,
extra: typeof msg.extra === 'string' ? JSON.parse(msg.extra || '{}') : msg.extra,
}))
chatStore.setRoomMessages(roomId, msgs.reverse())
} catch (error) {
console.error('Failed to load messages:', error)
}
}
// 滚动到底部
setTimeout(() => {
const container = document.getElementById('msgListContainer')
if (container) {
container.scrollTop = container.scrollHeight
}
}, 100)
}
function backToList() {
if (isMobile.value) {
chatVisible.value = false
}
}
function handleLogout() {
authStore.logout()
wsManager.disconnect()
router.push('/login')
}
function handleSendText() {
if (!inputText.value.trim() || !chatStore.currentTarget) return
sendMessage(0, inputText.value)
inputText.value = ''
}
function handleFileSelect(type: number, file: File) {
// TODO: 实现文件上传和发送
console.log('File selected:', type, file)
}
function handleRecordStart() {
isRecording.value = true
}
function handleRecordStop(blob: Blob, duration: number) {
isRecording.value = false
// TODO: 实现语音发送
console.log('Record stopped:', blob, duration)
}
function handleRecordCancel() {
isRecording.value = false
}
async function sendMessage(type: number, content: string, extra: any = {}, duration = 0) {
if (!chatStore.currentTarget) return
const roomId = getRoomId(chatStore.currentTarget)
const clientId = wsManager.getClientId()
const payload = {
sender_client_id: clientId || '',
receiver_user_id: chatStore.currentTarget.user_id || chatStore.currentTarget.id,
room_id: roomId,
message_type: type,
content,
duration,
extra: JSON.stringify(extra),
}
try {
await messageApi.sendMessage(payload)
// 本地添加消息
const message: ChatMessage = {
id: Date.now(),
room_id: roomId,
sender_user_id: authStore.user!.id,
receiver_user_id: chatStore.currentTarget.user_id || chatStore.currentTarget.id,
message_type: type,
content,
duration,
extra,
created_at: new Date().toISOString(),
isSelf: true,
}
chatStore.addMessage(roomId, message)
chatStore.updateContactLastMsg(
chatStore.currentTarget.id,
getMsgSummary(message),
Date.now()
)
} catch (error) {
console.error('Failed to send message:', error)
}
}
function getMsgSummary(msg: ChatMessage): string {
const types: Record<number, string> = {
1: '[图片]',
2: '[语音]',
3: '[视频]',
8: '[文件]',
}
return types[msg.message_type] || msg.content
}
function startCall(type: 'audio' | 'video') {
// TODO: 实现WebRTC通话
console.log('Start call:', type)
}
function showContactMenu(event: MouseEvent, contact: Contact) {
showContextMenu(event, [
{
label: '置顶会话',
icon: 'fas fa-thumbtack',
action: () => {
// TODO: 实现置顶
},
},
{
label: '标记已读',
icon: 'fas fa-check-double',
action: () => {
contact.unread = 0
},
},
{
label: '删除会话',
icon: 'fas fa-trash',
danger: true,
action: () => {
// TODO: 实现删除会话
},
},
])
}
function showChatOptionsMenu(event: MouseEvent, contact: Contact) {
showContextMenu(event, [
{
label: '查看资料',
icon: 'fas fa-user',
action: () => {
// TODO: 显示用户资料
},
},
{
label: '清空记录',
icon: 'fas fa-eraser',
danger: true,
action: () => {
const roomId = getRoomId(contact)
chatStore.clearRoomMessages(roomId)
},
},
])
}
// WebSocket消息处理
function handleWebSocketMessage(message: ChatMessage) {
const roomId = message.room_id
message.isSelf = message.sender_user_id === authStore.user!.id
message.extra = typeof message.extra === 'string' ? JSON.parse(message.extra || '{}') : message.extra
chatStore.addMessage(roomId, message)
// 更新联系人最后消息
const contact = chatStore.contacts.find(c =>
c.user_id === message.sender_user_id || c.id === message.sender_user_id
)
if (contact) {
chatStore.updateContactLastMsg(contact.id, getMsgSummary(message), Date.now())
if (!message.isSelf) {
chatStore.incrementUnread(contact.id)
}
}
// 如果当前正在查看该聊天,滚动到底部
if (chatStore.currentTarget && roomId === getRoomId(chatStore.currentTarget)) {
setTimeout(() => {
const container = document.getElementById('msgListContainer')
if (container) {
container.scrollTop = container.scrollHeight
}
}, 100)
}
}
// 加载联系人列表
async function loadContacts() {
try {
const contacts = await contactApi.getContacts()
chatStore.setContacts(contacts)
} catch (error) {
console.error('Failed to load contacts:', error)
}
}
onMounted(async () => {
// 检查认证
if (!authStore.isAuthenticated) {
const isValid = await authStore.checkAuth()
if (!isValid) {
router.push('/login')
return
}
}
// 连接WebSocket
if (authStore.user) {
await wsManager.connect(authStore.user.id)
wsManager.onMessage(handleWebSocketMessage)
}
// 加载联系人
await loadContacts()
// 响应式处理
window.addEventListener('resize', () => {
isMobile.value = window.innerWidth < 768
})
})
onUnmounted(() => {
wsManager.offMessage(handleWebSocketMessage)
})
</script>

View File

@@ -0,0 +1,164 @@
<template>
<div class="flex-1 flex flex-col bg-panel h-full">
<div class="p-4 border-b border-gray-800">
<h2 class="text-xl font-bold text-white">联系人</h2>
</div>
<div class="flex-1 overflow-y-auto p-4">
<div class="space-y-4">
<!-- 搜索用户 -->
<div>
<div class="relative mb-4">
<i class="fas fa-search absolute left-3 top-2.5 text-gray-500"></i>
<input
v-model="searchKeyword"
type="text"
class="w-full bg-input rounded-lg py-2 pl-10 pr-4 text-sm text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500"
placeholder="搜索用户ID、邮箱或手机号..."
@keyup.enter="handleSearch"
/>
</div>
<button
class="w-full py-2 bg-primary hover:bg-indigo-500 text-white rounded-lg transition"
@click="handleSearch"
>
搜索
</button>
</div>
<!-- 搜索结果 -->
<div v-if="searchResults.length > 0" class="space-y-2">
<h3 class="text-sm font-semibold text-gray-400">搜索结果</h3>
<div
v-for="user in searchResults"
:key="user.id"
class="flex items-center p-3 bg-input rounded-lg hover:bg-white/5 transition"
>
<Avatar
:name="user.name"
:avatar="user.avatar"
size="md"
class="mr-3"
/>
<div class="flex-1">
<div class="font-semibold text-white">{{ user.name }}</div>
<div class="text-xs text-gray-400">{{ user.email || user.phone }}</div>
</div>
<button
class="px-4 py-2 bg-primary hover:bg-indigo-500 text-white rounded-lg transition text-sm"
@click="handleAddFriend(user)"
>
添加好友
</button>
</div>
</div>
<!-- 好友申请 -->
<div v-if="friendRequests.length > 0" class="mt-6">
<h3 class="text-sm font-semibold text-gray-400 mb-2">好友申请</h3>
<div
v-for="request in friendRequests"
:key="request.id"
class="flex items-center p-3 bg-input rounded-lg hover:bg-white/5 transition mb-2"
>
<Avatar
:name="request.from_user?.name"
:avatar="request.from_user?.avatar"
size="md"
class="mr-3"
/>
<div class="flex-1">
<div class="font-semibold text-white">{{ request.from_user?.name }}</div>
<div class="text-xs text-gray-400">{{ request.message || '请求添加你为好友' }}</div>
</div>
<div class="flex gap-2">
<button
class="px-3 py-1 bg-success hover:bg-green-600 text-white rounded transition text-sm"
@click="handleAcceptRequest(request.id)"
>
接受
</button>
<button
class="px-3 py-1 bg-danger hover:bg-red-600 text-white rounded transition text-sm"
@click="handleRejectRequest(request.id)"
>
拒绝
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useContactStore } from '@/stores/contact'
import * as contactApi from '@/api/modules/contact'
import Avatar from '@/components/common/Avatar.vue'
import type { User } from '@/types/api'
const contactStore = useContactStore()
const searchKeyword = ref('')
const searchResults = ref<User[]>([])
const friendRequests = ref(contactStore.friendRequests)
async function handleSearch() {
if (!searchKeyword.value.trim()) return
try {
const results = await contactApi.searchUsers(searchKeyword.value)
searchResults.value = results
} catch (error) {
console.error('Search failed:', error)
alert('搜索失败')
}
}
async function handleAddFriend(user: User) {
try {
await contactApi.addFriend({
to_user_id: user.id,
message: '你好,我想加你为好友',
})
alert('好友申请已发送')
} catch (error: any) {
alert(error.message || '添加失败')
}
}
async function handleAcceptRequest(requestId: number) {
try {
await contactApi.acceptFriendRequest(requestId)
contactStore.removeFriendRequest(requestId)
friendRequests.value = contactStore.friendRequests
alert('已接受好友申请')
} catch (error: any) {
alert(error.message || '操作失败')
}
}
async function handleRejectRequest(requestId: number) {
try {
await contactApi.rejectFriendRequest(requestId)
contactStore.removeFriendRequest(requestId)
friendRequests.value = contactStore.friendRequests
alert('已拒绝好友申请')
} catch (error: any) {
alert(error.message || '操作失败')
}
}
onMounted(async () => {
try {
const requests = await contactApi.getFriendRequests()
contactStore.setFriendRequests(requests)
friendRequests.value = requests
} catch (error) {
console.error('Failed to load friend requests:', error)
}
})
</script>

View File

@@ -0,0 +1,229 @@
<template>
<div class="fixed inset-0 bg-black/90 z-[100] flex items-center justify-center p-4 backdrop-blur-md">
<div class="bg-panel p-8 rounded-2xl w-full max-w-2xl border border-gray-700 shadow-2xl">
<h1 class="text-3xl font-light mb-2 text-center text-white">
NL-IM <span class="text-primary font-bold">V2</span>
</h1>
<p class="text-gray-400 text-center mb-10">登录即时通讯系统</p>
<div class="space-y-4">
<!-- 登录表单 -->
<div v-if="!showRegister" class="space-y-4">
<div>
<input
v-model="loginForm.account"
type="text"
class="w-full bg-input rounded-lg py-3 px-4 text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500"
placeholder="邮箱或手机号"
/>
</div>
<div>
<input
v-model="loginForm.password"
type="password"
class="w-full bg-input rounded-lg py-3 px-4 text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500"
placeholder="密码"
@keyup.enter="handleLogin"
/>
</div>
<div class="flex items-center justify-between">
<label class="flex items-center gap-2 text-gray-400 text-sm cursor-pointer">
<input
v-model="loginForm.remember"
type="checkbox"
class="w-4 h-4 rounded border-gray-600 bg-input text-primary focus:ring-primary"
/>
<span>记住我</span>
</label>
<button
class="text-primary text-sm hover:underline"
@click="showRegister = true"
>
注册账号
</button>
</div>
<button
class="w-full py-3 bg-primary hover:bg-indigo-500 text-white font-medium rounded-lg transition shadow-lg shadow-indigo-500/30 active:scale-95"
:disabled="loading"
@click="handleLogin"
>
{{ loading ? '登录中...' : '登录' }}
</button>
</div>
<!-- 注册表单 -->
<div v-else class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<input
v-model="registerForm.email"
type="email"
class="w-full bg-input rounded-lg py-3 px-4 text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500"
placeholder="邮箱"
/>
</div>
<div>
<input
v-model="registerForm.phone"
type="tel"
class="w-full bg-input rounded-lg py-3 px-4 text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500"
placeholder="手机号"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<input
v-model="registerForm.password"
type="password"
class="w-full bg-input rounded-lg py-3 px-4 text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500"
placeholder="密码"
/>
</div>
<div>
<input
v-model="registerForm.confirmPassword"
type="password"
class="w-full bg-input rounded-lg py-3 px-4 text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500"
placeholder="确认密码"
/>
</div>
</div>
<div class="flex gap-2">
<input
v-model="registerForm.code"
type="text"
class="flex-1 bg-input rounded-lg py-3 px-4 text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500"
placeholder="验证码"
/>
<button
class="px-6 py-3 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition"
:disabled="codeLoading"
@click="handleSendCode"
>
{{ codeLoading ? '发送中...' : '发送验证码' }}
</button>
</div>
<label class="flex items-center gap-2 text-gray-400 text-sm cursor-pointer">
<input
v-model="registerForm.agreeTerms"
type="checkbox"
class="w-4 h-4 rounded border-gray-600 bg-input text-primary focus:ring-primary"
/>
<span>同意服务条款</span>
</label>
<div class="flex gap-4">
<button
class="flex-1 py-3 bg-gray-700 hover:bg-gray-600 text-white font-medium rounded-lg transition"
@click="showRegister = false"
>
返回登录
</button>
<button
class="flex-1 py-3 bg-primary hover:bg-indigo-500 text-white font-medium rounded-lg transition shadow-lg shadow-indigo-500/30 active:scale-95"
:disabled="loading"
@click="handleRegister"
>
{{ loading ? '注册中...' : '注册' }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import * as authApi from '@/api/modules/auth'
const router = useRouter()
const authStore = useAuthStore()
const showRegister = ref(false)
const loading = ref(false)
const codeLoading = ref(false)
const loginForm = ref({
account: '',
password: '',
remember: false,
})
const registerForm = ref({
email: '',
phone: '',
password: '',
confirmPassword: '',
code: '',
agreeTerms: false,
})
async function handleLogin() {
if (!loginForm.value.account || !loginForm.value.password) {
alert('请填写账号和密码')
return
}
loading.value = true
try {
await authStore.login(loginForm.value)
router.push('/chat')
} catch (error: any) {
alert(error.message || '登录失败')
} finally {
loading.value = false
}
}
async function handleRegister() {
if (!registerForm.value.email || !registerForm.value.phone || !registerForm.value.password) {
alert('请填写完整信息')
return
}
if (registerForm.value.password !== registerForm.value.confirmPassword) {
alert('两次密码不一致')
return
}
if (!registerForm.value.agreeTerms) {
alert('请同意服务条款')
return
}
loading.value = true
try {
await authStore.register(registerForm.value)
alert('注册成功,请登录')
showRegister.value = false
loginForm.value.account = registerForm.value.email
} catch (error: any) {
alert(error.message || '注册失败')
} finally {
loading.value = false
}
}
async function handleSendCode() {
const target = registerForm.value.email || registerForm.value.phone
if (!target) {
alert('请先填写邮箱或手机号')
return
}
codeLoading.value = true
try {
const type = registerForm.value.email ? 'email' : 'sms'
await authApi.sendEmailCode({ target, type })
alert('验证码已发送')
} catch (error: any) {
alert(error.message || '发送失败')
} finally {
codeLoading.value = false
}
}
</script>

8
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

34
tailwind.config.js Normal file
View File

@@ -0,0 +1,34 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {
colors: {
primary: '#6366f1', // Indigo
dark: '#0f172a', // Slate 900
panel: '#1e293b', // Slate 800
input: '#334155', // Slate 700
'msg-self': '#4f46e5',
'msg-other': '#334155',
danger: '#ef4444',
success: '#22c55e'
},
animation: {
'ripple': 'ripple 2s cubic-bezier(0, 0.2, 0.8, 1) infinite',
},
keyframes: {
ripple: {
'0%': { boxShadow: '0 0 0 0 rgba(99, 102, 241, 0.3)' },
'70%': { boxShadow: '0 0 0 20px rgba(99, 102, 241, 0)' },
'100%': { boxShadow: '0 0 0 0 rgba(99, 102, 241, 0)' },
}
}
},
},
plugins: [],
}

32
tsconfig.json Normal file
View File

@@ -0,0 +1,32 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Path alias */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

11
tsconfig.node.json Normal file
View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

28
vite.config.ts Normal file
View File

@@ -0,0 +1,28 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:12080',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:12080',
ws: true,
changeOrigin: true,
},
},
},
})