初始化项目结构
This commit is contained in:
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal 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?
|
||||||
|
|
||||||
87
PROJECT_STATUS.md
Normal file
87
PROJECT_STATUS.md
Normal 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
74
README.md
Normal 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
15
index.html
Normal 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
28
package.json
Normal 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
2121
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
7
postcss.config.js
Normal file
7
postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
2
public/vite.svg
Normal file
2
public/vite.svg
Normal 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
8
src/App.vue
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// App root component
|
||||||
|
</script>
|
||||||
|
|
||||||
37
src/api/modules/attachment.ts
Normal file
37
src/api/modules/attachment.ts
Normal 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
32
src/api/modules/auth.ts
Normal 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')
|
||||||
|
}
|
||||||
|
|
||||||
79
src/api/modules/contact.ts
Normal file
79
src/api/modules/contact.ts
Normal 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}`)
|
||||||
|
}
|
||||||
|
|
||||||
26
src/api/modules/message.ts
Normal file
26
src/api/modules/message.ts
Normal 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
22
src/api/modules/room.ts
Normal 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
31
src/api/modules/system.ts
Normal 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
34
src/api/modules/user.ts
Normal 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
58
src/api/request/index.ts
Normal 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
24
src/api/request/types.ts
Normal 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
178
src/api/websocket/index.ts
Normal 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
106
src/assets/styles/main.scss
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
35
src/components/chat/MessageBubble.vue
Normal file
35
src/components/chat/MessageBubble.vue
Normal 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>
|
||||||
|
|
||||||
181
src/components/chat/MessageInput.vue
Normal file
181
src/components/chat/MessageInput.vue
Normal 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>
|
||||||
|
|
||||||
61
src/components/chat/MessageList.vue
Normal file
61
src/components/chat/MessageList.vue
Normal 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>
|
||||||
|
|
||||||
38
src/components/chat/bubble/Audio.vue
Normal file
38
src/components/chat/bubble/Audio.vue
Normal 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>
|
||||||
|
|
||||||
48
src/components/chat/bubble/File.vue
Normal file
48
src/components/chat/bubble/File.vue
Normal 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>
|
||||||
|
|
||||||
29
src/components/chat/bubble/Image.vue
Normal file
29
src/components/chat/bubble/Image.vue
Normal 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>
|
||||||
|
|
||||||
47
src/components/chat/bubble/Text.vue
Normal file
47
src/components/chat/bubble/Text.vue
Normal 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>
|
||||||
|
|
||||||
24
src/components/chat/bubble/Video.vue
Normal file
24
src/components/chat/bubble/Video.vue
Normal 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>
|
||||||
|
|
||||||
41
src/components/common/Avatar.vue
Normal file
41
src/components/common/Avatar.vue
Normal 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>
|
||||||
|
|
||||||
70
src/components/common/ContextMenu.vue
Normal file
70
src/components/common/ContextMenu.vue
Normal 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>
|
||||||
|
|
||||||
47
src/composables/useContextMenu.ts
Normal file
47
src/composables/useContextMenu.ts
Normal 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
13
src/main.ts
Normal 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
47
src/router/index.ts
Normal 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
101
src/stores/auth.ts
Normal 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
102
src/stores/chat.ts
Normal 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
79
src/stores/contact.ts
Normal 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
51
src/stores/contextMenu.ts
Normal 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
143
src/types/api.ts
Normal 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
35
src/types/message.ts
Normal 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
20
src/types/user.ts
Normal 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
77
src/utils/format.ts
Normal 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 ' '
|
||||||
|
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
68
src/utils/storage.ts
Normal 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
502
src/views/chat/ChatView.vue
Normal 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>
|
||||||
|
|
||||||
164
src/views/contact/ContactView.vue
Normal file
164
src/views/contact/ContactView.vue
Normal 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>
|
||||||
|
|
||||||
229
src/views/login/LoginView.vue
Normal file
229
src/views/login/LoginView.vue
Normal 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
8
src/vite-env.d.ts
vendored
Normal 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
34
tailwind.config.js
Normal 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
32
tsconfig.json
Normal 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
11
tsconfig.node.json
Normal 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
28
vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
Reference in New Issue
Block a user