Compare commits
9 Commits
a4bed485a5
...
2abc7edab2
| Author | SHA1 | Date | |
|---|---|---|---|
| 2abc7edab2 | |||
| 5dbaff904c | |||
| 49f54a2168 | |||
| 122971200f | |||
| 970aae1c5f | |||
| 08ae7414d0 | |||
| 6f205d2408 | |||
| b40691a4a3 | |||
| 27c4a247d3 |
4
.env_template
Normal file
4
.env_template
Normal file
@@ -0,0 +1,4 @@
|
||||
STUN_URL=''
|
||||
TURN_URL=''
|
||||
TURN_USERNAME=''
|
||||
TURN_CREDENTIAL=''
|
||||
35
.gitea/workflows/main.yaml
Normal file
35
.gitea/workflows/main.yaml
Normal file
@@ -0,0 +1,35 @@
|
||||
name: Docker Image CI
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: gitea-runner-group-myplayer
|
||||
container:
|
||||
image: ${{ vars.HARBOR_URL }}/candlelight/action_builder:v0.0.1
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: docker login
|
||||
env:
|
||||
HARBOR_USERNAME: ${{ secrets.HARBOR_ROBOT }}
|
||||
HARBOR_PASSWORD: ${{ secrets.HARBOR_ROBOT_SECRET }}
|
||||
HARBOR_URL: ${{ vars.HARBOR_URL }}
|
||||
run: docker login ${HARBOR_URL} -u ${HARBOR_USERNAME} -p ${HARBOR_PASSWORD}
|
||||
- name: Build and push Docker images
|
||||
env:
|
||||
HARBOR_URL: ${{ vars.HARBOR_URL }}
|
||||
TAG: ${{ github.sha }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
ROOT_DIR=$(pwd)
|
||||
IMAGE_NAME="${HARBOR_URL}/testing/$REPOSITORY:${TAG}"
|
||||
echo "Building image: ${IMAGE_NAME}"
|
||||
docker build -t ${IMAGE_NAME} .
|
||||
echo "Pushing image: ${IMAGE_NAME}"
|
||||
docker push ${IMAGE_NAME}
|
||||
echo "Successfully pushed: ${IMAGE_NAME}"
|
||||
docker rmi ${IMAGE_NAME}
|
||||
echo "cleaned up local image"
|
||||
34
.gitea/workflows/tag.yaml
Normal file
34
.gitea/workflows/tag.yaml
Normal file
@@ -0,0 +1,34 @@
|
||||
name: Docker Image CI
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: gitea-runner-group-myplayer
|
||||
container:
|
||||
image: ${{ vars.HARBOR_URL }}/candlelight/action_builder:v0.0.1
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: docker login
|
||||
env:
|
||||
HARBOR_USERNAME: ${{ secrets.HARBOR_ROBOT }}
|
||||
HARBOR_PASSWORD: ${{ secrets.HARBOR_ROBOT_SECRET }}
|
||||
HARBOR_URL: ${{ vars.HARBOR_URL }}
|
||||
run: docker login ${HARBOR_URL} -u ${HARBOR_USERNAME} -p ${HARBOR_PASSWORD}
|
||||
- name: Build and push Docker images
|
||||
env:
|
||||
HARBOR_URL: ${{ vars.HARBOR_URL }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
ROOT_DIR=$(pwd)
|
||||
IMAGE_NAME="${HARBOR_URL}/$REPOSITORY:$GITHUB_REF_NAME"
|
||||
echo "Building image: ${IMAGE_NAME}"
|
||||
docker build -t ${IMAGE_NAME} .
|
||||
echo "Pushing image: ${IMAGE_NAME}"
|
||||
docker push ${IMAGE_NAME}
|
||||
echo "Successfully pushed: ${IMAGE_NAME}"
|
||||
docker rmi ${IMAGE_NAME}
|
||||
echo "cleaned up local image"
|
||||
64
.gitignore
vendored
64
.gitignore
vendored
@@ -1,30 +1,34 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
|
||||
# dev environment variables
|
||||
.env
|
||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
||||
FROM registry.merlin.xin/library/node:20-bullseye AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm run build
|
||||
|
||||
|
||||
FROM registry.merlin.xin/mirrors/nginxinc/nginx-unprivileged:stable
|
||||
|
||||
COPY --from=build /app/dist /app/dist
|
||||
9942
package-lock.json
generated
9942
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
71
package.json
71
package.json
@@ -1,33 +1,38 @@
|
||||
{
|
||||
"name": "myplayer_vue",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.12.0",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dplayer": "^1.27.0",
|
||||
"element-plus": "^2.9.4",
|
||||
"express": "^4.21.2",
|
||||
"hls.js": "^1.5.20",
|
||||
"idb": "^8.0.2",
|
||||
"node-fetch": "^3.3.2",
|
||||
"pinia": "^3.0.1",
|
||||
"pinia-plugin-persistedstate": "^4.2.0",
|
||||
"video.js": "^8.21.0",
|
||||
"videojs-vtt.js": "^0.15.5",
|
||||
"vue": "^3.5.13",
|
||||
"vue-axios": "^3.5.2",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"vite": "^6.0.11",
|
||||
"vite-plugin-vue-devtools": "^7.7.1"
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "myplayer_vue",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"proxy": "node proxy.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.13.6",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"dplayer": "^1.27.1",
|
||||
"element-plus": "^2.9.4",
|
||||
"express": "^4.21.2",
|
||||
"hls.js": "^1.5.20",
|
||||
"idb": "^8.0.2",
|
||||
"node-fetch": "^3.3.2",
|
||||
"pinia": "^3.0.1",
|
||||
"pinia-plugin-persistedstate": "^4.2.0",
|
||||
"video.js": "^8.21.0",
|
||||
"videojs-vtt.js": "^0.15.5",
|
||||
"vue": "^3.5.13",
|
||||
"vue-axios": "^3.5.2",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"vite": "^6.0.11",
|
||||
"vite-plugin-vue-devtools": "^7.7.1"
|
||||
},
|
||||
"overrides": {
|
||||
"axios": "^1.13.6"
|
||||
}
|
||||
}
|
||||
|
||||
186
proxy.js
186
proxy.js
@@ -1,62 +1,124 @@
|
||||
import express from 'express';
|
||||
import fetch from 'node-fetch';
|
||||
import { URL } from 'url';
|
||||
|
||||
const app = express();
|
||||
const port = 3000;
|
||||
|
||||
// 允许跨域请求
|
||||
app.use((req, res, next) => {
|
||||
res.header('Access-Control-Allow-Origin', '*');
|
||||
next();
|
||||
});
|
||||
|
||||
// 通用代理路由
|
||||
app.get('/proxy', async (req, res) => {
|
||||
console.log('Received Range header:', req.headers.range);
|
||||
// 获取目标 URL
|
||||
const targetUrl = req.query.url;
|
||||
console.log('Fetching data from:', targetUrl);
|
||||
if (!targetUrl) {
|
||||
return res.status(400).send('URL parameter is required');
|
||||
}
|
||||
|
||||
try {
|
||||
// 设置请求头
|
||||
const headers = {};
|
||||
if (req.headers.range) {
|
||||
headers['Range'] = req.headers.range; // 转发 Range 请求头
|
||||
}
|
||||
|
||||
// 向目标 URL 发起请求
|
||||
const response = await fetch(targetUrl, { headers });
|
||||
|
||||
// 设置响应头
|
||||
response.headers.forEach((value, key) => {
|
||||
if (key !== 'content-encoding') { // 避免某些头信息导致问题
|
||||
res.setHeader(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
// 特殊处理 Transfer-Encoding 和 Content-Length
|
||||
if (response.headers.get('transfer-encoding') === 'chunked') {
|
||||
res.removeHeader('Content-Length');
|
||||
}
|
||||
|
||||
// 将响应体直接流式传输给客户端
|
||||
response.body.pipe(res, { end: true });
|
||||
|
||||
// 错误处理
|
||||
response.body.on('error', (err) => {
|
||||
console.error('Error during data transfer:', err);
|
||||
res.status(500).send('Error during data transfer');
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
res.status(500).send('Error fetching data');
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Proxy server running at http://localhost:${port}`);
|
||||
});
|
||||
import express from 'express';
|
||||
import fetch from 'node-fetch';
|
||||
import { URL } from 'url';
|
||||
|
||||
const app = express();
|
||||
const port = 3000;
|
||||
|
||||
// 允许跨域请求
|
||||
app.use((req, res, next) => {
|
||||
res.header('Access-Control-Allow-Origin', '*');
|
||||
res.header('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
|
||||
res.header('Access-Control-Allow-Headers', 'Range');
|
||||
next();
|
||||
});
|
||||
|
||||
// 本地静态文件代理:/local -> C:\Users\xyf17\Merlin\data
|
||||
const localDataPath = 'C:\\Users\\xyf17\\Merlin\\data';
|
||||
app.use('/local', express.static(localDataPath));
|
||||
|
||||
// 通用代理路由
|
||||
app.get('/proxy', async (req, res) => {
|
||||
console.log('Received Range header:', req.headers.range);
|
||||
// 获取目标 URL
|
||||
const targetUrl = req.query.url;
|
||||
console.log('Fetching data from:', targetUrl);
|
||||
if (!targetUrl) {
|
||||
return res.status(400).json({ error: 'URL parameter is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
// 设置请求头
|
||||
const headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
};
|
||||
|
||||
// 避免上游压缩导致 Range/MSE 解析异常(mp4 一般不会压缩,但这里保守处理)
|
||||
headers['Accept-Encoding'] = 'identity';
|
||||
|
||||
if (req.headers.range) {
|
||||
headers['Range'] = req.headers.range; // 转发 Range 请求头,支持视频分段加载
|
||||
}
|
||||
|
||||
// 向目标 URL 发起请求
|
||||
const response = await fetch(targetUrl, { headers });
|
||||
|
||||
// 检查响应状态
|
||||
if (!response.ok) {
|
||||
return res.status(response.status).json({
|
||||
error: `Failed to fetch: ${response.statusText}`,
|
||||
status: response.status
|
||||
});
|
||||
}
|
||||
|
||||
// 关键:Range 请求上游通常返回 206;必须把状态码透传给浏览器
|
||||
res.status(response.status);
|
||||
|
||||
// 设置响应头
|
||||
response.headers.forEach((value, key) => {
|
||||
// 跳过一些可能导致问题的头信息
|
||||
if (key.toLowerCase() !== 'content-encoding' &&
|
||||
key.toLowerCase() !== 'transfer-encoding' &&
|
||||
key.toLowerCase() !== 'connection') {
|
||||
res.setHeader(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
// 特殊处理 Transfer-Encoding 和 Content-Length
|
||||
if (response.headers.get('transfer-encoding') === 'chunked') {
|
||||
res.removeHeader('Content-Length');
|
||||
}
|
||||
|
||||
// 将响应体直接流式传输给客户端
|
||||
if (response.body) {
|
||||
response.body.pipe(res, { end: true });
|
||||
|
||||
// 错误处理
|
||||
response.body.on('error', (err) => {
|
||||
console.error('Error during data transfer:', err);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Error during data transfer' });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({ error: 'No response body' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
|
||||
// 根据错误类型返回更具体的错误信息
|
||||
let errorMessage = 'Error fetching data';
|
||||
let statusCode = 500;
|
||||
let hostname = '';
|
||||
|
||||
try {
|
||||
hostname = new URL(targetUrl).hostname;
|
||||
} catch (e) {
|
||||
hostname = 'unknown';
|
||||
}
|
||||
|
||||
if (error.code === 'ENOTFOUND') {
|
||||
errorMessage = `DNS解析失败:无法解析域名 "${hostname}"。请检查URL是否正确,或网络连接是否正常。`;
|
||||
statusCode = 502;
|
||||
} else if (error.code === 'ECONNREFUSED') {
|
||||
errorMessage = `连接被拒绝:无法连接到目标服务器 "${hostname}"。`;
|
||||
statusCode = 502;
|
||||
} else if (error.code === 'ETIMEDOUT') {
|
||||
errorMessage = `请求超时:目标服务器 "${hostname}" 响应时间过长。`;
|
||||
statusCode = 504;
|
||||
}
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.status(statusCode).json({
|
||||
error: errorMessage,
|
||||
code: error.code,
|
||||
targetUrl: targetUrl
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Proxy server running at http://localhost:${port}`);
|
||||
});
|
||||
|
||||
|
||||
93
src/api/group.ts
Normal file
93
src/api/group.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import axios from "axios";
|
||||
import { userInfoStore } from "@/store/user";
|
||||
|
||||
|
||||
const userinfo = userInfoStore();
|
||||
|
||||
|
||||
export const getGroups = async ()=> {
|
||||
const response = await axios.get('/api/group/get',{
|
||||
headers:{
|
||||
'Authorization' : `Bearer ${userinfo.token}`
|
||||
}
|
||||
});
|
||||
if(response.data.code === "200"){
|
||||
return response.data.data;
|
||||
} else {
|
||||
throw new Error(response.data.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const createGroup = async (group_name: string) => {
|
||||
const response = await axios.post('/api/group/create',{
|
||||
g_name: group_name
|
||||
}
|
||||
,{
|
||||
headers:{
|
||||
'Authorization' : `Bearer ${userinfo.token}`
|
||||
}
|
||||
});
|
||||
if(response.data.code === "200"){
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(response.data.message);
|
||||
}
|
||||
}
|
||||
|
||||
export const searchGroups = async (group_name: string) => {
|
||||
const response = await axios.post('/api/group/search',{
|
||||
g_name: group_name
|
||||
},{
|
||||
headers:{
|
||||
'Authorization' : `Bearer ${userinfo.token}`
|
||||
}
|
||||
})
|
||||
if(response.data.code === "200"){
|
||||
return response.data.data.records;
|
||||
}else{
|
||||
throw new Error(response.data.message);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const joinGroup = async (group_id: number) => {
|
||||
const response = await axios.get('/api/group/join/'+group_id,{
|
||||
headers:{
|
||||
'Authorization' : `Bearer ${userinfo.token}`
|
||||
}})
|
||||
if(response.data.code === "200"){
|
||||
return true;
|
||||
}else{
|
||||
throw new Error(response.data.message);
|
||||
}
|
||||
}
|
||||
|
||||
export const leaveGroup = async (group_id: number) => {
|
||||
const response = await axios.get('/api/group/leave/'+group_id,{
|
||||
headers:{
|
||||
'Authorization' : `Bearer ${userinfo.token}`
|
||||
}})
|
||||
if(response.data.code === "200"){
|
||||
return true;
|
||||
}else{
|
||||
throw new Error(response.data.message);
|
||||
}
|
||||
}
|
||||
|
||||
export const getGroupMembers = async (group_id: number) => {
|
||||
const response = await axios.get('/api/group/member/'+group_id,{
|
||||
headers:{
|
||||
'Authorization' : `Bearer ${userinfo.token}`
|
||||
},
|
||||
params:{
|
||||
currentPage: 1,
|
||||
pageSize: 50
|
||||
}
|
||||
})
|
||||
if(response.data.code === "200"){
|
||||
return response.data.data;
|
||||
}else{
|
||||
throw new Error(response.data.message);
|
||||
}
|
||||
}
|
||||
93
src/api/playroom.ts
Normal file
93
src/api/playroom.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { userInfoStore } from "@/store/user";
|
||||
import axios from "axios";
|
||||
|
||||
const userinfo = userInfoStore();
|
||||
|
||||
|
||||
export const searchPlayRoom = async (inputValue: string) => {
|
||||
const response = await axios.post('/api/playroom/search',{
|
||||
r_name: inputValue
|
||||
},{
|
||||
headers:{
|
||||
'Authorization': "Bearer " + userinfo.token
|
||||
}
|
||||
})
|
||||
if(response.data.code === "200"){
|
||||
return response.data.data.records
|
||||
} else {
|
||||
console.error(response.data.msg)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export const createPlayroom = async (room_name: string) => {
|
||||
const response = await axios.post('/api/playroom/create',{
|
||||
r_name: room_name
|
||||
},{
|
||||
headers:{
|
||||
'Authorization': "Bearer " + userinfo.token
|
||||
}
|
||||
})
|
||||
if(response.data.code === "200"){
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(response.data.message);
|
||||
}
|
||||
}
|
||||
|
||||
export const getPlayrooms = async ()=> {
|
||||
const response = await axios.get('/api/playroom/get',{
|
||||
headers:{
|
||||
'Authorization' : `Bearer ${userinfo.token}`
|
||||
}
|
||||
})
|
||||
if(response.data.code === "200"){
|
||||
return response.data.data;
|
||||
} else {
|
||||
throw new Error(response.data.message);
|
||||
}
|
||||
}
|
||||
|
||||
export const joinPlayroom = async (r_id: number) => {
|
||||
const response = await axios.post('/api/inviting/playroom',{
|
||||
inviter: userinfo.user.id,
|
||||
target: userinfo.user.id,
|
||||
status: 0,
|
||||
room: r_id,
|
||||
},{
|
||||
headers:{
|
||||
'Authorization': "Bearer " + userinfo.token
|
||||
}
|
||||
})
|
||||
if(response.data.code === "200"){
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(response.data.message);
|
||||
}
|
||||
}
|
||||
|
||||
export const getPlayroomDetails = async (r_id:number) =>{
|
||||
const response = await axios.get('/api/playroom/detail/'+r_id,{
|
||||
headers:{
|
||||
'Authorization': "Bearer " + userinfo.token
|
||||
}
|
||||
})
|
||||
if(response.data.code === "200"){
|
||||
return response.data.data;
|
||||
} else {
|
||||
throw new Error(response.data.message);
|
||||
}
|
||||
}
|
||||
|
||||
export const getPlayroomMembers = async (r_id: number) => {
|
||||
const response = await axios.get('/api/playroom/member/'+r_id,{
|
||||
headers:{
|
||||
'Authorization': "Bearer " + userinfo.token
|
||||
}
|
||||
})
|
||||
if(response.data.code === "200"){
|
||||
return response.data.data;
|
||||
} else {
|
||||
throw new Error(response.data.message);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { userInfoStore } from "@/store/user";
|
||||
import axios from "axios";
|
||||
|
||||
const userinfo = userInfoStore();
|
||||
|
||||
|
||||
export const searchPlayRoom = async (inputValue: string) => {
|
||||
const response = await axios.post('/api/playroom/search',{
|
||||
r_name: inputValue
|
||||
},{
|
||||
headers:{
|
||||
'Authorization': "Bearer " + userinfo.token
|
||||
}
|
||||
})
|
||||
if(response.data.code === "200"){
|
||||
return response.data.data
|
||||
} else {
|
||||
console.error(response.data.msg)
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,37 @@
|
||||
@import './base.css';
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
/* margin: 0 auto; */
|
||||
/* padding: 2rem; */
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
a,
|
||||
.green {
|
||||
text-decoration: none;
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
transition: 0.4s;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
a:hover {
|
||||
/* background-color: hsla(160, 100%, 37%, 0.2); */
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
body {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: grid;
|
||||
/* grid-template-columns: 1fr 1fr;
|
||||
padding: 0 2rem; */
|
||||
}
|
||||
}
|
||||
@import './base.css';
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/* margin: 0 auto; */
|
||||
/* padding: 2rem; */
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
a,
|
||||
.green {
|
||||
text-decoration: none;
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
transition: 0.4s;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
a:hover {
|
||||
/* background-color: hsla(160, 100%, 37%, 0.2); */
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
body {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: grid;
|
||||
/* grid-template-columns: 1fr 1fr;
|
||||
padding: 0 2rem; */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,162 +1,159 @@
|
||||
<template>
|
||||
<nav class="navbar">
|
||||
<div class="navbar-left">
|
||||
<router-link to="/home" class="navbar-logo">
|
||||
<img :src="logo" alt="MyPlayer" />
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="navbar-right">
|
||||
<router-link to="/home/search">搜索</router-link>
|
||||
<router-link to="/home/friends">好友</router-link>
|
||||
<router-link to="/home/group">群聊</router-link>
|
||||
<router-link to="/home/playroom">Playroom</router-link>
|
||||
<div class="more-dropdown" @click="toggleDropdown" @blur="closeDropdown">
|
||||
更多
|
||||
<ul v-if="isDropdownOpen" class="dropdown-content">
|
||||
<li>设置</li>
|
||||
<li @click="aboutUs">关于我们</li>
|
||||
<li>帮助</li>
|
||||
<li @click="logout">退出登录</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, initCustomFormatter } from 'vue';
|
||||
import logo from '../assets/logo.png';
|
||||
import { onlineSocketStore } from '@/store/Online';
|
||||
import router from '@/router';
|
||||
import { voiceStore } from '@/store/Voice';
|
||||
import { groupMessageStore } from '@/store/message.ts';
|
||||
import { userInfoStore } from '@/store/user';
|
||||
|
||||
const userInfo = userInfoStore()
|
||||
const voice = voiceStore()
|
||||
const socket = onlineSocketStore()
|
||||
const groupMessage = groupMessageStore()
|
||||
|
||||
|
||||
const isDropdownOpen = ref(false); // 控制下拉框的显示与隐藏
|
||||
|
||||
const toggleDropdown = () => {
|
||||
isDropdownOpen.value = !isDropdownOpen.value; // 切换下拉框的显示状态
|
||||
};
|
||||
|
||||
const closeDropdown = () => {
|
||||
isDropdownOpen.value = false; // 关闭下拉框
|
||||
};
|
||||
|
||||
// 监听全局点击事件
|
||||
const handleGlobalClick = (event) => {
|
||||
// 检查点击是否发生在下拉框外部
|
||||
if (!event.target.closest('.more-dropdown')) {
|
||||
closeDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleGlobalClick);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleGlobalClick);
|
||||
});
|
||||
|
||||
|
||||
//退出登录逻辑
|
||||
const logout = () => {
|
||||
//保存群聊消息
|
||||
groupMessage.saveMessagesHistory(socket.u_id,groupMessage.g_id)
|
||||
// 清除用户全局信息
|
||||
userInfo.clearUserInfo();
|
||||
localStorage.clear();
|
||||
// 断开websocket链接
|
||||
voice.disconnect();
|
||||
socket.disconnect();
|
||||
// 跳转到登录页面
|
||||
window.location.href = '/';
|
||||
|
||||
};
|
||||
|
||||
const aboutUs = () => {
|
||||
router.push('/home/about')
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.navbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
padding: 10px 20px;
|
||||
background-color: #60605e;
|
||||
color: #fff;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.navbar-left,
|
||||
.navbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 10%;
|
||||
margin-left: 10%;
|
||||
}
|
||||
|
||||
.navbar-logo img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.navbar a {
|
||||
color: #fff;
|
||||
margin: 0 10px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.navbar a.router-link-active {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.more-dropdown {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dropdown-content {
|
||||
position: absolute;
|
||||
width: 100px;
|
||||
margin-top: 22px;
|
||||
right: -50px;
|
||||
color: #fff;
|
||||
background-color: #444444;
|
||||
border: 1px solid #000000;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||
padding: 5px;
|
||||
z-index: 1000;
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
.dropdown-content a {
|
||||
display: block;
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.dropdown-content li {
|
||||
height: 40px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.dropdown-content li:hover {
|
||||
|
||||
background-color: #ffffff;
|
||||
color: #000000;
|
||||
}
|
||||
<template>
|
||||
<nav class="navbar">
|
||||
<div class="navbar-left">
|
||||
<router-link to="/home" class="navbar-logo">
|
||||
<img :src="logo" alt="MyPlayer" />
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="navbar-right">
|
||||
<router-link to="/home/search">搜索</router-link>
|
||||
<router-link to="/home/friends">好友</router-link>
|
||||
<router-link to="/home/group">群聊</router-link>
|
||||
<router-link to="/home/playroom">Playroom</router-link>
|
||||
<div class="more-dropdown" @click="toggleDropdown" @blur="closeDropdown">
|
||||
更多
|
||||
<ul v-if="isDropdownOpen" class="dropdown-content">
|
||||
<li>设置</li>
|
||||
<li @click="aboutUs">关于我们</li>
|
||||
<li>帮助</li>
|
||||
<li @click="logout">退出登录</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import logo from '../assets/logo.png';
|
||||
import { onlineSocketStore } from '@/store/Online';
|
||||
import router from '@/router';
|
||||
import { groupMessageStore } from '@/store/message.ts';
|
||||
import { userInfoStore } from '@/store/user';
|
||||
|
||||
const userInfo = userInfoStore()
|
||||
const socket = onlineSocketStore()
|
||||
const groupMessage = groupMessageStore()
|
||||
|
||||
|
||||
const isDropdownOpen = ref(false); // 控制下拉框的显示与隐藏
|
||||
|
||||
const toggleDropdown = () => {
|
||||
isDropdownOpen.value = !isDropdownOpen.value; // 切换下拉框的显示状态
|
||||
};
|
||||
|
||||
const closeDropdown = () => {
|
||||
isDropdownOpen.value = false; // 关闭下拉框
|
||||
};
|
||||
|
||||
// 监听全局点击事件
|
||||
const handleGlobalClick = (event) => {
|
||||
// 检查点击是否发生在下拉框外部
|
||||
if (!event.target.closest('.more-dropdown')) {
|
||||
closeDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleGlobalClick);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleGlobalClick);
|
||||
});
|
||||
|
||||
|
||||
//退出登录逻辑
|
||||
const logout = () => {
|
||||
//保存群聊消息
|
||||
groupMessage.saveMessagesHistory(socket.u_id,groupMessage.g_id)
|
||||
// 清除用户全局信息
|
||||
userInfo.clearUserInfo();
|
||||
localStorage.clear();
|
||||
// 断开websocket链接
|
||||
socket.disconnect();
|
||||
// 跳转到登录页面
|
||||
window.location.href = '/';
|
||||
|
||||
};
|
||||
|
||||
const aboutUs = () => {
|
||||
router.push('/home/about')
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.navbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
padding: 10px 20px;
|
||||
background-color: #60605e;
|
||||
color: #fff;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.navbar-left,
|
||||
.navbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 10%;
|
||||
margin-left: 10%;
|
||||
}
|
||||
|
||||
.navbar-logo img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.navbar a {
|
||||
color: #fff;
|
||||
margin: 0 10px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.navbar a.router-link-active {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.more-dropdown {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dropdown-content {
|
||||
position: absolute;
|
||||
width: 100px;
|
||||
margin-top: 22px;
|
||||
right: -50px;
|
||||
color: #fff;
|
||||
background-color: #444444;
|
||||
border: 1px solid #000000;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||
padding: 5px;
|
||||
z-index: 1000;
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
.dropdown-content a {
|
||||
display: block;
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.dropdown-content li {
|
||||
height: 40px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.dropdown-content li:hover {
|
||||
|
||||
background-color: #ffffff;
|
||||
color: #000000;
|
||||
}
|
||||
</style>
|
||||
@@ -1,153 +1,154 @@
|
||||
<template>
|
||||
<el-button class="telegram-btn" @click="showTelegramPanel">
|
||||
<el-icon>
|
||||
<Mic />
|
||||
</el-icon>
|
||||
<div class="telegramTag"></div>
|
||||
</el-button>
|
||||
|
||||
<div class="voice" v-if="onCall.panel">
|
||||
<div>
|
||||
<div class="profilebox">
|
||||
<img :src="onCall.target.avatar" alt="头像">
|
||||
</div>
|
||||
<div class="infobox">
|
||||
<p>{{ onCall.target.name }}</p>
|
||||
</div>
|
||||
<div class="buttonsbox">
|
||||
<button @click="answer" v-if="!onCall.from">
|
||||
接听
|
||||
</button>
|
||||
<button @click="hangup">
|
||||
挂断
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<el-button @click="playRemoteAudio">没有声音?试试我</el-button>
|
||||
</div>
|
||||
<audio ref="remoteAudio" autoplay></audio>
|
||||
|
||||
</template>
|
||||
<script setup>
|
||||
import { userInfoStore } from '@/store/store';
|
||||
import { voiceStore } from '@/store/Voice';
|
||||
import { onCallStore } from '@/store/VoiceTarget';
|
||||
import { Mic } from '@element-plus/icons-vue';
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue';
|
||||
|
||||
const voice = voiceStore();
|
||||
const onCall = onCallStore();
|
||||
const userinfo = userInfoStore()
|
||||
|
||||
const localAudio = ref(null);
|
||||
const remoteAudio = ref(null);
|
||||
|
||||
onMounted(() => {
|
||||
onCall.setRemoteElement(remoteAudio.value);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// 清理音频流和元素
|
||||
if (localAudio.value.srcObject) {
|
||||
localAudio.value.srcObject.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
if (remoteAudio.value.srcObject) {
|
||||
remoteAudio.value.srcObject.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
})
|
||||
|
||||
const answer = () => {
|
||||
console.log("接听");
|
||||
onCall.callingOff();
|
||||
onCall.fromOn();
|
||||
voice.pickup(userinfo.user.u_id, onCall.target.u_id)
|
||||
}
|
||||
|
||||
const hangup = () => {
|
||||
voice.hangup();
|
||||
}
|
||||
|
||||
const showTelegramPanel = () => {
|
||||
onCall.panel = !onCall.panel;
|
||||
}
|
||||
|
||||
const playRemoteAudio = () => {
|
||||
const audio = document.getElementById("remoteAudio");
|
||||
if (audio) {
|
||||
audio.play().catch(err => {
|
||||
console.log(err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
<style>
|
||||
.voice {
|
||||
position: fixed;
|
||||
top: 210px;
|
||||
right: 100px;
|
||||
width: 300px;
|
||||
height: 400px;
|
||||
background-color: #d7d7d7;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.profilebox {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.profilebox img {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.infobox {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 18px;
|
||||
color: #646464;
|
||||
}
|
||||
|
||||
.buttonsbox {
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.buttonsbox button {
|
||||
margin: 0 20px 0 20px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.telegram-btn {
|
||||
position: fixed;
|
||||
top: 45%;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.telegramTag {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background-color: #f00;
|
||||
}
|
||||
<template>
|
||||
<el-button class="telegram-btn" @click="showTelegramPanel">
|
||||
<el-icon>
|
||||
<Mic />
|
||||
</el-icon>
|
||||
<div class="telegramTag"></div>
|
||||
</el-button>
|
||||
|
||||
<div class="voice" v-if="onCall.panel">
|
||||
<div>
|
||||
<div class="profilebox">
|
||||
<!-- 原逻辑::src="onCall.target.avatar"(远端头像) -->
|
||||
<img :src="defaultAvatar" alt="头像">
|
||||
</div>
|
||||
<div class="infobox">
|
||||
<p>{{ onCall.target.name }}</p>
|
||||
</div>
|
||||
<div class="buttonsbox">
|
||||
<button @click="answer" v-if="!onCall.from">
|
||||
接听
|
||||
</button>
|
||||
<button @click="denyCall(userinfo.user.id, onCall.target.u_id)" v-if="!onCall.from">
|
||||
拒绝
|
||||
</button>
|
||||
<button @click="hangupCall(userinfo.user.id, onCall.target.u_id)" v-if="onCall.from">
|
||||
挂断
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<el-button @click="playRemoteAudio">没有声音?试试我</el-button>
|
||||
</div>
|
||||
<audio ref="remoteAudio" id="remoteAudio" autoplay playsinline></audio>
|
||||
|
||||
</template>
|
||||
<script setup>
|
||||
import { userInfoStore } from '@/store/user';
|
||||
import { onCallStore } from '@/store/VoiceTarget';
|
||||
import { Mic } from '@element-plus/icons-vue';
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { denyCall, hangupCall,sendOffer } from '@/store/Voice.ts';
|
||||
import defaultAvatar from '@/assets/defaultavatar.jpg';
|
||||
|
||||
const onCall = onCallStore();
|
||||
const userinfo = userInfoStore()
|
||||
|
||||
const localAudio = ref(null);
|
||||
const remoteAudio = ref(null);
|
||||
|
||||
onMounted(() => {
|
||||
onCall.setRemoteElement(remoteAudio.value);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// 清理音频流和元素
|
||||
if (localAudio.value && localAudio.value.srcObject) {
|
||||
localAudio.value.srcObject.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
if (remoteAudio.value && remoteAudio.value.srcObject) {
|
||||
remoteAudio.value.srcObject.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
})
|
||||
|
||||
const answer = async () => {
|
||||
console.log("接听");
|
||||
onCall.callingOff();
|
||||
onCall.fromOn();
|
||||
// voice.pickup(userinfo.user.id, onCall.target.u_id)
|
||||
await sendOffer(userinfo.user.id, onCall.target.u_id);
|
||||
}
|
||||
|
||||
|
||||
const showTelegramPanel = () => {
|
||||
onCall.panel = !onCall.panel;
|
||||
}
|
||||
|
||||
const playRemoteAudio = () => {
|
||||
const audio = remoteAudio.value;
|
||||
if (!audio) return;
|
||||
audio.play().catch(err => {
|
||||
console.log(err);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
<style>
|
||||
.voice {
|
||||
position: fixed;
|
||||
top: 210px;
|
||||
right: 100px;
|
||||
width: 300px;
|
||||
height: 400px;
|
||||
background-color: #d7d7d7;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.profilebox {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.profilebox img {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.infobox {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 18px;
|
||||
color: #646464;
|
||||
}
|
||||
|
||||
.buttonsbox {
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.buttonsbox button {
|
||||
margin: 0 20px 0 20px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.telegram-btn {
|
||||
position: fixed;
|
||||
top: 45%;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.telegramTag {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background-color: #f00;
|
||||
}
|
||||
</style>
|
||||
@@ -1,72 +1,74 @@
|
||||
<template>
|
||||
<router-link to="/home/user" v-if="userinfo.user.u_avatar" class="user-profile-link">
|
||||
<div class="user-profile">
|
||||
<img :src="userinfo.user.u_avatar" alt="User Avatar" />
|
||||
<div :class="['status-dot', statusClass]"></div>
|
||||
</div>
|
||||
</router-link>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { userInfoStore } from '@/store/store';
|
||||
|
||||
const userinfo = userInfoStore();
|
||||
|
||||
const isLoggedIn = ref(true); // 假设用户已登录
|
||||
|
||||
const statusClass = computed(() => {
|
||||
return isLoggedIn.value ? 'online' : 'offline';
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-profile-link {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
/* 去掉链接的下划线 */
|
||||
}
|
||||
|
||||
.user-profile {
|
||||
position: fixed;
|
||||
bottom: 30px;
|
||||
right: 30px;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
border-radius: 50%;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
|
||||
z-index: 500;
|
||||
|
||||
}
|
||||
|
||||
.user-profile img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid white;
|
||||
box-shadow: 0 0 5px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.online {
|
||||
background-color: green;
|
||||
/* 在线状态 */
|
||||
}
|
||||
|
||||
.offline {
|
||||
background-color: red;
|
||||
/* 离线状态 */
|
||||
}
|
||||
<template>
|
||||
<router-link to="/home/user" class="user-profile-link">
|
||||
<div class="user-profile">
|
||||
<!-- 原逻辑::src="userinfo.user.u_avatar"(远端头像) -->
|
||||
<img :src="defaultAvatar" alt="User Avatar" />
|
||||
<div :class="['status-dot', statusClass]"></div>
|
||||
</div>
|
||||
</router-link>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { userInfoStore } from '@/store/user.ts';
|
||||
import defaultAvatar from '@/assets/defaultavatar.jpg';
|
||||
|
||||
const userinfo = userInfoStore();
|
||||
|
||||
const isLoggedIn = ref(true); // 假设用户已登录
|
||||
|
||||
const statusClass = computed(() => {
|
||||
return isLoggedIn.value ? 'online' : 'offline';
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-profile-link {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
/* 去掉链接的下划线 */
|
||||
}
|
||||
|
||||
.user-profile {
|
||||
position: fixed;
|
||||
bottom: 30px;
|
||||
right: 30px;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
border-radius: 50%;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
|
||||
z-index: 500;
|
||||
|
||||
}
|
||||
|
||||
.user-profile img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid white;
|
||||
box-shadow: 0 0 5px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.online {
|
||||
background-color: green;
|
||||
/* 在线状态 */
|
||||
}
|
||||
|
||||
.offline {
|
||||
background-color: red;
|
||||
/* 离线状态 */
|
||||
}
|
||||
</style>
|
||||
@@ -8,6 +8,10 @@ import DPlayer from 'dplayer';
|
||||
import Hls from 'hls.js';
|
||||
|
||||
const videoRef = ref(null);
|
||||
const player = ref(null);
|
||||
const hlsInstance = ref(null);
|
||||
|
||||
const emit = defineEmits(['canplay', 'play', 'pause', 'remote-play-failed']);
|
||||
|
||||
const props = defineProps({
|
||||
autoplay: { type: Boolean, default: false },
|
||||
@@ -15,75 +19,279 @@ const props = defineProps({
|
||||
danmaku: { type: Object, default: () => ({}) }
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
const playerOptions = {
|
||||
// 判断是否为 HLS 格式
|
||||
const isHlsUrl = (url) => {
|
||||
return url.includes('.m3u8') || url.includes('hls') || url.includes('application/x-mpegURL');
|
||||
};
|
||||
|
||||
// 清理 HLS 实例
|
||||
const cleanupHls = () => {
|
||||
if (hlsInstance.value) {
|
||||
try {
|
||||
hlsInstance.value.destroy();
|
||||
} catch (e) {
|
||||
console.warn('清理 HLS 实例时出错:', e);
|
||||
}
|
||||
hlsInstance.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
// 创建播放器配置
|
||||
const createPlayerOptions = (url) => {
|
||||
const proxyUrl = `/proxy?url=${encodeURIComponent(url)}`;
|
||||
const isHls = isHlsUrl(url);
|
||||
|
||||
console.log('视频 URL:', url);
|
||||
console.log('是否为 HLS 格式:', isHls);
|
||||
console.log('代理 URL:', proxyUrl);
|
||||
|
||||
const options = {
|
||||
container: videoRef.value,
|
||||
autoplay: props.autoplay,
|
||||
video: {
|
||||
url: `/proxy?url=${encodeURIComponent(props.videoUrl)}`,
|
||||
video: {},
|
||||
danmaku: props.danmaku
|
||||
};
|
||||
|
||||
if (isHls && Hls.isSupported()) {
|
||||
// HLS 格式使用 customHls
|
||||
options.video = {
|
||||
url: proxyUrl,
|
||||
type: 'customHls',
|
||||
customType: {
|
||||
customHls: function (video, player) {
|
||||
if (Hls.isSupported()) {
|
||||
const hls = new Hls();
|
||||
hls.loadSource(video.src);
|
||||
hls.attachMedia(video);
|
||||
hls.config.maxBufferLength = 60; // 设置最大缓冲时间为60秒
|
||||
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||
video.play();
|
||||
});
|
||||
}
|
||||
cleanupHls(); // 清理旧的实例
|
||||
|
||||
const hls = new Hls({
|
||||
maxBufferLength: 20,
|
||||
maxMaxBufferLength: 60,
|
||||
enableWorker: true,
|
||||
lowLatencyMode: false
|
||||
});
|
||||
|
||||
hlsInstance.value = hls;
|
||||
|
||||
hls.loadSource(video.src);
|
||||
hls.attachMedia(video);
|
||||
|
||||
// 错误处理
|
||||
hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
console.error('HLS 错误:', data);
|
||||
if (data.fatal) {
|
||||
switch (data.type) {
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
console.error('网络错误,尝试恢复...');
|
||||
hls.startLoad();
|
||||
break;
|
||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||
console.error('媒体错误,尝试恢复...');
|
||||
hls.recoverMediaError();
|
||||
break;
|
||||
default:
|
||||
console.error('致命错误,无法恢复');
|
||||
hls.destroy();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
console.log('HLS 清单解析完成');
|
||||
if (props.autoplay) {
|
||||
video.play().catch(err => {
|
||||
console.warn('自动播放失败:', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
danmaku: props.danmaku
|
||||
};
|
||||
} else {
|
||||
// 普通视频格式(MP4等)
|
||||
options.video = {
|
||||
url: proxyUrl,
|
||||
type: 'auto' // 让 DPlayer 自动检测类型
|
||||
};
|
||||
}
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
};
|
||||
onMounted(() => {
|
||||
try {
|
||||
const playerOptions = createPlayerOptions(props.videoUrl);
|
||||
player.value = new DPlayer(playerOptions);
|
||||
|
||||
console.log(`/proxy?url=${encodeURIComponent(props.videoUrl)}`)
|
||||
const player = new DPlayer(playerOptions);
|
||||
player.value.on('play', () => {
|
||||
console.log('播放:', props.videoUrl);
|
||||
emit('play', { time: getCurrentTime() });
|
||||
});
|
||||
|
||||
player.value.on('pause', () => {
|
||||
console.log('暂停');
|
||||
emit('pause', { time: getCurrentTime() });
|
||||
});
|
||||
|
||||
player.value.on('ended', () => {
|
||||
console.log('播放结束');
|
||||
});
|
||||
|
||||
player.value.on('error', (error) => {
|
||||
console.error('播放器错误:', error);
|
||||
});
|
||||
|
||||
player.value.on('loadstart', () => {
|
||||
console.log('开始加载视频');
|
||||
});
|
||||
|
||||
player.value.on('canplay', () => {
|
||||
// 缓冲、seek 后常会多次触发;不在此刷屏打印,由父组件用 pending* 决定是否执行同步
|
||||
emit('canplay');
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('初始化播放器失败:', error);
|
||||
}
|
||||
});
|
||||
|
||||
player.on('play', () => {
|
||||
console.log("播放...")
|
||||
})
|
||||
player.on('pause', () => {
|
||||
console.log("暂停...")
|
||||
})
|
||||
player.on('ended', () => {
|
||||
console.log("结束...")
|
||||
})
|
||||
player.on('error', () => {
|
||||
console.log("出错...")
|
||||
})
|
||||
const getCurrentTime = () => {
|
||||
const video = player.value?.video;
|
||||
if (!video || typeof video.currentTime !== 'number' || Number.isNaN(video.currentTime)) {
|
||||
return 0;
|
||||
}
|
||||
return video.currentTime;
|
||||
};
|
||||
|
||||
watch(() => props.videoUrl, (newUrl) => {
|
||||
if (player) {
|
||||
console.log("切换视频...")
|
||||
player.switchVideo({
|
||||
url: `/proxy?url=${encodeURIComponent(newUrl)}`,
|
||||
type: 'customHls',
|
||||
customType: {
|
||||
customHls: function (video, player) {
|
||||
if (Hls.isSupported()) {
|
||||
const hls = new Hls();
|
||||
const seekTo = (timeSec) => {
|
||||
const t = Number(timeSec);
|
||||
if (!Number.isFinite(t) || t < 0) return;
|
||||
if (player.value?.seek) {
|
||||
try {
|
||||
player.value.seek(t);
|
||||
return;
|
||||
} catch (e) {}
|
||||
}
|
||||
const video = player.value?.video;
|
||||
if (video && typeof video.currentTime === 'number') {
|
||||
try {
|
||||
video.currentTime = t;
|
||||
} catch (e) {}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {{ remote?: boolean }} options
|
||||
* remote=true:来自 WebSocket 同步,无用户手势;需静音才能通过多数浏览器的自动播放策略
|
||||
*/
|
||||
const play = async (options = {}) => {
|
||||
const remote = options?.remote === true;
|
||||
const video = player.value?.video;
|
||||
if (!video?.play) return false;
|
||||
if (remote) {
|
||||
try {
|
||||
video.muted = true;
|
||||
video.setAttribute?.('playsinline', 'true');
|
||||
} catch (e) {}
|
||||
}
|
||||
try {
|
||||
await video.play();
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn('[videoPlayer] play() 被拒绝(多为自动播放策略):', e?.name, e?.message);
|
||||
if (remote) {
|
||||
emit('remote-play-failed', { error: e });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const pause = () => {
|
||||
if (player.value?.pause) {
|
||||
try {
|
||||
player.value.pause();
|
||||
return;
|
||||
} catch (e) {}
|
||||
}
|
||||
const video = player.value?.video;
|
||||
if (video?.pause) {
|
||||
try {
|
||||
video.pause();
|
||||
} catch (e) {}
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ getCurrentTime, seekTo, play, pause });
|
||||
|
||||
watch(() => props.videoUrl, (newUrl) => {
|
||||
if (player.value && newUrl) {
|
||||
console.log('切换视频到:', newUrl);
|
||||
|
||||
try {
|
||||
cleanupHls(); // 清理旧的 HLS 实例
|
||||
|
||||
const isHls = isHlsUrl(newUrl);
|
||||
const proxyUrl = `/proxy?url=${encodeURIComponent(newUrl)}`;
|
||||
|
||||
if (isHls && Hls.isSupported()) {
|
||||
// HLS 格式切换
|
||||
player.value.switchVideo({
|
||||
url: proxyUrl,
|
||||
type: 'customHls',
|
||||
customType: {
|
||||
customHls: function (video, player) {
|
||||
cleanupHls();
|
||||
|
||||
const hls = new Hls({
|
||||
maxBufferLength: 20,
|
||||
maxMaxBufferLength: 60,
|
||||
enableWorker: true
|
||||
});
|
||||
|
||||
hlsInstance.value = hls;
|
||||
hls.loadSource(video.src);
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||
video.play();
|
||||
|
||||
hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
console.error('HLS 错误:', data);
|
||||
if (data.fatal) {
|
||||
switch (data.type) {
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
hls.startLoad();
|
||||
break;
|
||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||
hls.recoverMediaError();
|
||||
break;
|
||||
default:
|
||||
hls.destroy();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
} else {
|
||||
// 普通视频格式切换
|
||||
player.value.switchVideo({
|
||||
url: proxyUrl,
|
||||
type: 'auto'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('切换视频失败:', error);
|
||||
}
|
||||
}
|
||||
)
|
||||
});
|
||||
|
||||
// 销毁时清理播放器
|
||||
onBeforeUnmount(() => {
|
||||
player.destroy();
|
||||
});
|
||||
// 销毁时清理播放器和 HLS 实例
|
||||
onBeforeUnmount(() => {
|
||||
cleanupHls();
|
||||
if (player.value) {
|
||||
try {
|
||||
player.value.destroy();
|
||||
} catch (e) {
|
||||
console.warn('销毁播放器时出错:', e);
|
||||
}
|
||||
player.value = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ const STORE_NAME = 'groupHistoryMessages';
|
||||
|
||||
// 打开数据库
|
||||
const getDB = async () => {
|
||||
return openDB(DB_NAME, 3, {
|
||||
return openDB(DB_NAME, 4, {
|
||||
upgrade(db) {
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME); // 使用 userId 作为 key
|
||||
|
||||
@@ -79,7 +79,7 @@ const router = createRouter({
|
||||
{
|
||||
path: '/room',
|
||||
name: 'room',
|
||||
component: () => import('../views/room/room.vue'),
|
||||
component: () => import('../views/room/index.vue'),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,37 +1,42 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { connectWebSocket, disconnectWebSocket, sendMessage, setIsManualClose } from '@/websocket/onlineSocket'
|
||||
import { messageStore } from './message'
|
||||
|
||||
const message = messageStore()
|
||||
|
||||
export const onlineSocketStore = defineStore('onlineSocket', {
|
||||
state: () => ({
|
||||
isConnected: false,
|
||||
hasGotMessage: false,
|
||||
id: ''
|
||||
}),
|
||||
|
||||
actions: {
|
||||
connect(id) {
|
||||
this.id = id;
|
||||
if (this.isConnected === true) return
|
||||
connectWebSocket();
|
||||
this.isConnected = true;
|
||||
if (!this.hasGotMessage) {
|
||||
message.loadMessagesHistory(this.id)
|
||||
this.hasGotMessage = true
|
||||
}
|
||||
},
|
||||
disconnect() {
|
||||
setIsManualClose(true);
|
||||
disconnectWebSocket();
|
||||
this.isConnected = false;
|
||||
if (this.hasGotMessage) {
|
||||
message.saveMessagesHistory(this.id)
|
||||
}
|
||||
},
|
||||
send(message) {
|
||||
sendMessage(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
import { defineStore } from 'pinia'
|
||||
import { connectWebSocket, disconnectWebSocket, sendMessage, setIsManualClose } from '@/websocket/onlineSocket'
|
||||
import { messageStore } from './message'
|
||||
|
||||
const message = messageStore()
|
||||
|
||||
export const onlineSocketStore = defineStore('onlineSocket', {
|
||||
state: () => ({
|
||||
isConnected: false,
|
||||
hasGotMessage: false,
|
||||
id: ''
|
||||
}),
|
||||
|
||||
actions: {
|
||||
connect(id: number) {
|
||||
this.id = id;
|
||||
if (this.isConnected === true) return
|
||||
connectWebSocket();
|
||||
this.isConnected = true;
|
||||
if (!this.hasGotMessage) {
|
||||
message.loadMessagesHistory(this.id)
|
||||
this.hasGotMessage = true
|
||||
}
|
||||
},
|
||||
disconnect() {
|
||||
setIsManualClose(true);
|
||||
disconnectWebSocket();
|
||||
this.isConnected = false;
|
||||
if (this.hasGotMessage) {
|
||||
message.saveMessagesHistory(this.id)
|
||||
}
|
||||
},
|
||||
send(message: any) {
|
||||
try {
|
||||
sendMessage(message);
|
||||
} catch (error) {
|
||||
console.error('Failed to stringify message:', error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,50 +0,0 @@
|
||||
import { connectVoicesocket, disconnectVoicesocket, sendMessage, hangup } from "@/websocket/voiceSocket";
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
export const voiceStore = defineStore("voice", {
|
||||
state: () => ({
|
||||
isConnected: false
|
||||
}),
|
||||
actions: {
|
||||
connect() {
|
||||
connectVoicesocket();
|
||||
this.isConnected = true;
|
||||
},
|
||||
disconnect() {
|
||||
disconnectVoicesocket();
|
||||
this.isConnected = false;
|
||||
},
|
||||
startCall(from, from_name, from_avatar, to) {
|
||||
if (this.isConnected) {
|
||||
const message = {
|
||||
type: "incomingcall",
|
||||
from: from,
|
||||
from_name: from_name,
|
||||
from_avatar: from_avatar,
|
||||
to: to
|
||||
}
|
||||
sendMessage(JSON.stringify(message))
|
||||
} else {
|
||||
console.log("voice socket is not connected")
|
||||
}
|
||||
},
|
||||
pickup(from,to){
|
||||
if (this.isConnected) {
|
||||
const message ={
|
||||
type: "pickup",
|
||||
from: from,
|
||||
to: to
|
||||
}
|
||||
sendMessage(JSON.stringify(message))
|
||||
} else {
|
||||
console.log("voice socket is not connected")
|
||||
}
|
||||
},
|
||||
hangup(){
|
||||
hangup()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
)
|
||||
235
src/store/Voice.ts
Normal file
235
src/store/Voice.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { sendMessage } from "@/websocket/onlineSocket";
|
||||
import { onCallStore } from "@/store/VoiceTarget";
|
||||
import { ref } from "vue";
|
||||
|
||||
const iceserver = {
|
||||
iceServers: [
|
||||
{
|
||||
urls: process.env.STUN_URL,
|
||||
},
|
||||
{
|
||||
urls: process.env.TURN_URL,
|
||||
username: process.env.TURN_USERNAME,
|
||||
credential: process.env.TURN_CREDENTIAL,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
interface message {
|
||||
cmd: "VOICE_ICE_CANDIDATE" | "VOICE_SDP_OFFER" | "VOICE_SDP_ANSWER",
|
||||
from: number,
|
||||
to: number,
|
||||
content: any,
|
||||
}
|
||||
|
||||
const oncall = onCallStore();
|
||||
|
||||
const RTCpeerConnection = ref(null);
|
||||
|
||||
const localstream = ref(null);
|
||||
const remotestream = ref<MediaStream | null>(null);
|
||||
|
||||
// 本地候选:为了避免“绑定 onicecandidate 太晚”导致丢失,统一先缓存,等 remoteDescription 就绪后再发送
|
||||
const pendingLocalCandidates = ref<any[]>([]);
|
||||
// 远端候选:避免远端 candidate 早到但 remoteDescription 未 set 导致 addIceCandidate 失败
|
||||
const pendingRemoteCandidates = ref<any[]>([]);
|
||||
|
||||
// 当前会话对端信息(用于发送 ICE 时带上 from/to)
|
||||
const currentFrom = ref<number | null>(null);
|
||||
const currentTo = ref<number | null>(null);
|
||||
|
||||
const canSendIceNow = () => {
|
||||
const pc: any = RTCpeerConnection.value;
|
||||
return (
|
||||
!!pc &&
|
||||
!!pc.localDescription &&
|
||||
!!pc.remoteDescription &&
|
||||
currentFrom.value !== null &&
|
||||
currentTo.value !== null
|
||||
);
|
||||
};
|
||||
|
||||
const flushLocalCandidatesIfReady = () => {
|
||||
if (!canSendIceNow()) return;
|
||||
if (pendingLocalCandidates.value.length === 0) return;
|
||||
|
||||
pendingLocalCandidates.value.forEach((candidate) => {
|
||||
const message: message = {
|
||||
cmd: "VOICE_ICE_CANDIDATE",
|
||||
from: currentFrom.value as number,
|
||||
to: currentTo.value as number,
|
||||
content: candidate,
|
||||
};
|
||||
sendMessage(message);
|
||||
});
|
||||
pendingLocalCandidates.value = [];
|
||||
};
|
||||
|
||||
const flushRemoteCandidatesIfReady = async () => {
|
||||
const pc: any = RTCpeerConnection.value;
|
||||
if (!pc || !pc.remoteDescription) return;
|
||||
if (pendingRemoteCandidates.value.length === 0) return;
|
||||
|
||||
for (const c of pendingRemoteCandidates.value) {
|
||||
await pc.addIceCandidate(new RTCIceCandidate(c));
|
||||
}
|
||||
pendingRemoteCandidates.value = [];
|
||||
};
|
||||
|
||||
const getlocalStream = async () => {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
console.log("获取本地音频流成功");
|
||||
return stream;
|
||||
// // 获取音频和视频轨道
|
||||
// const audioTrack = stream.getAudioTracks()[0];
|
||||
// // 将轨道添加到 RTCPeerConnection
|
||||
// peerConnection.addTrack(audioTrack, stream);
|
||||
};
|
||||
|
||||
const closeLocalStream = (stream: MediaStream) => {
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
|
||||
export const initRTCconnection = async () => {
|
||||
RTCpeerConnection.value = new RTCPeerConnection(iceserver);
|
||||
localstream.value = await getlocalStream();
|
||||
RTCpeerConnection.value.addTrack(localstream.value.getAudioTracks()[0], localstream.value);
|
||||
|
||||
// 必须尽早绑定,否则可能在 setLocalDescription 后就把 candidate 产完了
|
||||
RTCpeerConnection.value.onicecandidate = (event: { candidate: any }) => {
|
||||
if (!event.candidate) return;
|
||||
pendingLocalCandidates.value.push(event.candidate);
|
||||
flushLocalCandidatesIfReady();
|
||||
};
|
||||
|
||||
// 远端音频:收到 track 后把 MediaStream 绑定到 UI 的 <audio>
|
||||
RTCpeerConnection.value.ontrack = (event: RTCTrackEvent) => {
|
||||
// 优先用浏览器提供的 streams[0];否则手动聚合 track
|
||||
let stream = event.streams && event.streams[0] ? event.streams[0] : null;
|
||||
if (!stream) {
|
||||
if (!remotestream.value) remotestream.value = new MediaStream();
|
||||
if (event.track) remotestream.value.addTrack(event.track);
|
||||
stream = remotestream.value;
|
||||
}
|
||||
if (stream) {
|
||||
oncall.setRemoteStream(stream);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const sendOffer = async (from: number,to: number) => {
|
||||
if (!RTCpeerConnection.value) {
|
||||
await initRTCconnection();
|
||||
};
|
||||
currentFrom.value = from;
|
||||
currentTo.value = to;
|
||||
const offer = await RTCpeerConnection.value.createOffer();
|
||||
await RTCpeerConnection.value.setLocalDescription(offer);
|
||||
const message: message = {
|
||||
cmd: "VOICE_SDP_OFFER",
|
||||
from: from,
|
||||
to: to,
|
||||
content: offer
|
||||
}
|
||||
sendMessage(message);
|
||||
}
|
||||
|
||||
export const handleOffer = async (offer: any, from: number, to: number) => {
|
||||
if (!RTCpeerConnection.value) {
|
||||
await initRTCconnection();
|
||||
}
|
||||
// 这里是“我收到对方 offer”,因此我这端的 from/to 应该是 (to -> from)
|
||||
currentFrom.value = to;
|
||||
currentTo.value = from;
|
||||
await RTCpeerConnection.value.setRemoteDescription(new RTCSessionDescription(offer));
|
||||
await flushRemoteCandidatesIfReady();
|
||||
const answer = await RTCpeerConnection.value.createAnswer();
|
||||
await RTCpeerConnection.value.setLocalDescription(answer);
|
||||
const message: message = {
|
||||
cmd: "VOICE_SDP_ANSWER",
|
||||
from: to,
|
||||
to: from,
|
||||
content: answer
|
||||
}
|
||||
sendMessage(message);
|
||||
// remoteDescription + localDescription 都已具备,允许发送本地候选
|
||||
flushLocalCandidatesIfReady();
|
||||
}
|
||||
|
||||
export const handleAnswer = async (answer: any, from: number, to: number) => {
|
||||
if (!RTCpeerConnection.value)
|
||||
{
|
||||
console.error("connection lost");
|
||||
return;
|
||||
}
|
||||
// 这里是“我收到对方 answer”,因此我这端的 from/to 应该是 (to -> from)
|
||||
currentFrom.value = to;
|
||||
currentTo.value = from;
|
||||
await RTCpeerConnection.value.setRemoteDescription(new RTCSessionDescription(answer));
|
||||
await flushRemoteCandidatesIfReady();
|
||||
flushLocalCandidatesIfReady();
|
||||
}
|
||||
|
||||
export const handleCandidate = async (candidate: any) => {
|
||||
if (!RTCpeerConnection.value) return;
|
||||
// candidate 可能早到:remoteDescription 未 set 时直接 add 会失败,先缓存
|
||||
if (!RTCpeerConnection.value.remoteDescription) {
|
||||
pendingRemoteCandidates.value.push(candidate);
|
||||
return;
|
||||
}
|
||||
await RTCpeerConnection.value.addIceCandidate(new RTCIceCandidate(candidate));
|
||||
}
|
||||
|
||||
export const hangupCall = (from: number, to: number) => {
|
||||
if(from === null || to === null) return;
|
||||
if (RTCpeerConnection.value) {
|
||||
RTCpeerConnection.value.close();
|
||||
RTCpeerConnection.value = null;
|
||||
const msg = {
|
||||
cmd: "VOICE_CALL_END",
|
||||
from: from,
|
||||
to: to,
|
||||
time: new Date().toLocaleString()
|
||||
}
|
||||
sendMessage(msg);
|
||||
oncall.clear();
|
||||
closeLocalStream(localstream.value);
|
||||
remotestream.value = null;
|
||||
pendingLocalCandidates.value = [];
|
||||
pendingRemoteCandidates.value = [];
|
||||
currentFrom.value = null;
|
||||
currentTo.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const denyCall = (from: number, to: number) => {
|
||||
if(from === null || to === null) return;
|
||||
const msg = {
|
||||
cmd: "VOICE_CALL_DENY",
|
||||
from: from,
|
||||
to: to,
|
||||
time: new Date().toLocaleString()
|
||||
}
|
||||
sendMessage(msg);
|
||||
oncall.clear();
|
||||
remotestream.value = null;
|
||||
pendingLocalCandidates.value = [];
|
||||
pendingRemoteCandidates.value = [];
|
||||
currentFrom.value = null;
|
||||
currentTo.value = null;
|
||||
}
|
||||
|
||||
|
||||
export const closeConnection = () => {
|
||||
if (RTCpeerConnection.value) {
|
||||
RTCpeerConnection.value.close();
|
||||
RTCpeerConnection.value = null;
|
||||
}
|
||||
closeLocalStream(localstream.value);
|
||||
remotestream.value = null;
|
||||
pendingLocalCandidates.value = [];
|
||||
pendingRemoteCandidates.value = [];
|
||||
currentFrom.value = null;
|
||||
currentTo.value = null;
|
||||
}
|
||||
@@ -1,93 +1,115 @@
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
export const onCallStore = defineStore("onCall", {
|
||||
state: () => ({
|
||||
target: {
|
||||
u_id: "",
|
||||
name: "",
|
||||
avatar: ""
|
||||
},
|
||||
panel: false,
|
||||
calling: false,
|
||||
from: false,
|
||||
status: false,
|
||||
localstream: {
|
||||
audioStream: null,
|
||||
audioElement: null
|
||||
},
|
||||
remotestream: {
|
||||
audioStream: null,
|
||||
audioElement: null
|
||||
}
|
||||
}),
|
||||
actions: {
|
||||
setTarget(u_id, u_name, u_avatar) {
|
||||
this.target = {
|
||||
u_id: u_id,
|
||||
name: u_name,
|
||||
avatar: u_avatar
|
||||
}
|
||||
},
|
||||
panelOn() {
|
||||
this.panel = true
|
||||
},
|
||||
panelOff() {
|
||||
this.panel = false
|
||||
},
|
||||
callingOn() {
|
||||
this.calling = true
|
||||
},
|
||||
callingOff() {
|
||||
this.calling = false
|
||||
},
|
||||
fromOn() {
|
||||
this.from = true
|
||||
},
|
||||
fromOff() {
|
||||
this.from = false
|
||||
},
|
||||
statusOn() {
|
||||
this.status = true
|
||||
},
|
||||
statusOff() {
|
||||
this.status = false
|
||||
},
|
||||
setLocalStream(stream) {
|
||||
this.localstream.audioStream = stream;
|
||||
if (this.localstream.audioStream) {
|
||||
this.localstream.audioElement.srcObject = stream;
|
||||
}
|
||||
},
|
||||
setLocalElement(element) {
|
||||
this.localstream.audioElement = element;
|
||||
if (this.localstream.audioStream) {
|
||||
this.localstream.audioElement.srcObject = this.localstream.audioStream;
|
||||
}
|
||||
},
|
||||
setRemoteStream(stream) {
|
||||
this.remotestream.audioStream = stream;
|
||||
if (this.remotestream.audioStream) {
|
||||
this.remotestream.audioElement.srcObject = stream;
|
||||
}
|
||||
},
|
||||
setRemoteElement(element) {
|
||||
this.remotestream.audioElement = element;
|
||||
if (this.remotestream.audioStream) {
|
||||
this.remotestream.audioElement.srcObject = this.remotestream.audioStream;
|
||||
}
|
||||
},
|
||||
resetStream() {
|
||||
// 停止本地媒体流
|
||||
if (this.localstream.audioStream) {
|
||||
this.localstream.audioStream.getTracks().forEach(track => track.stop());
|
||||
this.localstream.audioStream = null;
|
||||
}
|
||||
|
||||
// 停止远程媒体流
|
||||
if (this.remotestream.videoStream) {
|
||||
this.remotestream.videoStream.getTracks().forEach(track => track.stop());
|
||||
this.remotestream.videoStream = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
export const onCallStore = defineStore("onCall", {
|
||||
state: () => ({
|
||||
target: {
|
||||
u_id: "",
|
||||
name: "",
|
||||
avatar: ""
|
||||
},
|
||||
panel: false,
|
||||
calling: false,
|
||||
from: false,
|
||||
status: false,
|
||||
localstream: {
|
||||
audioStream: null,
|
||||
audioElement: null
|
||||
},
|
||||
remotestream: {
|
||||
audioStream: null,
|
||||
audioElement: null
|
||||
}
|
||||
}),
|
||||
actions: {
|
||||
getTarget() {
|
||||
return this.target;
|
||||
},
|
||||
setTarget(u_id:number, u_name:string, u_avatar:string) {
|
||||
this.target = {
|
||||
u_id: u_id,
|
||||
name: u_name,
|
||||
avatar: u_avatar
|
||||
}
|
||||
},
|
||||
panelOn() {
|
||||
this.panel = true
|
||||
},
|
||||
panelOff() {
|
||||
this.panel = false
|
||||
},
|
||||
callingOn() {
|
||||
this.calling = true
|
||||
},
|
||||
callingOff() {
|
||||
this.calling = false
|
||||
},
|
||||
fromOn() {
|
||||
this.from = true
|
||||
},
|
||||
fromOff() {
|
||||
this.from = false
|
||||
},
|
||||
statusOn() {
|
||||
this.status = true
|
||||
},
|
||||
statusOff() {
|
||||
this.status = false
|
||||
},
|
||||
clear(){
|
||||
this.target = {
|
||||
u_id: "",
|
||||
name: "",
|
||||
avatar: ""
|
||||
}
|
||||
this.panel = false;
|
||||
this.calling = false;
|
||||
this.from = false;
|
||||
this.status = false;
|
||||
this.localstream = {
|
||||
audioStream: null,
|
||||
audioElement: null
|
||||
}
|
||||
this.remotestream = {
|
||||
audioStream: null,
|
||||
audioElement: null
|
||||
}
|
||||
},
|
||||
setLocalStream(stream) {
|
||||
this.localstream.audioStream = stream;
|
||||
if (this.localstream.audioStream) {
|
||||
this.localstream.audioElement.srcObject = stream;
|
||||
}
|
||||
},
|
||||
setLocalElement(element) {
|
||||
this.localstream.audioElement = element;
|
||||
if (this.localstream.audioStream) {
|
||||
this.localstream.audioElement.srcObject = this.localstream.audioStream;
|
||||
}
|
||||
},
|
||||
setRemoteStream(stream) {
|
||||
this.remotestream.audioStream = stream;
|
||||
if (this.remotestream.audioStream) {
|
||||
this.remotestream.audioElement.srcObject = stream;
|
||||
}
|
||||
},
|
||||
setRemoteElement(element) {
|
||||
this.remotestream.audioElement = element;
|
||||
if (this.remotestream.audioStream) {
|
||||
this.remotestream.audioElement.srcObject = this.remotestream.audioStream;
|
||||
}
|
||||
},
|
||||
resetStream() {
|
||||
// 停止本地媒体流
|
||||
if (this.localstream.audioStream) {
|
||||
this.localstream.audioStream.getTracks().forEach(track => track.stop());
|
||||
this.localstream.audioStream = null;
|
||||
}
|
||||
|
||||
// 停止远程媒体流
|
||||
if (this.remotestream.videoStream) {
|
||||
this.remotestream.videoStream.getTracks().forEach(track => track.stop());
|
||||
this.remotestream.videoStream = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
76
src/store/group_message.ts
Normal file
76
src/store/group_message.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { defineStore } from "pinia";
|
||||
import {
|
||||
saveGroupMessages,
|
||||
loadGroupMessages,
|
||||
deleteGroupMessages,
|
||||
} from "@/functions/groupHistoryMessage";
|
||||
|
||||
interface Group_Message{
|
||||
cmd: string;
|
||||
from: number;
|
||||
to: number;
|
||||
group: number;
|
||||
content: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
export const groupMessageStore = defineStore("groupMessageStore", {
|
||||
state: () => ({
|
||||
g_id: "",
|
||||
historymessages: [],
|
||||
messages: [],
|
||||
corresponding: [],
|
||||
}),
|
||||
actions: {
|
||||
async recieveMessage(u_id: number, message: Group_Message) {
|
||||
const key = `${u_id}-${message.group}`
|
||||
if(this.g_id !== '' && this.g_id === message.group) this.messages.push(message);
|
||||
await saveGroupMessages(key, [message]);
|
||||
},
|
||||
reset(){
|
||||
this.g_id = "";
|
||||
this.historymessages = [];
|
||||
this.messages = [];
|
||||
this.corresponding = [];
|
||||
},
|
||||
addMessage(message: Group_Message) {
|
||||
this.messages.push(message);
|
||||
},
|
||||
clearMessages() {
|
||||
this.messages = [];
|
||||
},
|
||||
|
||||
async initMessages() {
|
||||
this.historymessages = [...this.historymessages, ...this.messages];
|
||||
this.messages = [];
|
||||
},
|
||||
|
||||
async getHistoryMessages(u_id:number, g_id: number) {
|
||||
this.g_id = g_id;
|
||||
const key = `${u_id}-${g_id}`;
|
||||
try {
|
||||
this.historymessages = await loadGroupMessages(key);
|
||||
// 确保历史消息是数组类型
|
||||
if (!Array.isArray(this.historymessages)) {
|
||||
console.error("历史消息数据无效:", this.historymessages);
|
||||
this.historymessages = []; // 如果数据无效,设置为空数组
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("加载历史消息时出错" + error);
|
||||
}
|
||||
},
|
||||
async saveMessagesHistory(u_id: number, g_id: number) {
|
||||
// const key = `${u_id}-${g_id}`;
|
||||
// const messages = toRaw(this.messages);
|
||||
this.messages = [];
|
||||
this.historymessages = [];
|
||||
// await saveGroupMessages(key, messages);
|
||||
},
|
||||
async deleteMessagesHistory(u_id: number, g_id: number) {
|
||||
const key = `${u_id}-${g_id}`;
|
||||
this.historymessages = [];
|
||||
this.messages = [];
|
||||
await deleteGroupMessages(key, []);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,186 +0,0 @@
|
||||
import { defineStore } from "pinia";
|
||||
import {
|
||||
saveMessages,
|
||||
loadMessages,
|
||||
deleteMessages,
|
||||
} from "@/functions/historyMessages";
|
||||
import {
|
||||
saveGroupMessages,
|
||||
loadGroupMessages,
|
||||
deleteGroupMessages,
|
||||
} from "@/functions/groupHistoryMessage";
|
||||
import { toRaw } from "vue";
|
||||
|
||||
export const messageStore = defineStore("messageStore", {
|
||||
// 定义一个响应式数组来存储聊天消息
|
||||
state: () => ({
|
||||
historymessages: [],
|
||||
messages: [],
|
||||
sender: "", //选择的聊天用户id
|
||||
target: "", //用户自己的id
|
||||
corresponding: [],
|
||||
}),
|
||||
|
||||
// 定义操作消息的函数
|
||||
actions: {
|
||||
// 添加消息到数组
|
||||
addMessage(message) {
|
||||
this.messages.push(message);
|
||||
},
|
||||
|
||||
// 清空所有消息
|
||||
clearMessages() {
|
||||
this.messages = [];
|
||||
},
|
||||
|
||||
setCorresponding() {
|
||||
// 过滤出当前聊天中的消息
|
||||
this.corresponding = this.messages.filter(
|
||||
(msg) =>
|
||||
(msg.sender === this.sender && msg.target === this.target) ||
|
||||
(msg.sender === this.target && msg.target === this.sender)
|
||||
);
|
||||
const historymessages = this.historymessages.filter(
|
||||
(msg) =>
|
||||
(msg.sender === this.sender && msg.target === this.target) ||
|
||||
(msg.sender === this.target && msg.target === this.sender)
|
||||
);
|
||||
this.corresponding = [...historymessages, ...this.corresponding];
|
||||
},
|
||||
// 清楚当前登录的聊天数据,保存到本地
|
||||
async saveMessagesHistory(id) {
|
||||
const messages = toRaw(this.messages);
|
||||
await saveMessages(id, messages);
|
||||
},
|
||||
// 加载本地聊天数据
|
||||
async loadMessagesHistory(u_id) {
|
||||
try {
|
||||
this.historymessages = await loadMessages(u_id);
|
||||
|
||||
// 确保历史消息是数组类型
|
||||
if (!Array.isArray(this.historymessages)) {
|
||||
console.error("历史消息数据无效:", this.historymessages);
|
||||
this.historymessages = []; // 如果数据无效,设置为空数组
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("加载历史消息时出错" + error);
|
||||
}
|
||||
},
|
||||
async deleteMessagesHistory(u_id, f_id) {
|
||||
this.historymessages = this.historymessages.filter(
|
||||
(msg) =>
|
||||
!(
|
||||
(msg.sender === f_id && msg.target === u_id) ||
|
||||
(msg.sender === u_id && msg.target === f_id)
|
||||
)
|
||||
);
|
||||
this.messages = this.messages.filter(
|
||||
(msg) =>
|
||||
!(
|
||||
(msg.sender === f_id && msg.target === u_id) ||
|
||||
(msg.sender === u_id && msg.target === f_id)
|
||||
)
|
||||
);
|
||||
if (f_id === this.sender) {
|
||||
console.log("清除对应聊天数据展示栈");
|
||||
this.corresponding = [];
|
||||
}
|
||||
const messages = JSON.parse(JSON.stringify(toRaw(this.historymessages)));
|
||||
try {
|
||||
await deleteMessages(u_id, messages);
|
||||
} catch (error) {
|
||||
console.log("删除历史消息时出错" + error);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// 定义计算属性(可选)
|
||||
getters: {
|
||||
// 获取消息数量
|
||||
messageCount: (state) => state.messages.length,
|
||||
},
|
||||
});
|
||||
|
||||
export const messageSignStore = defineStore("messageSignStore", {
|
||||
state: () => ({
|
||||
sign: [],
|
||||
}),
|
||||
actions: {
|
||||
addSign(sign) {
|
||||
// 检查是否已经存在相同的值
|
||||
const exists = this.sign.some(
|
||||
(item) =>
|
||||
item.sender === sign.sender && item.sender_name === sign.sender_name
|
||||
);
|
||||
if (!exists) {
|
||||
this.sign.push(sign);
|
||||
} else {
|
||||
console.warn(`Sign "${sign}" already exists and will not be added.`);
|
||||
}
|
||||
},
|
||||
clearSign() {
|
||||
this.sign = [];
|
||||
},
|
||||
removeSign(sign) {
|
||||
// 找到相同值的索引
|
||||
const index = this.sign.indexOf(sign);
|
||||
if (index !== -1) {
|
||||
// 如果存在,删除该值
|
||||
this.sign.splice(index, 1);
|
||||
} else {
|
||||
console.warn(`Sign "${sign}" not found.`);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const groupMessageStore = defineStore("groupMessageStore", {
|
||||
state: () => ({
|
||||
g_id: "",
|
||||
historymessages: [],
|
||||
messages: [],
|
||||
corresponding: [],
|
||||
}),
|
||||
actions: {
|
||||
addMessage(message) {
|
||||
this.messages.push(message);
|
||||
},
|
||||
clearMessages() {
|
||||
this.messages = [];
|
||||
},
|
||||
async initMessages() {
|
||||
this.historymessages = [...this.historymessages, ...this.messages];
|
||||
this.messages = [];
|
||||
},
|
||||
async getHistoryMessages(u_id, g_id) {
|
||||
this.g_id = g_id;
|
||||
const key = `${u_id}-${g_id}`;
|
||||
try {
|
||||
this.historymessages = await loadGroupMessages(key);
|
||||
console.log("缓存到历史消息:");
|
||||
console.log(this.historymessages);
|
||||
// 确保历史消息是数组类型
|
||||
if (!Array.isArray(this.historymessages)) {
|
||||
console.error("历史消息数据无效:", this.historymessages);
|
||||
this.historymessages = []; // 如果数据无效,设置为空数组
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("加载历史消息时出错" + error);
|
||||
}
|
||||
},
|
||||
async saveMessagesHistory(u_id, g_id) {
|
||||
const key = `${u_id}-${g_id}`;
|
||||
console.log(key);
|
||||
const messages = toRaw(this.messages);
|
||||
this.messages = [];
|
||||
this.historymessages = [];
|
||||
await saveGroupMessages(key, messages);
|
||||
},
|
||||
async deleteMessagesHistory(u_id, g_id) {
|
||||
const key = `${u_id}-${g_id}`;
|
||||
this.historymessages = [];
|
||||
this.messages = [];
|
||||
await deleteGroupMessages(key, []);
|
||||
},
|
||||
},
|
||||
});
|
||||
93
src/store/playroom.ts
Normal file
93
src/store/playroom.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { ref } from "vue";
|
||||
import { defineStore } from "pinia";
|
||||
import { connectWebSocket, disconnectWebSocket, sendMessage, setIsManualClose } from "@/websocket/roomSocket";
|
||||
|
||||
interface PlayroomState {
|
||||
id: number
|
||||
r_id: number;
|
||||
r_name: string;
|
||||
r_introduction: string;
|
||||
r_avatar: string;
|
||||
role: number;
|
||||
}
|
||||
|
||||
interface member {
|
||||
id: number;
|
||||
u_id: string;
|
||||
u_name: string;
|
||||
u_avatar: string;
|
||||
}
|
||||
|
||||
|
||||
export const PlayroomStore = defineStore("PlayroomStore",
|
||||
() =>{
|
||||
const currentPlayroom = ref<PlayroomState>();
|
||||
const members = ref<member[]>([]);
|
||||
const currentUrl = ref<string>("");
|
||||
|
||||
const setCurrentPlayroom = (playroom: PlayroomState) => {
|
||||
currentPlayroom.value = playroom;
|
||||
}
|
||||
|
||||
const getCurrentPlayroom = () =>{
|
||||
return currentPlayroom.value;
|
||||
}
|
||||
|
||||
const getCurrentId = () =>{
|
||||
return currentPlayroom.value?.r_id;
|
||||
}
|
||||
|
||||
const setCurrentUrl = (url: string) => {
|
||||
currentUrl.value = url;
|
||||
}
|
||||
|
||||
const clearPlayroom = () => {
|
||||
currentPlayroom.value = null;
|
||||
currentUrl.value = "";
|
||||
members.value = [];
|
||||
}
|
||||
|
||||
const addmember = (member: member) => {
|
||||
members.value.push(member);
|
||||
}
|
||||
|
||||
const getmembers = (page: number, pageSize: number) => {
|
||||
return members.value.slice((page - 1) * pageSize, page * pageSize);
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
currentPlayroom,
|
||||
currentUrl,
|
||||
getCurrentId,
|
||||
setCurrentPlayroom,
|
||||
clearPlayroom,
|
||||
addmember,
|
||||
getmembers,
|
||||
}
|
||||
})
|
||||
|
||||
export const videoSocketStore = defineStore("videoSocketStore",{
|
||||
state: () => ({
|
||||
isConnected: false,
|
||||
hasGotMessage: false,
|
||||
id: 0
|
||||
}),
|
||||
|
||||
actions: {
|
||||
connect(id: number) {
|
||||
this.id = id;
|
||||
if (this.isConnected === true) return
|
||||
connectWebSocket(id);
|
||||
this.isConnected = true;
|
||||
},
|
||||
disconnect() {
|
||||
setIsManualClose(true);
|
||||
disconnectWebSocket();
|
||||
this.isConnected = false;
|
||||
},
|
||||
send(message: string) {
|
||||
sendMessage(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,15 +0,0 @@
|
||||
import { ref } from "vue";
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
export const roomStore = defineStore("room",{
|
||||
state: () => ({
|
||||
r_id: '',
|
||||
r_name: '',
|
||||
r_avatar: '',
|
||||
inroomTag: '',
|
||||
currentURL: '',
|
||||
}),
|
||||
actions:{
|
||||
|
||||
}
|
||||
})
|
||||
@@ -1,33 +1,33 @@
|
||||
<template>
|
||||
<el-row>
|
||||
<el-col :span="6">
|
||||
<h1>关于我们</h1>
|
||||
<p>前端是我</p>
|
||||
<p>后端是我</p>
|
||||
<p>测试是我</p>
|
||||
<p>运维是我</p>
|
||||
<p>UI设计是我</p>
|
||||
<p>项目管理是我</p>
|
||||
<p>架构设计是我</p>
|
||||
<p>数据库设计还是我</p>
|
||||
<p>感谢使用与支持</p>
|
||||
</el-col>
|
||||
<el-col :span="9">
|
||||
可以添加微信询问详情
|
||||
<img class="QRcode" src="@/assets/微信二维码.png" alt="candlelight_official">
|
||||
</el-col>
|
||||
<!-- <el-col :span="9">
|
||||
<img class="QRcode" src="@/assets/微信收款码.png" alt="" />
|
||||
</el-col>
|
||||
<el-col :span="9">
|
||||
<img class="QRcode" src="@/assets/支付宝收款码.jpg" alt="" />
|
||||
</el-col> -->
|
||||
</el-row>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.QRcode {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
<template>
|
||||
<el-row>
|
||||
<el-col :span="6">
|
||||
<h1>关于我们</h1>
|
||||
<p>前端是我</p>
|
||||
<p>后端是我</p>
|
||||
<p>测试是我</p>
|
||||
<p>运维是我</p>
|
||||
<p>UI设计是我</p>
|
||||
<p>项目管理是我</p>
|
||||
<p>架构设计是我</p>
|
||||
<p>数据库设计还是我</p>
|
||||
<p>感谢使用</p>
|
||||
</el-col>
|
||||
<!-- <el-col :span="9">
|
||||
可以添加微信询问详情
|
||||
<img class="QRcode" src="@/assets/微信二维码.png" alt="candlelight_official">
|
||||
</el-col> -->
|
||||
<!-- <el-col :span="9">
|
||||
<img class="QRcode" src="@/assets/微信收款码.png" alt="" />
|
||||
</el-col>
|
||||
<el-col :span="9">
|
||||
<img class="QRcode" src="@/assets/支付宝收款码.jpg" alt="" />
|
||||
</el-col> -->
|
||||
</el-row>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.QRcode {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,23 +1,24 @@
|
||||
<template>
|
||||
<div class="home-default">
|
||||
<h1>欢迎使用Myplayer!</h1>
|
||||
<h2>当前版本v0.2.0测试版</h2>
|
||||
<p>v0.1.0 websocket实时聊天实装</p>
|
||||
<p>v0.1.1 面板更新,websocket重连机制增加</p>
|
||||
<p>v0.1.2 修复bug,群聊功能性实现,webRTC点对点语言聊天实装</p>
|
||||
<p>v0.2.0 修复若干bug,完善了部分ui和逻辑</p>
|
||||
<p>预期开发计划:1、播放器相关开发 2、ui重绘 3、群聊功能完善......</p>
|
||||
<p>总之还有好多事,慢慢写吧!</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home-default {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
<template>
|
||||
<div class="home-default">
|
||||
<h1>欢迎使用Myplayer!</h1>
|
||||
<h2>当前版本v0.4.0测试版</h2>
|
||||
<!-- <p>v0.1.0 websocket实时聊天实装</p>
|
||||
<p>v0.1.1 面板更新,websocket重连机制增加</p>
|
||||
<p>v0.1.2 修复bug,群聊功能性实现,webRTC点对点语言聊天实装</p>
|
||||
<p>v0.2.0 修复若干bug,完善了部分ui和逻辑</p>
|
||||
<p>v0.3.0 重构了后端以及前端逻辑</p>
|
||||
<p>预期开发计划:1、播放器相关开发 2、ui重绘 3、群聊功能完善......</p> -->
|
||||
<p>总之还有好多事,慢慢写吧!</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home-default {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,402 +1,414 @@
|
||||
<template>
|
||||
<el-row class="container">
|
||||
<el-col :span="6">
|
||||
<el-button type="primary" @click="dialogVisibleFriendManage = true">好友管理</el-button>
|
||||
<el-input v-model="tempsearch" :prefix-icon="Search" style="width: 90%;" clearable></el-input>
|
||||
<el-scrollbar style="height: 500px;width: 90%;">
|
||||
<ul class="infinitelist" style="overflow: auto">
|
||||
<li v-for="(item) in tempsearchResult" :key="item.id" class="infinitelistitem"
|
||||
@click="switchTemplate(item.id, item.u_name, item.u_avatar)"
|
||||
:class="{ 'selected': selectedFriendId === item.id }">
|
||||
<div class="user-profile">
|
||||
<img :src="item.u_avatar" alt="User Avatar" />
|
||||
<div :class="['status-dot', statusClass]"></div>
|
||||
</div>
|
||||
<div style="display: inline-block;">{{ item.u_name }}#{{ item.u_id }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
</el-scrollbar>
|
||||
</el-col>
|
||||
<el-col :span="18">
|
||||
<el-row class="messagebox">
|
||||
<el-scrollbar ref="scrollbarRef" style="width: 100%;height: 100%;">
|
||||
<!-- 聊天消息指示框 头像、时间、内容 -->
|
||||
<div v-for="(item) in messagebox" class="message-item">
|
||||
<div
|
||||
:class="{ 'message-item-profile': true, 'left': item.from !== userinfo.user.id, 'right': item.from === userinfo.user.id }">
|
||||
<img :src="item.from !== userinfo.user.id ? oppositeAvatar : userinfo.user.u_avatar"
|
||||
alt="User Avatar" />
|
||||
</div>
|
||||
<div
|
||||
:class="{ 'message-item-content': true, 'left': item.from !== userinfo.user.id, 'right': item.from === userinfo.user.id }">
|
||||
{{ item.content }}</div>
|
||||
<div
|
||||
:class="{ 'message-item-time': true, 'left': item.from === userinfo.user.id, 'right': item.from !== userinfo.user.id }">
|
||||
{{ item.time }}</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<el-input class="inputbox" v-model="inputValue" :placeholder="placeholder" @keyup.enter="handleEnter" clearable
|
||||
:disabled="inputDisabled"></el-input>
|
||||
<el-button class="call" :disabled="inputDisabled" @click="confirmCall()">语音通话</el-button>
|
||||
</el-row>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 确认通话弹窗 -->
|
||||
<el-dialog v-model="dialogVisibleCallConfirm" title="确认通话对象" width="30%" :before-close="handleClose">
|
||||
<el-row style="height: 80px;align-items: center;">
|
||||
<div class="user-profile">
|
||||
<img :src="oppositeAvatar" alt="User Avatar" />
|
||||
</div>
|
||||
<div>{{ oppositeName }}</div>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<el-button type="primary" @click="handleConfirmCall()" style="margin: auto;">确认</el-button>
|
||||
</el-row>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 好友管理弹窗 -->
|
||||
<el-dialog v-model="dialogVisibleFriendManage" title="好友管理" style="width: 850px;height: 500px;">
|
||||
<el-input v-model="friendSearch" placeholder="搜索好友" style="margin-bottom: 10px;width: 100%;"></el-input>
|
||||
<el-table :data="friendSearchResult" style="width: 100%;height: 400px;">
|
||||
<el-table-column prop="u_avatar" label="" width="100">
|
||||
<template #default="scope">
|
||||
<!-- 使用 el-avatar 组件显示头像 -->
|
||||
<el-avatar :src="friendSearchResult[scope.$index].u_avatar" size="large" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="u_name" label="姓名" width="100"></el-table-column>
|
||||
<!-- <el-table-column prop="id" label="id" width="0"></el-table-column> -->
|
||||
<el-table-column prop="u_id" label="u_id" width="100"></el-table-column>
|
||||
<el-table-column prop="u_introduction" label="个性签名" width="200"></el-table-column>
|
||||
<el-table-column>
|
||||
<template #default="scope">
|
||||
<el-button @click="deleteChatHisory(scope.row.u_id)">删除聊天记录</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column>
|
||||
<template #default="scope">
|
||||
<el-button @click="deletefriend(scope.row.id)">删除好友</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, nextTick, watch, computed } from 'vue'
|
||||
import { userInfoStore } from '@/store/user';
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { onlineSocketStore } from '@/store/Online';
|
||||
import { messageStore } from '@/store/message.ts';
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { voiceStore } from '@/store/Voice';
|
||||
import { onCallStore } from '@/store/VoiceTarget';
|
||||
import { getFriends, deleteFriend } from '@/api/friend';
|
||||
|
||||
const socket = onlineSocketStore()
|
||||
const userinfo = userInfoStore()
|
||||
const message = messageStore()
|
||||
const voice = voiceStore()
|
||||
const oncall = onCallStore()
|
||||
|
||||
const messagebox = ref([])
|
||||
const scrollbarRef = ref(null)
|
||||
const dialogVisibleFriendManage = ref(false)
|
||||
const dialogVisibleCallConfirm = ref(false)
|
||||
|
||||
const tempsearch = ref('')
|
||||
const inputValue = ref('')
|
||||
const statusClass = ref('online')
|
||||
const inputDisabled = ref(true)
|
||||
const placeholder = ref('请选择聊天对象')
|
||||
const oppositeId = ref('')
|
||||
const oppositeName = ref('')
|
||||
const oppositeAvatar = ref('')
|
||||
|
||||
const friends = ref([])
|
||||
const friendSearch = ref('')
|
||||
const selectedFriendId = ref('')
|
||||
|
||||
const tempsearchResult = computed(() => {
|
||||
if (!tempsearch.value)
|
||||
return friends.value
|
||||
friends.value.filter(item => item.u_name.includes(tempsearch.value))
|
||||
})
|
||||
|
||||
const friendSearchResult = computed(() => {
|
||||
if (!friendSearch.value)
|
||||
return friends.value
|
||||
else
|
||||
return friends.value.filter(item => item.u_name.includes(friendSearch.value))
|
||||
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
//切换聊天对象
|
||||
const switchTemplate = (id, u_name, u_avatar) => {
|
||||
message.from = id
|
||||
oppositeId.value = id
|
||||
oppositeName.value = u_name
|
||||
oppositeAvatar.value = u_avatar
|
||||
message.setCorresponding()
|
||||
messagebox.value = message.corresponding
|
||||
inputDisabled.value = false
|
||||
placeholder.value = '请输入消息内容'
|
||||
scrollToBottom()
|
||||
selectedFriendId.value = id
|
||||
console.log(message.corresponding)
|
||||
}
|
||||
|
||||
// 定义回车键的处理函数
|
||||
const handleEnter = () => {
|
||||
if (inputValue.value === '') {
|
||||
ElMessage.error('消息不能为空')
|
||||
return
|
||||
}
|
||||
|
||||
// 发送一类消息给目标用户
|
||||
const msg = {
|
||||
cmd: "MESSAGE",
|
||||
from: userinfo.user.id,
|
||||
to: message.from,
|
||||
content: inputValue.value,
|
||||
time: new Date().toLocaleString()
|
||||
};
|
||||
|
||||
socket.send(msg)
|
||||
message.addMessage(msg)
|
||||
inputValue.value = ''
|
||||
scrollToBottom()
|
||||
};
|
||||
|
||||
const scrollToBottom = () => {
|
||||
nextTick(() => {
|
||||
const scrollbar = scrollbarRef.value?.$el; // 获取 el-scrollbar 的根元素
|
||||
if (scrollbar) {
|
||||
const scrollWrap = scrollbar.querySelector(".el-scrollbar__wrap"); // 找到滚动区域
|
||||
if (scrollWrap) {
|
||||
scrollWrap.scrollTop = scrollWrap.scrollHeight; // 滚动到底部
|
||||
} else {
|
||||
console.error("滚动容器未找到,请检查 el-scrollbar 的 DOM 结构");
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 监听消息
|
||||
watch(() => message.messages.length, (newLength, oldLength) => {
|
||||
const msg = message.messages[newLength - 1]
|
||||
console.log("监听到新消息:", msg)
|
||||
if (newLength > oldLength && (msg.from === message.from && msg.to === message.to) || (msg.from === message.to && msg.to === message.from)) {
|
||||
messagebox.value.push(msg)
|
||||
scrollToBottom()
|
||||
}
|
||||
})
|
||||
|
||||
const deletefriend = (id) => {
|
||||
if(deleteFriend(id)){
|
||||
inputValue.value = ''
|
||||
inputDisabled.value = true
|
||||
placeholder.value = '请选择聊天对象'
|
||||
oppositeAvatar.value = ''
|
||||
ElMessage.success('删除好友成功')
|
||||
friends.value = getFriends()
|
||||
return
|
||||
}else{
|
||||
ElMessage.error('删除好友失败')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const deleteChatHisory = (u_id) => {
|
||||
try {
|
||||
if (u_id === message.sender) messagebox.value = []
|
||||
message.deleteMessagesHistory(userinfo.user.u_id, u_id)
|
||||
} catch (e) {
|
||||
ElMessage.error('删除聊天记录失败')
|
||||
}
|
||||
}
|
||||
|
||||
const confirmCall = () => {
|
||||
dialogVisibleCallConfirm.value = true
|
||||
}
|
||||
|
||||
const handleConfirmCall = () => {
|
||||
dialogVisibleCallConfirm.value = false
|
||||
oncall.setTarget(oppositeId.value, oppositeName.value, oppositeAvatar.value)
|
||||
oncall.panelOn()
|
||||
oncall.callingOn()
|
||||
oncall.fromOn()
|
||||
voice.startCall(userinfo.user.u_id, userinfo.user.u_name, userinfo.user.u_avatar, oppositeId.value)
|
||||
}
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
friends.value = await getFriends()
|
||||
await nextTick()
|
||||
console.log(friends.value)
|
||||
console.log(message.from)
|
||||
if (message.from !== '' || message.from !== null) {
|
||||
console.log("切换聊天对象")
|
||||
const foundUser = friends.value.find((friend) => friend.id === message.from);
|
||||
switchTemplate(message.from, foundUser.u_name, foundUser.u_avatar)
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
height: 70%;
|
||||
margin-top: 4%;
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.infinitelist {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.infinitelistitem {
|
||||
padding: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.infinitelistitem.selected {
|
||||
background-color: #f0f0f0;
|
||||
/* 灰色背景 */
|
||||
}
|
||||
|
||||
.messagebox {
|
||||
height: 400px;
|
||||
border: 1px solid #cccccc;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.inputbox {
|
||||
margin-top: 10px;
|
||||
margin-left: 10px;
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.user-profile-link {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
/* 去掉链接的下划线 */
|
||||
}
|
||||
|
||||
.user-profile {
|
||||
position: relative;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
|
||||
|
||||
}
|
||||
|
||||
.user-profile img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
position: absolute;
|
||||
bottom: -5px;
|
||||
right: -5px;
|
||||
z-index: 1;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid white;
|
||||
box-shadow: 0 0 5px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.online {
|
||||
background-color: green;
|
||||
/* 在线状态 */
|
||||
}
|
||||
|
||||
.offline {
|
||||
background-color: red;
|
||||
/* 离线状态 */
|
||||
}
|
||||
|
||||
.message-item {
|
||||
padding: 10px;
|
||||
height: 80px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message-item-profile {
|
||||
position: absolute;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.message-item-profile img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.message-item-profile.left {
|
||||
left: 20px;
|
||||
}
|
||||
|
||||
.message-item-profile.right {
|
||||
right: 20px;
|
||||
}
|
||||
|
||||
.message-item-content {
|
||||
position: absolute;
|
||||
display: inline-block;
|
||||
bottom: 28px;
|
||||
}
|
||||
|
||||
.message-item-content.left {
|
||||
left: 90px;
|
||||
}
|
||||
|
||||
.message-item-content.right {
|
||||
right: 90px;
|
||||
}
|
||||
|
||||
.message-item-time {
|
||||
position: absolute;
|
||||
bottom: 23px;
|
||||
height: 20px;
|
||||
text-align: left;
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
.message-item-time.left {
|
||||
left: 20px;
|
||||
}
|
||||
|
||||
.message-item-time.right {
|
||||
right: 20px;
|
||||
}
|
||||
|
||||
.call {
|
||||
margin: auto;
|
||||
margin-top: 10px;
|
||||
}
|
||||
<template>
|
||||
<el-row class="container">
|
||||
<el-col :span="6">
|
||||
<el-button type="primary" @click="dialogVisibleFriendManage = true">好友管理</el-button>
|
||||
<el-input v-model="tempsearch" :prefix-icon="Search" style="width: 90%;" clearable></el-input>
|
||||
<el-scrollbar style="height: 500px;width: 90%;">
|
||||
<ul class="infinitelist" style="overflow: auto">
|
||||
<li v-for="(item) in tempsearchResult" :key="item.id" class="infinitelistitem"
|
||||
@click="switchTemplate(item.id, item.u_name, item.u_avatar)"
|
||||
:class="{ 'selected': selectedFriendId === item.id }">
|
||||
<div class="user-profile">
|
||||
<!-- 原逻辑::src="item.u_avatar"(远端头像) -->
|
||||
<img :src="defaultAvatar" alt="User Avatar" />
|
||||
<div :class="['status-dot', statusClass]"></div>
|
||||
</div>
|
||||
<div style="display: inline-block;">{{ item.u_name }}#{{ item.u_id }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
</el-scrollbar>
|
||||
</el-col>
|
||||
<el-col :span="18">
|
||||
<el-row class="messagebox">
|
||||
<el-scrollbar ref="scrollbarRef" style="width: 100%;height: 100%;">
|
||||
<!-- 聊天消息指示框 头像、时间、内容 -->
|
||||
<div v-for="(item) in messagebox" class="message-item">
|
||||
<div
|
||||
:class="{ 'message-item-profile': true, 'left': item.from !== userinfo.user.id, 'right': item.from === userinfo.user.id }">
|
||||
<!-- 原逻辑::src="item.from !== userinfo.user.id ? oppositeAvatar : userinfo.user.u_avatar"(远端头像) -->
|
||||
<img :src="defaultAvatar" alt="User Avatar" />
|
||||
</div>
|
||||
<div
|
||||
:class="{ 'message-item-content': true, 'left': item.from !== userinfo.user.id, 'right': item.from === userinfo.user.id }">
|
||||
{{ item.content }}</div>
|
||||
<div
|
||||
:class="{ 'message-item-time': true, 'left': item.from === userinfo.user.id, 'right': item.from !== userinfo.user.id }">
|
||||
{{ item.time }}</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<el-input class="inputbox" v-model="inputValue" :placeholder="placeholder" @keyup.enter="handleEnter" clearable
|
||||
:disabled="inputDisabled"></el-input>
|
||||
<el-button class="call" :disabled="inputDisabled" @click="confirmCall()">语音通话</el-button>
|
||||
</el-row>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 确认通话弹窗 -->
|
||||
<el-dialog v-model="dialogVisibleCallConfirm" title="确认通话对象" width="30%" :before-close="handleClose">
|
||||
<el-row style="height: 80px;align-items: center;">
|
||||
<div class="user-profile">
|
||||
<!-- 原逻辑::src="oppositeAvatar"(远端头像) -->
|
||||
<img :src="defaultAvatar" alt="User Avatar" />
|
||||
</div>
|
||||
<div>{{ oppositeName }}</div>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<el-button type="primary" @click="handleConfirmCall()" style="margin: auto;">确认</el-button>
|
||||
</el-row>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 好友管理弹窗 -->
|
||||
<el-dialog v-model="dialogVisibleFriendManage" title="好友管理" style="width: 850px;height: 500px;">
|
||||
<el-input v-model="friendSearch" placeholder="搜索好友" style="margin-bottom: 10px;width: 100%;"></el-input>
|
||||
<el-table :data="friendSearchResult" style="width: 100%;height: 400px;">
|
||||
<el-table-column prop="u_avatar" label="" width="100">
|
||||
<template #default="scope">
|
||||
<!-- 使用 el-avatar 组件显示头像 -->
|
||||
<!-- 原逻辑::src="friendSearchResult[scope.$index].u_avatar"(远端头像) -->
|
||||
<el-avatar :src="defaultAvatar" size="large" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="u_name" label="姓名" width="100"></el-table-column>
|
||||
<!-- <el-table-column prop="id" label="id" width="0"></el-table-column> -->
|
||||
<el-table-column prop="u_id" label="u_id" width="100"></el-table-column>
|
||||
<el-table-column prop="u_introduction" label="个性签名" width="200"></el-table-column>
|
||||
<el-table-column>
|
||||
<template #default="scope">
|
||||
<el-button @click="deleteChatHisory(scope.row.u_id)">删除聊天记录</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column>
|
||||
<template #default="scope">
|
||||
<el-button @click="deletefriend(scope.row.id)">删除好友</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, nextTick, watch, computed } from 'vue'
|
||||
import { userInfoStore } from '@/store/user';
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { sendMessage } from '@/websocket/onlineSocket';
|
||||
import { messageStore } from '@/store/message.ts';
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { onCallStore } from '@/store/VoiceTarget.ts';
|
||||
import { getFriends, deleteFriend } from '@/api/friend';
|
||||
import defaultAvatar from '@/assets/defaultavatar.jpg';
|
||||
|
||||
|
||||
const userinfo = userInfoStore()
|
||||
const message = messageStore()
|
||||
const oncall = onCallStore()
|
||||
|
||||
const messagebox = ref([])
|
||||
const scrollbarRef = ref(null)
|
||||
const dialogVisibleFriendManage = ref(false)
|
||||
const dialogVisibleCallConfirm = ref(false)
|
||||
|
||||
const tempsearch = ref('')
|
||||
const inputValue = ref('')
|
||||
const statusClass = ref('online')
|
||||
const inputDisabled = ref(true)
|
||||
const placeholder = ref('请选择聊天对象')
|
||||
const oppositeId = ref('')
|
||||
const oppositeName = ref('')
|
||||
const oppositeAvatar = ref('')
|
||||
|
||||
const friends = ref([])
|
||||
const friendSearch = ref('')
|
||||
const selectedFriendId = ref('')
|
||||
|
||||
const tempsearchResult = computed(() => {
|
||||
if (!tempsearch.value)
|
||||
return friends.value
|
||||
else
|
||||
return friends.value.filter(item => item.u_name.includes(tempsearch.value))
|
||||
})
|
||||
|
||||
const friendSearchResult = computed(() => {
|
||||
if (!friendSearch.value)
|
||||
return friends.value
|
||||
else
|
||||
return friends.value.filter(item => item.u_name.includes(friendSearch.value))
|
||||
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
//切换聊天对象
|
||||
const switchTemplate = (id, u_name, u_avatar) => {
|
||||
message.from = id
|
||||
oppositeId.value = id
|
||||
oppositeName.value = u_name
|
||||
oppositeAvatar.value = u_avatar
|
||||
message.setCorresponding()
|
||||
messagebox.value = message.corresponding
|
||||
inputDisabled.value = false
|
||||
placeholder.value = '请输入消息内容'
|
||||
scrollToBottom()
|
||||
selectedFriendId.value = id
|
||||
console.log(message.corresponding)
|
||||
}
|
||||
|
||||
// 定义回车键的处理函数
|
||||
const handleEnter = () => {
|
||||
if (inputValue.value === '') {
|
||||
ElMessage.error('消息不能为空')
|
||||
return
|
||||
}
|
||||
|
||||
// 发送一类消息给目标用户
|
||||
const msg = {
|
||||
cmd: "MESSAGE",
|
||||
from: userinfo.user.id,
|
||||
to: message.from,
|
||||
content: inputValue.value,
|
||||
time: new Date().toLocaleString()
|
||||
};
|
||||
|
||||
sendMessage(msg);
|
||||
message.addMessage(msg)
|
||||
inputValue.value = ''
|
||||
scrollToBottom()
|
||||
};
|
||||
|
||||
const scrollToBottom = () => {
|
||||
nextTick(() => {
|
||||
const scrollbar = scrollbarRef.value?.$el; // 获取 el-scrollbar 的根元素
|
||||
if (scrollbar) {
|
||||
const scrollWrap = scrollbar.querySelector(".el-scrollbar__wrap"); // 找到滚动区域
|
||||
if (scrollWrap) {
|
||||
scrollWrap.scrollTop = scrollWrap.scrollHeight; // 滚动到底部
|
||||
} else {
|
||||
console.error("滚动容器未找到,请检查 el-scrollbar 的 DOM 结构");
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 监听消息
|
||||
watch(() => message.messages.length, (newLength, oldLength) => {
|
||||
const msg = message.messages[newLength - 1]
|
||||
console.log("监听到新消息:", msg)
|
||||
if (newLength > oldLength && (msg.from === message.from && msg.to === message.to) || (msg.from === message.to && msg.to === message.from)) {
|
||||
messagebox.value.push(msg)
|
||||
scrollToBottom()
|
||||
}
|
||||
})
|
||||
|
||||
const deletefriend = (id) => {
|
||||
if(deleteFriend(id)){
|
||||
inputValue.value = ''
|
||||
inputDisabled.value = true
|
||||
placeholder.value = '请选择聊天对象'
|
||||
oppositeAvatar.value = ''
|
||||
ElMessage.success('删除好友成功')
|
||||
friends.value = getFriends()
|
||||
return
|
||||
}else{
|
||||
ElMessage.error('删除好友失败')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const deleteChatHisory = (u_id) => {
|
||||
try {
|
||||
if (u_id === message.sender) messagebox.value = []
|
||||
message.deleteMessagesHistory(userinfo.user.u_id, u_id)
|
||||
} catch (e) {
|
||||
ElMessage.error('删除聊天记录失败')
|
||||
}
|
||||
}
|
||||
|
||||
const confirmCall = () => {
|
||||
dialogVisibleCallConfirm.value = true
|
||||
}
|
||||
|
||||
const handleConfirmCall = () => {
|
||||
dialogVisibleCallConfirm.value = false
|
||||
oncall.setTarget(oppositeId.value, oppositeName.value, oppositeAvatar.value)
|
||||
oncall.panelOn()
|
||||
oncall.callingOn()
|
||||
oncall.fromOn()
|
||||
// voice.startCall(userinfo.user.u_id, userinfo.user.u_name, userinfo.user.u_avatar, oppositeId.value)
|
||||
const msg = {
|
||||
cmd: "VOICE_CALL_REQUEST",
|
||||
from: userinfo.user.id,
|
||||
from_name: userinfo.user.u_name,
|
||||
from_avatar: userinfo.user.u_avatar,
|
||||
to: oppositeId.value,
|
||||
time: new Date().toLocaleString()
|
||||
}
|
||||
sendMessage(msg);
|
||||
}
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
friends.value = await getFriends()
|
||||
await nextTick()
|
||||
console.log(friends.value)
|
||||
console.log(message.from)
|
||||
if (message.from !== '' || message.from !== null) {
|
||||
console.log("切换聊天对象")
|
||||
const foundUser = friends.value.find((friend) => friend.id === message.from);
|
||||
switchTemplate(message.from, foundUser.u_name, foundUser.u_avatar)
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
height: 70%;
|
||||
margin-top: 4%;
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.infinitelist {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.infinitelistitem {
|
||||
padding: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.infinitelistitem.selected {
|
||||
background-color: #f0f0f0;
|
||||
/* 灰色背景 */
|
||||
}
|
||||
|
||||
.messagebox {
|
||||
height: 400px;
|
||||
border: 1px solid #cccccc;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.inputbox {
|
||||
margin-top: 10px;
|
||||
margin-left: 10px;
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.user-profile-link {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
/* 去掉链接的下划线 */
|
||||
}
|
||||
|
||||
.user-profile {
|
||||
position: relative;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
|
||||
|
||||
}
|
||||
|
||||
.user-profile img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
position: absolute;
|
||||
bottom: -5px;
|
||||
right: -5px;
|
||||
z-index: 1;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid white;
|
||||
box-shadow: 0 0 5px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.online {
|
||||
background-color: green;
|
||||
/* 在线状态 */
|
||||
}
|
||||
|
||||
.offline {
|
||||
background-color: red;
|
||||
/* 离线状态 */
|
||||
}
|
||||
|
||||
.message-item {
|
||||
padding: 10px;
|
||||
height: 80px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message-item-profile {
|
||||
position: absolute;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.message-item-profile img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.message-item-profile.left {
|
||||
left: 20px;
|
||||
}
|
||||
|
||||
.message-item-profile.right {
|
||||
right: 20px;
|
||||
}
|
||||
|
||||
.message-item-content {
|
||||
position: absolute;
|
||||
display: inline-block;
|
||||
bottom: 28px;
|
||||
}
|
||||
|
||||
.message-item-content.left {
|
||||
left: 90px;
|
||||
}
|
||||
|
||||
.message-item-content.right {
|
||||
right: 90px;
|
||||
}
|
||||
|
||||
.message-item-time {
|
||||
position: absolute;
|
||||
bottom: 23px;
|
||||
height: 20px;
|
||||
text-align: left;
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
.message-item-time.left {
|
||||
left: 20px;
|
||||
}
|
||||
|
||||
.message-item-time.right {
|
||||
right: 20px;
|
||||
}
|
||||
|
||||
.call {
|
||||
margin: auto;
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,88 +1,85 @@
|
||||
<template>
|
||||
<!-- 顶部导航栏 -->
|
||||
<Navbar />
|
||||
|
||||
<div class="home-container">
|
||||
|
||||
|
||||
<div id="mainContent" class="main-content">
|
||||
<!-- 左侧主空间 -->
|
||||
<div class="left-content">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
<!-- 语音面板 -->
|
||||
<phonePanel />
|
||||
|
||||
<!-- 消息按钮 -->
|
||||
<GlobalMessageButton />
|
||||
|
||||
<!-- 右下角用户头像框 -->
|
||||
<UserProfile />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, watchEffect } from 'vue';
|
||||
import Navbar from '../../components/navBar.vue';
|
||||
import UserProfile from '../../components/userProfile.vue';
|
||||
import { userInfoStore } from '@/store/user';
|
||||
import { onlineSocketStore } from '@/store/Online';
|
||||
import { getUserInfo } from '@/api/user';
|
||||
import GlobalMessageButton from '@/components/GlobalMessageButton.vue';
|
||||
import { messageStore } from '@/store/message.ts';
|
||||
import phonePanel from '@/components/phonePanel.vue';
|
||||
import { voiceStore } from '@/store/Voice';
|
||||
import { initDB } from '@/functions/historyMessages';
|
||||
|
||||
const voice = voiceStore();
|
||||
|
||||
const userinfo = userInfoStore();
|
||||
const socket = onlineSocketStore();
|
||||
const message = messageStore();
|
||||
|
||||
onMounted(() => {
|
||||
getUserInfo()
|
||||
|
||||
// 使用 watchEffect 监听 u_id 是否为空
|
||||
watchEffect(() => {
|
||||
if (userinfo.user.u_id) { // 如果 u_id 不为空
|
||||
console.log('User ID is available:', userinfo.user.u_id);
|
||||
socket.connect(userinfo.user.id); // 建立 WebSocket 连接
|
||||
message.to = userinfo.user.id; // 设置消息发送者为当前用户 ID
|
||||
|
||||
// voice.connect(userinfo.user.u_id); // 建立语音通话连接
|
||||
}
|
||||
});
|
||||
|
||||
initDB(); // 初始化历史消息数据库
|
||||
})
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home-container {
|
||||
position: relative;
|
||||
top: 0px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
position: relative;
|
||||
left: 0;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
margin-top: 72px;
|
||||
|
||||
}
|
||||
|
||||
.left-content {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
}
|
||||
<template>
|
||||
<!-- 顶部导航栏 -->
|
||||
<Navbar />
|
||||
|
||||
<div class="home-container">
|
||||
|
||||
|
||||
<div id="mainContent" class="main-content">
|
||||
<!-- 左侧主空间 -->
|
||||
<div class="left-content">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
<!-- 语音面板 -->
|
||||
<phonePanel />
|
||||
|
||||
<!-- 消息按钮 -->
|
||||
<GlobalMessageButton />
|
||||
|
||||
<!-- 右下角用户头像框 -->
|
||||
<UserProfile />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, watchEffect } from 'vue';
|
||||
import Navbar from '../../components/navBar.vue';
|
||||
import UserProfile from '../../components/userProfile.vue';
|
||||
import { userInfoStore } from '@/store/user';
|
||||
import { onlineSocketStore } from '@/store/Online.ts';
|
||||
import { getUserInfo } from '@/api/user';
|
||||
import GlobalMessageButton from '@/components/GlobalMessageButton.vue';
|
||||
import { messageStore } from '@/store/message.ts';
|
||||
import phonePanel from '@/components/phonePanel.vue';
|
||||
import { initDB } from '@/functions/historyMessages';
|
||||
|
||||
const userinfo = userInfoStore();
|
||||
const socket = onlineSocketStore();
|
||||
const message = messageStore();
|
||||
|
||||
onMounted(() => {
|
||||
getUserInfo()
|
||||
|
||||
// 使用 watchEffect 监听 u_id 是否为空
|
||||
watchEffect(() => {
|
||||
if (userinfo.user.u_id) { // 如果 u_id 不为空
|
||||
console.log('User ID is available:', userinfo.user.u_id);
|
||||
socket.connect(userinfo.user.id); // 建立 WebSocket 连接
|
||||
message.to = userinfo.user.id; // 设置消息发送者为当前用户 ID
|
||||
|
||||
// voice.connect(userinfo.user.u_id); // 建立语音通话连接
|
||||
}
|
||||
});
|
||||
|
||||
initDB(); // 初始化历史消息数据库
|
||||
})
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home-container {
|
||||
position: relative;
|
||||
top: 0px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
position: relative;
|
||||
left: 0;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
margin-top: 72px;
|
||||
|
||||
}
|
||||
|
||||
.left-content {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,142 +1,119 @@
|
||||
<template>
|
||||
|
||||
<div class="container">
|
||||
<el-button @Click="createWindow = true">创建房间</el-button>
|
||||
|
||||
<el-table :data="rooms">
|
||||
<el-table-column label="" prop="r_avatar" width="100">
|
||||
<template #default="scope">
|
||||
<!-- 使用 el-avatar 组件显示头像 -->
|
||||
<el-avatar :src="rooms[scope.$index].r_avatar" size="large" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="房间名" prop="r_name"></el-table-column>
|
||||
<el-table-column label="房间ID" prop="r_id"></el-table-column>
|
||||
<el-table-column label="角色" prop="role"></el-table-column>
|
||||
<el-table-column label="简介" prop="r_introduction"></el-table-column>
|
||||
<el-table-column label="">
|
||||
<template #default="scope">
|
||||
<el-button @click="goToRoom(scope.row.r_id)">进入房间</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
|
||||
<el-dialog v-model="createWindow" title="创建房间" style="width: 400px;height: 300px;">
|
||||
<el-form ref="form" :model="formData" label-width="80px" @submit.prevent="createRoom">
|
||||
<el-form-item label="房间名" prop="roomName">
|
||||
<el-input v-model="formData.roomName" placeholder="请输入房间名" clearable></el-input>
|
||||
</el-form-item>
|
||||
<el-button type="primary" native-type="submit">创建</el-button>
|
||||
</el-form>
|
||||
|
||||
</el-dialog>
|
||||
|
||||
|
||||
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { roomStore } from '@/store/room';
|
||||
import axios from 'axios';
|
||||
import { userInfoStore } from '@/store/store';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
const userinfo = userInfoStore()
|
||||
const roominfo = roomStore()
|
||||
|
||||
const createWindow = ref(false)
|
||||
const formData = reactive({
|
||||
roomName: ''
|
||||
})
|
||||
|
||||
const rooms = ref([])
|
||||
|
||||
const goToRoom = (r_id) => {
|
||||
console.log(r_id)
|
||||
roominfo.r_id = r_id
|
||||
// 跳转到房间页面
|
||||
const baseUrl = window.location.origin;
|
||||
const targetUrl = `${baseUrl}/room`; // 替换为你的目标路由
|
||||
window.open(targetUrl, "room");
|
||||
};
|
||||
|
||||
const createRoom = async () => {
|
||||
if (!formData.roomName || formData.roomName.trim() === '') {
|
||||
ElMessage.error('房间名不能为空')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await axios.post('/api/room/create',{
|
||||
r_name: formData.roomName
|
||||
},{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': userinfo.token
|
||||
},
|
||||
})
|
||||
if (response.data.code === 200) {
|
||||
console.log(formData.roomName + '创建成功')
|
||||
} else {
|
||||
console.log(response.data.msg)
|
||||
}
|
||||
formData.roomName = ''
|
||||
createWindow.value = false
|
||||
getRooms()
|
||||
}
|
||||
catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
const getRooms = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/room/getrooms',{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': userinfo.token
|
||||
},
|
||||
})
|
||||
if (response.data.code === 200) {
|
||||
console.log(response.data.data)
|
||||
rooms.value = response.data.data
|
||||
} else {
|
||||
console.log(response.data.msg)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getRooms()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
margin-top: 20px;
|
||||
width: 800px;
|
||||
height: 600px;
|
||||
}
|
||||
|
||||
.profile {
|
||||
top: 50px;
|
||||
left: 100px;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.profile img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
<template>
|
||||
|
||||
<div class="container">
|
||||
<el-button @Click="createWindow = true">创建房间</el-button>
|
||||
|
||||
<el-table :data="rooms">
|
||||
<el-table-column label="" prop="r_avatar" width="100">
|
||||
<template #default="scope">
|
||||
<!-- 使用 el-avatar 组件显示头像 -->
|
||||
<el-avatar :src="rooms[scope.$index].r_avatar" size="large" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="房间名" prop="r_name"></el-table-column>
|
||||
<el-table-column label="房间ID" prop="r_id"></el-table-column>
|
||||
<el-table-column label="角色" prop="role"></el-table-column>
|
||||
<el-table-column label="简介" prop="r_introduction"></el-table-column>
|
||||
<el-table-column label="">
|
||||
<template #default="scope">
|
||||
<el-button @click="goToRoom(scope.row)">进入房间</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
|
||||
<el-dialog v-model="createWindow" title="创建房间" style="width: 400px;height: 300px;">
|
||||
<el-form ref="form" :model="formData" label-width="80px" @submit.prevent="createroom">
|
||||
<el-form-item label="房间名" prop="roomName">
|
||||
<el-input v-model="formData.roomName" placeholder="请输入房间名" clearable></el-input>
|
||||
</el-form-item>
|
||||
<el-button type="primary" native-type="submit">创建</el-button>
|
||||
</el-form>
|
||||
|
||||
</el-dialog>
|
||||
|
||||
|
||||
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { PlayroomStore } from '@/store/playroom';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { getPlayrooms, createPlayroom } from '@/api/playroom';
|
||||
|
||||
const roominfo = PlayroomStore()
|
||||
const createWindow = ref(false)
|
||||
const formData = reactive({
|
||||
roomName: ''
|
||||
})
|
||||
|
||||
const rooms = ref([])
|
||||
|
||||
const goToRoom = (r) => {
|
||||
roominfo.setCurrentPlayroom(r);
|
||||
// 跳转到房间页面
|
||||
const baseUrl = window.location.origin;
|
||||
const targetUrl = `${baseUrl}/room?r_id=${r.r_id}`; // 替换为你的目标路由
|
||||
window.open(targetUrl, "room");
|
||||
};
|
||||
|
||||
const createroom = async () => {
|
||||
if (!formData.roomName || formData.roomName.trim() === '') {
|
||||
ElMessage.error('房间名不能为空')
|
||||
return
|
||||
}
|
||||
try {
|
||||
if(await createPlayroom(formData.roomName)){
|
||||
ElMessage.success('创建成功')
|
||||
}else{
|
||||
ElMessage.error('创建失败')
|
||||
}
|
||||
formData.roomName = ''
|
||||
createWindow.value = false
|
||||
rooms.value = await getPlayrooms()
|
||||
}
|
||||
catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
const getrooms = async () => {
|
||||
try {
|
||||
rooms.value = await getPlayrooms()
|
||||
}
|
||||
catch (error) {
|
||||
ElMessage.error('获取房间列表失败' + error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getrooms()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
margin-top: 20px;
|
||||
width: 800px;
|
||||
height: 600px;
|
||||
}
|
||||
|
||||
.profile {
|
||||
top: 50px;
|
||||
left: 100px;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.profile img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
</style>
|
||||
@@ -50,7 +50,7 @@
|
||||
<el-table-column prop="r_introduction" label="个性签名" width="200"></el-table-column>
|
||||
<el-table-column>
|
||||
<template #default="scope">
|
||||
<el-button @click="joinRoom(scope.row.r_id)">加入房间</el-button>
|
||||
<el-button @click="joinroom(scope.row.r_id)">加入房间</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -69,7 +69,7 @@ import { userInfoStore } from '@/store/user'
|
||||
import { onlineSocketStore } from '@/store/Online'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { searchFriend,addFriend } from '@/api/friend'
|
||||
import { searchPlayRoom } from '@/api/room'
|
||||
import { searchPlayRoom, joinPlayroom } from '@/api/playroom'
|
||||
|
||||
const socket = onlineSocketStore()
|
||||
const inputValue = ref('')
|
||||
@@ -123,26 +123,16 @@ const addfriend = async (f_id) => {
|
||||
}
|
||||
|
||||
//加入房间申请逻辑
|
||||
const joinRoom = (r_id) => {
|
||||
axios({
|
||||
headers: {
|
||||
'Authorization': userinfo.token,
|
||||
},
|
||||
url: '/api/inviting/sendinviting',
|
||||
method: 'POST',
|
||||
data: {
|
||||
inviter: userinfo.user.u_id,
|
||||
target: userinfo.user.u_id,
|
||||
room: r_id
|
||||
}
|
||||
}).then((response) => {
|
||||
if (response.data.code === 200) {
|
||||
ElMessage.success("请求发送成功,请耐心等待审核")
|
||||
}
|
||||
else if (response.data.code === 500) {
|
||||
ElMessage.error("请勿重复发送!")
|
||||
}
|
||||
})
|
||||
const joinroom = (r_id) => {
|
||||
try{
|
||||
if(joinPlayroom(r_id)){
|
||||
ElMessage.success("请求发送成功,请耐心等待审核")
|
||||
return
|
||||
}
|
||||
}catch(error){
|
||||
ElMessage.error("加入房间失败:",error)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
@@ -1 +1 @@
|
||||
<template></template>
|
||||
<template>设置</template>
|
||||
@@ -25,7 +25,8 @@
|
||||
<ul class="memberlist" style="overflow: auto">
|
||||
<li v-for="(item) in members" :key="item.u_id" class="memberlistitem">
|
||||
<div class="user-profile">
|
||||
<img :src="item.u_avatar" alt="User Avatar" />
|
||||
<!-- <img :src="item.u_avatar" alt="User Avatar" /> -->
|
||||
<img :src="defaultAvatar" alt="User Avatar" />
|
||||
<div :class="['status-dot', item.status]"></div>
|
||||
</div>
|
||||
<el-text style="width: 100%;" truncated>{{ item.u_name }}#{{ item.u_id }}</el-text>
|
||||
@@ -36,45 +37,16 @@
|
||||
|
||||
</el-row>
|
||||
</el-col>
|
||||
<el-col :span="16">
|
||||
<video-player :autoplay="false" :videoUrl="videoUrl" />
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-row style="height: 50%">
|
||||
<!-- 语音大厅 -->
|
||||
<el-col :span="24" class="voice-room">
|
||||
<!-- <audio-player :audioUrl="audioUrl" /> -->
|
||||
<el-row>
|
||||
<!-- 加入的语言用户 -->
|
||||
<el-text size="large">语言聊天室:</el-text>
|
||||
<el-button type="success" round style="margin-left: 10px;" @click="joinVoiceRoom"
|
||||
v-if="!invoice">加入</el-button>
|
||||
<el-button type="info" round style="margin-left: 10px;" @click="leaveVoiceRoom"
|
||||
v-if="invoice">离开</el-button>
|
||||
<el-scrollbar style="height: 150px;width: 100%;">
|
||||
<div class="invoice">
|
||||
<div v-for="(item) in membersInVoice" class="user-profile-invoice">
|
||||
<img :src="item.u_avatar" alt="User Avatar" />
|
||||
</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</el-row>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row style="height: 50%">
|
||||
<el-col :span="24">
|
||||
<!-- 弹幕显示区域 -->
|
||||
<el-scroller class="comments">
|
||||
<div v-for="comment in roomMessages" :key="comment.id" class="comment">
|
||||
{{ comment.u_name }}:
|
||||
{{ comment.content }}
|
||||
</div>
|
||||
</el-scroller>
|
||||
<!-- 发送弹幕输入框 -->
|
||||
<el-input v-model="newComment" placeholder="发送弹幕" style="height: 20%;margin-left: 3px;"
|
||||
@keyup.enter="sendComment" />
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-col :span="20">
|
||||
<video-player
|
||||
ref="videoPlayerRef"
|
||||
:autoplay="false"
|
||||
:videoUrl="currentURL"
|
||||
@canplay="handlePlayerCanplay"
|
||||
@play="handleLocalPlay"
|
||||
@pause="handleLocalPause"
|
||||
@remote-play-failed="handleRemotePlayFailed"
|
||||
/>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
@@ -96,7 +68,8 @@
|
||||
<el-col :span="24">
|
||||
<div class="updateProfile">
|
||||
<div class="profile">
|
||||
<img :src="avatarPreview" alt="头像">
|
||||
<!-- <img :src="avatarPreview" alt="头像"> -->
|
||||
<img :src="defaultAvatar" alt="User Avatar" />
|
||||
</div>
|
||||
<el-col :span="12" v-if="role === 1 || role === 0">
|
||||
<input type="file" id="avatar" @change="handleAvatarChange" accept="image/*" />
|
||||
@@ -140,7 +113,7 @@
|
||||
<el-dialog title="设置/替换视频流" v-model="dialogVisibleVideo">
|
||||
<el-row>
|
||||
<el-col :span="20">
|
||||
<el-input v-model="curruentRoomInfo.currentURL" placeholder="请输入视频流地址"></el-input>
|
||||
<el-input v-model="changingVideoUrl" placeholder="请输入视频流地址"></el-input>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-button>测试</el-button>
|
||||
@@ -157,24 +130,69 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref, watchEffect } from 'vue';
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watchEffect } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { userInfoStore } from '@/store/store';
|
||||
import { userInfoStore } from '@/store/user';
|
||||
import videoPlayer from '@/components/videoPlayer.vue';
|
||||
import { roomStore } from '@/store/room';
|
||||
import { PlayroomStore } from '@/store/playroom';
|
||||
import { getPlayroomDetails } from '@/api/playroom';
|
||||
import { getUserInfo } from '@/api/user';
|
||||
import {
|
||||
connectWebSocket,
|
||||
ROOM_SOCKET_VIDEO_PAUSE_EVENT,
|
||||
ROOM_SOCKET_VIDEO_PLAY_EVENT,
|
||||
ROOM_SOCKET_VIDEO_SYNC_EVENT,
|
||||
sendMessage
|
||||
} from '@/websocket/roomSocket';
|
||||
import defaultAvatar from '@/assets/defaultavatar.jpg';
|
||||
|
||||
//import audioPlayer from '@/components/audioPlayer.vue'; // 假设你有一个音频播放组件
|
||||
|
||||
const curruentRoomInfo = roomStore();
|
||||
const curruentRoomInfo = PlayroomStore();
|
||||
const userinfo = userInfoStore();
|
||||
|
||||
const videoPlayerRef = ref(null);
|
||||
const pendingSyncSeekSec = ref(null);
|
||||
const pendingSyncUrl = ref(null);
|
||||
const isApplyingRemoteControl = ref(false);
|
||||
const pendingRemoteAction = ref(null); // 'play' | 'pause' | null
|
||||
|
||||
const getSelfFrom = () => {
|
||||
// u_id 在 JSON 里经常是 number,若只判断 typeof === 'string' 会漏掉,最后错误落到 id===0 → from 变成 0
|
||||
const rawId = userinfo.user?.id;
|
||||
if (typeof rawId === 'number' && rawId > 0) return rawId;
|
||||
if (typeof rawId === 'string' && rawId.trim() !== '') {
|
||||
const n = Number(rawId);
|
||||
if (Number.isFinite(n) && n > 0) return n;
|
||||
}
|
||||
const rawUid = userinfo.user?.u_id;
|
||||
if (typeof rawUid === 'number' && Number.isFinite(rawUid) && rawUid !== 0) return rawUid;
|
||||
if (typeof rawUid === 'string' && rawUid.trim() !== '') return rawUid.trim();
|
||||
return 0;
|
||||
};
|
||||
|
||||
const isSelfMessage = (from) => {
|
||||
// 后端可能用数字 id,也可能用 u_id;任一对上即视为自己,避免只比 getSelfFrom() 一种形态
|
||||
const f = String(from);
|
||||
const id = userinfo.user?.id;
|
||||
const uid = userinfo.user?.u_id;
|
||||
if (id != null && String(id) === f) return true;
|
||||
if (uid != null && String(uid) === f) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const dialogVisibleCode = ref(false)
|
||||
const dialogVisibleVideo = ref(false)
|
||||
|
||||
const invoice = ref(false)
|
||||
const invoice = ref(false);
|
||||
|
||||
|
||||
const drawer = ref(false);
|
||||
const role = ref(null);
|
||||
const invitingCode = ref('666666')
|
||||
|
||||
const changingVideoUrl = ref('')
|
||||
|
||||
const currentURL = ref('https://www.5dm.link/api/dd.php?vid=ccccxhndnys1&cid=ccccxhndnys1&xid=0&pid=55293&tid=1742788904&t=616d5131b6ade51a0e20814466b13515&ext=.mp4')
|
||||
const avatar = ref(null)
|
||||
const avatarPreview = ref('')
|
||||
@@ -256,7 +274,7 @@ const membersInVoice = ref([
|
||||
])
|
||||
|
||||
|
||||
const videoUrl = ref('https://www.5dm.link/api/dd.php?vid=ccccxhndnys1&cid=ccccxhndnys1&xid=0&pid=55293&tid=1742788904&t=616d5131b6ade51a0e20814466b13515&ext=.mp4'); // 替换为你的视频流地址
|
||||
const videoUrl = ref(''); // 替换为你的视频流地址
|
||||
const audioUrl = ref('https://example.com/audio/stream'); // 替换为你的音频流地址
|
||||
|
||||
const danmakuOptions = ref({
|
||||
@@ -286,8 +304,13 @@ const sendComment = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const getRoomInfo = () => {
|
||||
|
||||
const getRoomInfo = async (r_id) => {
|
||||
try {
|
||||
const response = await getPlayroomDetails(r_id)
|
||||
curruentRoomInfo.setCurrentPlayroom(response)
|
||||
} catch (error) {
|
||||
ElMessage.error('获取房间信息失败:' + error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAvatarChange = (event) => {
|
||||
@@ -300,32 +323,7 @@ const handleAvatarChange = (event) => {
|
||||
|
||||
//上传到服务器
|
||||
const uploadAvatar = () => {
|
||||
if (!avatar.value) {
|
||||
ElMessage('请选择头像')
|
||||
return
|
||||
}
|
||||
const formdata = new FormData();
|
||||
formdata.append('file', avatar.value)
|
||||
formdata.append('r_id', curruentRoomInfo.r_id)
|
||||
axios({
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
'Authorization': userinfo.token
|
||||
},
|
||||
method: 'post',
|
||||
url: '/api/avatar/upload',
|
||||
data: formdata
|
||||
}).then((response) => {
|
||||
if (response.data.code !== 200) {
|
||||
ElMessage.error(response.data.msg || '上传失败')
|
||||
return
|
||||
}
|
||||
getUserInfo()
|
||||
ElMessage.success('上传成功')
|
||||
}).catch((error) => {
|
||||
console.error('上传失败:', error);
|
||||
ElMessage.error('上传失败')
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
const getInvitingCode = () => {
|
||||
@@ -333,10 +331,150 @@ const getInvitingCode = () => {
|
||||
}
|
||||
|
||||
const replaceURL = () => {
|
||||
currentURL.value = curruentRoomInfo.currentURL;
|
||||
const playbackTimeSec = videoPlayerRef.value?.getCurrentTime?.() ?? 0;
|
||||
currentURL.value = changingVideoUrl.value;
|
||||
|
||||
const msg = {
|
||||
cmd: "VIDEO_SYNC",
|
||||
from: getSelfFrom(),
|
||||
url: changingVideoUrl.value,
|
||||
timestamp: Number(playbackTimeSec.toFixed(3)),
|
||||
playroom: curruentRoomInfo.getCurrentId()
|
||||
}
|
||||
console.log(msg);
|
||||
sendMessage(msg);
|
||||
dialogVisibleVideo.value = false;
|
||||
};
|
||||
|
||||
}
|
||||
const handlePlayerCanplay = () => {
|
||||
const hasWork =
|
||||
pendingSyncSeekSec.value != null ||
|
||||
pendingRemoteAction.value != null ||
|
||||
pendingSyncUrl.value != null;
|
||||
if (!hasWork) return;
|
||||
|
||||
if (pendingSyncSeekSec.value != null) {
|
||||
const t = Number(pendingSyncSeekSec.value);
|
||||
if (Number.isFinite(t) && t >= 0) {
|
||||
videoPlayerRef.value?.seekTo?.(t);
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingRemoteAction.value === 'play') {
|
||||
void videoPlayerRef.value?.play?.({ remote: true });
|
||||
} else if (pendingRemoteAction.value === 'pause') {
|
||||
videoPlayerRef.value?.pause?.();
|
||||
}
|
||||
|
||||
pendingSyncSeekSec.value = null;
|
||||
pendingSyncUrl.value = null;
|
||||
pendingRemoteAction.value = null;
|
||||
|
||||
if (isApplyingRemoteControl.value) {
|
||||
setTimeout(() => {
|
||||
isApplyingRemoteControl.value = false;
|
||||
}, 200);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemotePlayFailed = () => {
|
||||
ElMessage.info('同步播放被浏览器拦截:请在本页点击一次播放器上的播放(需用户手势后才能出声)');
|
||||
};
|
||||
|
||||
const handleVideoSync = (payload) => {
|
||||
if (!payload) return;
|
||||
|
||||
const from = payload.from;
|
||||
// 如果同步消息来自自己,直接跳过
|
||||
if (isSelfMessage(from)) return;
|
||||
|
||||
const url = payload.url;
|
||||
if (typeof url !== 'string' || url.trim() === '') return;
|
||||
|
||||
const ts = Number(payload.timestamp ?? 0);
|
||||
pendingSyncSeekSec.value = Number.isFinite(ts) && ts >= 0 ? ts : 0;
|
||||
pendingSyncUrl.value = url;
|
||||
|
||||
// 先切 URL,等 canplay 后再 seek
|
||||
currentURL.value = url;
|
||||
};
|
||||
|
||||
const handleLocalPlay = () => {
|
||||
if (isApplyingRemoteControl.value) return;
|
||||
const t = videoPlayerRef.value?.getCurrentTime?.() ?? 0;
|
||||
const msg = {
|
||||
cmd: "VIDEO_PLAY",
|
||||
from: getSelfFrom(),
|
||||
playroom: curruentRoomInfo.getCurrentId(),
|
||||
timestamp: Number(Number(t).toFixed(3)),
|
||||
url: currentURL.value,
|
||||
};
|
||||
console.log("[ws send]", msg);
|
||||
sendMessage(msg);
|
||||
};
|
||||
|
||||
const handleLocalPause = () => {
|
||||
if (isApplyingRemoteControl.value) return;
|
||||
const t = videoPlayerRef.value?.getCurrentTime?.() ?? 0;
|
||||
const msg = {
|
||||
cmd: "VIDEO_PAUSE",
|
||||
from: getSelfFrom(),
|
||||
playroom: curruentRoomInfo.getCurrentId(),
|
||||
timestamp: Number(Number(t).toFixed(3)),
|
||||
url: currentURL.value,
|
||||
};
|
||||
console.log("[ws send]", msg);
|
||||
sendMessage(msg);
|
||||
};
|
||||
|
||||
const applyRemotePlayPause = async (payload, action) => {
|
||||
if (!payload) return;
|
||||
|
||||
const from = payload.from;
|
||||
if (isSelfMessage(from)) return;
|
||||
|
||||
console.log("[ws recv]", action, payload);
|
||||
|
||||
const url = payload.url;
|
||||
if (typeof url === 'string' && url.trim() !== '' && url !== currentURL.value) {
|
||||
// 远端可能切了新 URL,但你本地还没收到/处理到 LINK/VIDEO_SYNC,先对齐
|
||||
pendingSyncUrl.value = url;
|
||||
currentURL.value = url;
|
||||
}
|
||||
|
||||
const ts = Number(payload.timestamp ?? 0);
|
||||
pendingSyncSeekSec.value = Number.isFinite(ts) && ts >= 0 ? ts : 0;
|
||||
|
||||
isApplyingRemoteControl.value = true;
|
||||
pendingRemoteAction.value = action;
|
||||
|
||||
// 尽量立刻执行一次(同 URL 时不用等 canplay)
|
||||
// 用 nextTick + setTimeout(0) 确保 URL/DOM 更新完成再触发 play/pause
|
||||
await nextTick();
|
||||
setTimeout(async () => {
|
||||
const seekSec = pendingSyncSeekSec.value;
|
||||
try {
|
||||
videoPlayerRef.value?.seekTo?.(seekSec);
|
||||
// 先清 pending,避免 await play() 期间多次 canplay 又走一遍 handlePlayerCanplay
|
||||
pendingSyncSeekSec.value = null;
|
||||
pendingSyncUrl.value = null;
|
||||
pendingRemoteAction.value = null;
|
||||
if (action === 'play') {
|
||||
console.log("[apply]", "play", seekSec);
|
||||
await videoPlayerRef.value?.play?.({ remote: true });
|
||||
} else {
|
||||
console.log("[apply]", "pause", seekSec);
|
||||
videoPlayerRef.value?.pause?.();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[apply failed]", e);
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
isApplyingRemoteControl.value = false;
|
||||
}, 250);
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const joinVoiceRoom = () => {
|
||||
membersInVoice.value.push({
|
||||
@@ -359,31 +497,44 @@ const goback = () => {
|
||||
}
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
getRoomInfo()
|
||||
const syncListener = (e) => handleVideoSync(e?.detail);
|
||||
const playListener = (e) => applyRemotePlayPause(e?.detail, 'play');
|
||||
const pauseListener = (e) => applyRemotePlayPause(e?.detail, 'pause');
|
||||
|
||||
//测试代码
|
||||
curruentRoomInfo.r_id = '123456';
|
||||
curruentRoomInfo.r_name = '弹幕聊天室';
|
||||
curruentRoomInfo.currentURL = 'https://www.5dm.link/api/dd.php?vid=ccccxhndnys1&cid=ccccxhndnys1&xid=0&pid=55293&tid=1742788904&t=616d5131b6ade51a0e20814466b13515&ext=.mp4';
|
||||
curruentRoomInfo.r_avatar = 'https://merlin.xin/avatars/avatar';
|
||||
role.value = 0;
|
||||
avatarPreview.value = curruentRoomInfo.r_avatar;
|
||||
|
||||
|
||||
watchEffect(() => {
|
||||
if (curruentRoomInfo.r_id !== '' && curruentRoomInfo.r_id !== null) { // 如果 r_id 不为空
|
||||
console.log('Room ID is available:', curruentRoomInfo.r_id);
|
||||
//页面需要加载的逻辑
|
||||
|
||||
//头像预览容器赋值
|
||||
avatarPreview.value = curruentRoomInfo.r_avatar;
|
||||
onMounted(async () => {
|
||||
const r_id = window.location.search.split('=')[1];
|
||||
// 新窗口只进房间时不会经过 home,getUserInfo 可能从未执行,导致 id/u_id 仍是默认值
|
||||
if (
|
||||
userinfo.token &&
|
||||
(!String(userinfo.user?.u_id || '').trim() || userinfo.user?.id === 0)
|
||||
) {
|
||||
const ok = await getUserInfo();
|
||||
if (!ok && getSelfFrom() === 0) {
|
||||
ElMessage.warning('用户信息未就绪,播放同步里的 from 可能为 0,请重新登录或从首页进入房间');
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
console.log("[playroom-debug][room onMounted 即将连 WS]", {
|
||||
"user.id": userinfo.user?.id,
|
||||
"id 非 0": typeof userinfo.user?.id === "number" && userinfo.user.id > 0,
|
||||
"user.u_id": userinfo.user?.u_id,
|
||||
getSelfFrom: getSelfFrom(),
|
||||
hasToken: Boolean(userinfo.token),
|
||||
note: "房间 WebSocket 仅用 r_id + token 子协议,不校验 id;from 为 0 是发消息时 store 里 id/u_id 仍为空",
|
||||
});
|
||||
await getRoomInfo(r_id)
|
||||
connectWebSocket(r_id)
|
||||
|
||||
window.addEventListener(ROOM_SOCKET_VIDEO_SYNC_EVENT, syncListener);
|
||||
window.addEventListener(ROOM_SOCKET_VIDEO_PLAY_EVENT, playListener);
|
||||
window.addEventListener(ROOM_SOCKET_VIDEO_PAUSE_EVENT, pauseListener);
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener(ROOM_SOCKET_VIDEO_SYNC_EVENT, syncListener);
|
||||
window.removeEventListener(ROOM_SOCKET_VIDEO_PLAY_EVENT, playListener);
|
||||
window.removeEventListener(ROOM_SOCKET_VIDEO_PAUSE_EVENT, pauseListener);
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
@@ -396,11 +547,12 @@ onMounted(() => {
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
top: 50px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 80vh;
|
||||
width: 80vw;
|
||||
height: 80%;
|
||||
width: 100%;
|
||||
padding: 50px;
|
||||
max-height: 100vh;
|
||||
}
|
||||
|
||||
/* .maincontent {} */
|
||||
@@ -1,496 +1,498 @@
|
||||
<template>
|
||||
<el-row>
|
||||
<el-col :span="12">
|
||||
<div class="updateProfile">
|
||||
<div class="profile">
|
||||
<img :src="avatarPreview" alt="头像">
|
||||
</div>
|
||||
<el-col span="12">
|
||||
<input type="file" id="avatar" @change="handleAvatarChange" accept="image/*" />
|
||||
</el-col>
|
||||
<el-col span="12">
|
||||
<el-button class="re-profile" @click="uploadAvatar">修改头像</el-button>
|
||||
</el-col>
|
||||
<el-col span="12">
|
||||
<span>推荐头像参数:<br>
|
||||
分辨率:1051*1051 96dpi <br>
|
||||
格式:jpg、png <br>
|
||||
大小:≤1M
|
||||
</span>
|
||||
</el-col>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-row>
|
||||
<span style="width: 80%;">用户名:{{ userinfo.user.u_name }}</span>
|
||||
<el-button @click="dialogVisibleChangeName = true">修改名字</el-button>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<span style="width: 80%;">账号:{{ userinfo.account }}</span>
|
||||
<!-- <el-button @click="dialogVisibleChangeAccount = true">修改账号</el-button> -->
|
||||
</el-row>
|
||||
<el-row>
|
||||
<span style="width: 80%;">个性签名:{{ userinfo.user.u_introduction }}</span>
|
||||
<el-button @click="dialogVisibleChangeIntro = true">修改简介</el-button>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<span style="width: 80%;">密码: *********************</span>
|
||||
<el-button @click="dialogVisibleChangePassword = true">修改密码</el-button>
|
||||
</el-row>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<!-- 修改名字弹窗 -->
|
||||
<el-dialog v-model="dialogVisibleChangeName" title="修改名字" width="500">
|
||||
<el-input v-model="newname" autocomplete="off" />
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="dialogVisibleChangeName = false">算了</el-button>
|
||||
<el-button type="primary" @click="changeName">
|
||||
改!
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<!-- 修改个签弹窗 -->
|
||||
<el-dialog v-model="dialogVisibleChangeIntro" title="修改个签" width="500">
|
||||
<el-input v-model="newintro" autocomplete="off" size="large" />
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="dialogVisibleChangeIntro = false">算了</el-button>
|
||||
<el-button type="primary" @click="changeIntro">
|
||||
改!
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<!-- 修改邮箱弹窗 -->
|
||||
<el-dialog v-model="dialogVisibleChangeAccount" title="修改邮箱" width="500">
|
||||
<el-row style="margin-bottom: 10px;">
|
||||
<el-col span="6">当前邮箱:</el-col>
|
||||
<el-col span="18">{{ userinfo.user.u_account }}</el-col>
|
||||
</el-row>
|
||||
<el-form ref="formRef" :model="formData" :rules="rules">
|
||||
<el-form-item label="新邮箱" prop="newaccount">
|
||||
<el-col span="24">
|
||||
<el-input v-model="formData.newaccount" autocomplete="off" placeholder="请输入邮箱地址"
|
||||
style="width: 300px;" />
|
||||
</el-col>
|
||||
</el-form-item>
|
||||
<el-form-item label="验证码" prop="code">
|
||||
<el-col span="18">
|
||||
<el-input v-model="formData.code" autocomplete="off" placeholder="请输入验证码" style="width: 225px;" />
|
||||
</el-col>
|
||||
<el-col span="6">
|
||||
<button class="codebutton" type="button" :disabled="isCountingDown" @click="sendVerificationCode">
|
||||
{{ isCountingDown ? `${countdownTime} s` : 'Get Code' }}
|
||||
</button>
|
||||
</el-col>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="dialogVisibleChangeAccount = false">算了</el-button>
|
||||
<el-button type="primary" @click="changeAccount">
|
||||
改!
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-dialog>
|
||||
<!-- 修改密码弹窗 -->
|
||||
<el-dialog v-model="dialogVisibleChangePassword" title="修改密码" width="500">
|
||||
<el-form ref="formRedPassword" :model="password" :rules="passwordRules">
|
||||
<el-form-item label="原密码" prop="oldpassword">
|
||||
<el-col span="24">
|
||||
<el-input v-model="password.oldpassword" autocomplete="off" placeholder="请输入原密码"
|
||||
show-password="true" style="width: 300px;" />
|
||||
</el-col>
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码" prop="password">
|
||||
<el-col span="24">
|
||||
<el-input v-model="password.password" autocomplete="off" placeholder="请输入新密码" show-password="true"
|
||||
style="width: 300px;" />
|
||||
</el-col>
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码" prop="confirmPassword">
|
||||
<el-col span="24">
|
||||
<el-input v-model="password.confirmPassword" autocomplete="off" placeholder="确认密码"
|
||||
show-password="true" style="width: 300px;" />
|
||||
</el-col>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="dialogVisibleChangePassword = false">算了</el-button>
|
||||
<el-button type="primary" @click="changePassword">
|
||||
改!
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
</el-form>
|
||||
|
||||
</el-dialog>
|
||||
|
||||
</template>
|
||||
<script setup>
|
||||
import { userInfoStore } from '@/store/user';
|
||||
import axios from 'axios';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { reactive, ref } from 'vue'
|
||||
import { getUserInfo } from '@/functions/user';
|
||||
|
||||
const userinfo = userInfoStore();
|
||||
console.log(userinfo.account);
|
||||
|
||||
|
||||
const dialogVisibleChangeName = ref(false)
|
||||
const dialogVisibleChangeIntro = ref(false)
|
||||
const dialogVisibleChangeAccount = ref(false)
|
||||
const dialogVisibleChangePassword = ref(false)
|
||||
const isCountingDown = ref(false);
|
||||
const countdownTime = ref(60);
|
||||
|
||||
|
||||
const newname = ref('')
|
||||
const newintro = ref('')
|
||||
|
||||
const formRef = ref(null); // 表单引用
|
||||
const formData = ref({
|
||||
newaccount: '',
|
||||
v_id: '',
|
||||
code: '' // 初始化表单数据
|
||||
});
|
||||
const formRedPassword = ref(null); // 密码表单引用
|
||||
const password = ref({
|
||||
oldpassword: '',
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
}); // 密码输入框
|
||||
|
||||
// 定义验证规则
|
||||
const rules = {
|
||||
newaccount: [
|
||||
{ required: true, message: '请输入账号', trigger: 'blur' }, // 非空验证
|
||||
{ type: 'email', message: '请输入有效的邮箱地址', trigger: ['blur', 'change'] } // 邮箱格式验证
|
||||
],
|
||||
code: [
|
||||
{ required: true, message: '请输入验证码', trigger: 'blur' }, // 非空验证
|
||||
]
|
||||
};
|
||||
// 定义密码验证规则
|
||||
const passwordRules = {
|
||||
oldpassword: [
|
||||
{ required: true, message: '请输入原密码', trigger: 'blur' }, // 非空验证
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入新密码', trigger: 'blur' }, // 非空验证
|
||||
{ min: 6, message: '密码长度至少6位', trigger: 'blur' }, // 密码长度验证
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: '请确认密码', trigger: 'blur' }, // 非空验证
|
||||
{ min: 6, message: '密码长度至少6位', trigger: 'blur' }, // 密码长度验证
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
|
||||
//修改头像部分
|
||||
//预览头像
|
||||
const avatarPreview = ref(userinfo.user.u_avatar);
|
||||
const avatar = ref(null); // 存储上传的头像文件
|
||||
|
||||
|
||||
// 处理头像预览并将文件保存到变量
|
||||
const handleAvatarChange = (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
avatar.value = file;
|
||||
avatarPreview.value = URL.createObjectURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
//上传到服务器
|
||||
const uploadAvatar = () => {
|
||||
if(!avatar.value){
|
||||
ElMessage('请选择头像')
|
||||
return
|
||||
}
|
||||
const formdata = new FormData();
|
||||
formdata.append('file', avatar.value)
|
||||
formdata.append('id', userinfo.user.u_id)
|
||||
axios({
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
'Authorization': userinfo.token
|
||||
},
|
||||
method: 'post',
|
||||
url: '/api/avatar/upload',
|
||||
data: formdata
|
||||
}).then((response) => {
|
||||
if (response.data.code !== 200) {
|
||||
ElMessage.error(response.data.msg || '上传失败')
|
||||
return
|
||||
}
|
||||
getUserInfo()
|
||||
ElMessage.success('上传成功')
|
||||
}).catch((error) => {
|
||||
console.error('上传失败:', error);
|
||||
ElMessage.error('上传失败')
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
const changeName = async () => {
|
||||
|
||||
const response = await axios.post('/api/user/updatename', {
|
||||
u_name: newname.value
|
||||
}
|
||||
, {
|
||||
headers: {
|
||||
'Authorization': userinfo.token,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
if (response.data.code === 200) {
|
||||
userinfo.user.u_name = newname.value
|
||||
newname.value = ''
|
||||
dialogVisibleChangeName.value = false
|
||||
ElMessage.success('修改成功')
|
||||
} else {
|
||||
ElMessage.error(response.data.msg)
|
||||
}
|
||||
}
|
||||
|
||||
const changeIntro = async () => {
|
||||
console.log(newintro.value);
|
||||
const response = await axios.post('/api/user/updateintroduction', {
|
||||
u_introduction: newintro.value
|
||||
}, {
|
||||
headers: {
|
||||
'Authorization': userinfo.token,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
if (response.data.code === 200) {
|
||||
userinfo.user.u_introduction = newintro.value
|
||||
newintro.value = ''
|
||||
dialogVisibleChangeIntro.value = false
|
||||
ElMessage.success('修改成功')
|
||||
} else {
|
||||
ElMessage.error(response.data.msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 发送验证码
|
||||
const sendVerificationCode = () => {
|
||||
formRef.value.validateField('newaccount', (isValid) => {
|
||||
console.log(isValid);
|
||||
if (isValid) {
|
||||
// 如果邮箱验证通过
|
||||
ElMessage.success('邮箱格式正确,正在发送验证码...');
|
||||
axios({
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
method: "post",
|
||||
url: "/api/code/sendcode",
|
||||
data: JSON.stringify({ u_account: formData.value.newaccount })
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.data && response.data.code !== 200) {
|
||||
ElMessageBox.alert("注册失败,请重新填写 msg:" + (response.data.msg || '未知错误'), '注册失败');
|
||||
} else {
|
||||
startCountdown();
|
||||
formData.value.v_id = response.data.data;
|
||||
console.log(formData.value.v_id);
|
||||
ElMessageBox.alert('发送成功,请耐心等待', '请求成功');
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("请求失败:", error);
|
||||
ElMessageBox.alert("请求失败,请稍后再试", '网络错误');
|
||||
});
|
||||
} else {
|
||||
// 如果邮箱验证失败
|
||||
ElMessage.error('请输入有效的邮箱地址');
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
|
||||
// 开始倒计时
|
||||
const startCountdown = () => {
|
||||
isCountingDown.value = true;
|
||||
const interval = setInterval(() => {
|
||||
countdownTime.value--;
|
||||
if (countdownTime.value <= 0) {
|
||||
clearInterval(interval);
|
||||
isCountingDown.value = false;
|
||||
countdownTime.value = 60;
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
|
||||
// 验证验证码,并进行邮箱更改
|
||||
const changeAccount = async () => {
|
||||
|
||||
// 验证新邮箱字段
|
||||
formRef.value.validateField(['newaccount', 'code'], (errorMessage) => {
|
||||
console.log(errorMessage);
|
||||
if (errorMessage) {
|
||||
// 如果验证通过(errorMessage为空)
|
||||
ElMessage.success('正在修改邮箱...');
|
||||
// 验证验证码
|
||||
axios({
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
method: "post",
|
||||
url: "/api/code/verifycode",
|
||||
data: {
|
||||
v_id: formData.value.v_id,
|
||||
code: formData.value.code
|
||||
}
|
||||
}).then((response) => {
|
||||
if (response.data.code !== 200) {
|
||||
ElMessage.error(response.data.msg || '验证码错误');
|
||||
return;
|
||||
} else {
|
||||
// 验证码验证通过,发起修改邮箱的请求
|
||||
axios({
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': userinfo.token
|
||||
},
|
||||
method: "post",
|
||||
url: "/api/user/updateaccount",
|
||||
data: {
|
||||
u_account: formData.value.newaccount
|
||||
}
|
||||
}).then((response) => {
|
||||
if (response.data.code !== 200) {
|
||||
ElMessage.error(response.data.msg || '修改失败');
|
||||
return;
|
||||
} else {
|
||||
ElMessage.success('邮箱修改成功');
|
||||
userinfo.user.u_account = formData.value.newaccount; // 更新当前邮箱
|
||||
formRef.value.resetFields(); // 重置表单数据
|
||||
formRef.value.clearValidate(); // 清除验证错误
|
||||
dialogVisibleChangeAccount.value = false; // 关闭弹窗
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// 如果验证失败(errorMessage不为空)
|
||||
ElMessage.error('请输入正确的验证码');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 修改密码
|
||||
const changePassword = async () => {
|
||||
// 验证密码字段
|
||||
formRedPassword.value.validateField(['oldpassword', 'password', 'confirmPassword'], (errorMessage) => {
|
||||
console.log(errorMessage);
|
||||
if (password.value.password !== password.value.confirmPassword) {
|
||||
ElMessage.error('两次密码输入不一致');
|
||||
return;
|
||||
}
|
||||
if (password.value.password === password.value.oldpassword) {
|
||||
ElMessage.error('密码一样你改密码呢');
|
||||
return;
|
||||
}
|
||||
if (errorMessage) {
|
||||
// 如果验证通过(errorMessage为空)
|
||||
ElMessage.success('正在修改密码...');
|
||||
// 发送修改密码请求
|
||||
axios({
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Authorization': userinfo.token
|
||||
},
|
||||
method: "post",
|
||||
url: "/api/user/updatepassword",
|
||||
data: password.value
|
||||
}).then((response) => {
|
||||
if (response.data.code !== 200) {
|
||||
ElMessage.error(response.data.msg || '修改失败');
|
||||
return;
|
||||
} else {
|
||||
ElMessage.success('密码修改成功');
|
||||
formRedPassword.value.resetFields(); // 重置表单数据
|
||||
formRedPassword.value.clearValidate(); // 清除验证错误
|
||||
dialogVisibleChangePassword.value = false; // 关闭弹窗
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// 如果验证失败(errorMessage不为空)
|
||||
ElMessage.error('请输入正确的密码');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
</script>
|
||||
<style>
|
||||
.updateProfile {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.profile {
|
||||
top: 50px;
|
||||
left: 100px;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
border-radius: 50%;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.profile img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.userinfo {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.re-profile {
|
||||
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.el-row {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
position: relative;
|
||||
margin-left: 300px;
|
||||
}
|
||||
|
||||
.codebutton {
|
||||
width: 75px;
|
||||
padding: 8px;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.codebutton:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
.codebutton:disabled {
|
||||
background-color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
<template>
|
||||
<el-row>
|
||||
<el-col :span="12">
|
||||
<div class="updateProfile">
|
||||
<div class="profile">
|
||||
<!-- 原逻辑::src="avatarPreview"(远端头像/本地预览) -->
|
||||
<img :src="defaultAvatar" alt="头像">
|
||||
</div>
|
||||
<el-col span="12">
|
||||
<input type="file" id="avatar" @change="handleAvatarChange" accept="image/*" />
|
||||
</el-col>
|
||||
<el-col span="12">
|
||||
<el-button class="re-profile" @click="uploadAvatar">修改头像</el-button>
|
||||
</el-col>
|
||||
<el-col span="12">
|
||||
<span>推荐头像参数:<br>
|
||||
分辨率:1051*1051 96dpi <br>
|
||||
格式:jpg、png <br>
|
||||
大小:≤1M
|
||||
</span>
|
||||
</el-col>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-row>
|
||||
<span style="width: 80%;">用户名:{{ userinfo.user.u_name }}</span>
|
||||
<el-button @click="dialogVisibleChangeName = true">修改名字</el-button>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<span style="width: 80%;">账号:{{ userinfo.account }}</span>
|
||||
<!-- <el-button @click="dialogVisibleChangeAccount = true">修改账号</el-button> -->
|
||||
</el-row>
|
||||
<el-row>
|
||||
<span style="width: 80%;">个性签名:{{ userinfo.user.u_introduction }}</span>
|
||||
<el-button @click="dialogVisibleChangeIntro = true">修改简介</el-button>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<span style="width: 80%;">密码: *********************</span>
|
||||
<el-button @click="dialogVisibleChangePassword = true">修改密码</el-button>
|
||||
</el-row>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<!-- 修改名字弹窗 -->
|
||||
<el-dialog v-model="dialogVisibleChangeName" title="修改名字" width="500">
|
||||
<el-input v-model="newname" autocomplete="off" />
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="dialogVisibleChangeName = false">算了</el-button>
|
||||
<el-button type="primary" @click="changeName">
|
||||
改!
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<!-- 修改个签弹窗 -->
|
||||
<el-dialog v-model="dialogVisibleChangeIntro" title="修改个签" width="500">
|
||||
<el-input v-model="newintro" autocomplete="off" size="large" />
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="dialogVisibleChangeIntro = false">算了</el-button>
|
||||
<el-button type="primary" @click="changeIntro">
|
||||
改!
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<!-- 修改邮箱弹窗 -->
|
||||
<el-dialog v-model="dialogVisibleChangeAccount" title="修改邮箱" width="500">
|
||||
<el-row style="margin-bottom: 10px;">
|
||||
<el-col span="6">当前邮箱:</el-col>
|
||||
<el-col span="18">{{ userinfo.user.u_account }}</el-col>
|
||||
</el-row>
|
||||
<el-form ref="formRef" :model="formData" :rules="rules">
|
||||
<el-form-item label="新邮箱" prop="newaccount">
|
||||
<el-col span="24">
|
||||
<el-input v-model="formData.newaccount" autocomplete="off" placeholder="请输入邮箱地址"
|
||||
style="width: 300px;" />
|
||||
</el-col>
|
||||
</el-form-item>
|
||||
<el-form-item label="验证码" prop="code">
|
||||
<el-col span="18">
|
||||
<el-input v-model="formData.code" autocomplete="off" placeholder="请输入验证码" style="width: 225px;" />
|
||||
</el-col>
|
||||
<el-col span="6">
|
||||
<button class="codebutton" type="button" :disabled="isCountingDown" @click="sendVerificationCode">
|
||||
{{ isCountingDown ? `${countdownTime} s` : 'Get Code' }}
|
||||
</button>
|
||||
</el-col>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="dialogVisibleChangeAccount = false">算了</el-button>
|
||||
<el-button type="primary" @click="changeAccount">
|
||||
改!
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-dialog>
|
||||
<!-- 修改密码弹窗 -->
|
||||
<el-dialog v-model="dialogVisibleChangePassword" title="修改密码" width="500">
|
||||
<el-form ref="formRedPassword" :model="password" :rules="passwordRules">
|
||||
<el-form-item label="原密码" prop="oldpassword">
|
||||
<el-col span="24">
|
||||
<el-input v-model="password.oldpassword" autocomplete="off" placeholder="请输入原密码"
|
||||
show-password="true" style="width: 300px;" />
|
||||
</el-col>
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码" prop="password">
|
||||
<el-col span="24">
|
||||
<el-input v-model="password.password" autocomplete="off" placeholder="请输入新密码" show-password="true"
|
||||
style="width: 300px;" />
|
||||
</el-col>
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码" prop="confirmPassword">
|
||||
<el-col span="24">
|
||||
<el-input v-model="password.confirmPassword" autocomplete="off" placeholder="确认密码"
|
||||
show-password="true" style="width: 300px;" />
|
||||
</el-col>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="dialogVisibleChangePassword = false">算了</el-button>
|
||||
<el-button type="primary" @click="changePassword">
|
||||
改!
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
</el-form>
|
||||
|
||||
</el-dialog>
|
||||
|
||||
</template>
|
||||
<script setup>
|
||||
import { userInfoStore } from '@/store/user';
|
||||
import axios from 'axios';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { reactive, ref } from 'vue'
|
||||
import { getUserInfo } from '@/functions/user';
|
||||
import defaultAvatar from '@/assets/defaultavatar.jpg'
|
||||
|
||||
const userinfo = userInfoStore();
|
||||
console.log(userinfo.account);
|
||||
|
||||
|
||||
const dialogVisibleChangeName = ref(false)
|
||||
const dialogVisibleChangeIntro = ref(false)
|
||||
const dialogVisibleChangeAccount = ref(false)
|
||||
const dialogVisibleChangePassword = ref(false)
|
||||
const isCountingDown = ref(false);
|
||||
const countdownTime = ref(60);
|
||||
|
||||
|
||||
const newname = ref('')
|
||||
const newintro = ref('')
|
||||
|
||||
const formRef = ref(null); // 表单引用
|
||||
const formData = ref({
|
||||
newaccount: '',
|
||||
v_id: '',
|
||||
code: '' // 初始化表单数据
|
||||
});
|
||||
const formRedPassword = ref(null); // 密码表单引用
|
||||
const password = ref({
|
||||
oldpassword: '',
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
}); // 密码输入框
|
||||
|
||||
// 定义验证规则
|
||||
const rules = {
|
||||
newaccount: [
|
||||
{ required: true, message: '请输入账号', trigger: 'blur' }, // 非空验证
|
||||
{ type: 'email', message: '请输入有效的邮箱地址', trigger: ['blur', 'change'] } // 邮箱格式验证
|
||||
],
|
||||
code: [
|
||||
{ required: true, message: '请输入验证码', trigger: 'blur' }, // 非空验证
|
||||
]
|
||||
};
|
||||
// 定义密码验证规则
|
||||
const passwordRules = {
|
||||
oldpassword: [
|
||||
{ required: true, message: '请输入原密码', trigger: 'blur' }, // 非空验证
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入新密码', trigger: 'blur' }, // 非空验证
|
||||
{ min: 6, message: '密码长度至少6位', trigger: 'blur' }, // 密码长度验证
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: '请确认密码', trigger: 'blur' }, // 非空验证
|
||||
{ min: 6, message: '密码长度至少6位', trigger: 'blur' }, // 密码长度验证
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
|
||||
//修改头像部分
|
||||
//预览头像
|
||||
// const avatarPreview = ref(userinfo.user.u_avatar); // 原:远端头像预览
|
||||
const avatar = ref(null); // 存储上传的头像文件
|
||||
|
||||
|
||||
// 处理头像预览并将文件保存到变量
|
||||
const handleAvatarChange = (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
avatar.value = file;
|
||||
// avatarPreview.value = URL.createObjectURL(file); // 原:本地预览
|
||||
}
|
||||
};
|
||||
|
||||
//上传到服务器
|
||||
const uploadAvatar = () => {
|
||||
if(!avatar.value){
|
||||
ElMessage('请选择头像')
|
||||
return
|
||||
}
|
||||
const formdata = new FormData();
|
||||
formdata.append('file', avatar.value)
|
||||
formdata.append('id', userinfo.user.u_id)
|
||||
axios({
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
'Authorization': userinfo.token
|
||||
},
|
||||
method: 'post',
|
||||
url: '/api/avatar/upload',
|
||||
data: formdata
|
||||
}).then((response) => {
|
||||
if (response.data.code !== 200) {
|
||||
ElMessage.error(response.data.msg || '上传失败')
|
||||
return
|
||||
}
|
||||
getUserInfo()
|
||||
ElMessage.success('上传成功')
|
||||
}).catch((error) => {
|
||||
console.error('上传失败:', error);
|
||||
ElMessage.error('上传失败')
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
const changeName = async () => {
|
||||
|
||||
const response = await axios.post('/api/user/updatename', {
|
||||
u_name: newname.value
|
||||
}
|
||||
, {
|
||||
headers: {
|
||||
'Authorization': userinfo.token,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
if (response.data.code === 200) {
|
||||
userinfo.user.u_name = newname.value
|
||||
newname.value = ''
|
||||
dialogVisibleChangeName.value = false
|
||||
ElMessage.success('修改成功')
|
||||
} else {
|
||||
ElMessage.error(response.data.msg)
|
||||
}
|
||||
}
|
||||
|
||||
const changeIntro = async () => {
|
||||
console.log(newintro.value);
|
||||
const response = await axios.post('/api/user/updateintroduction', {
|
||||
u_introduction: newintro.value
|
||||
}, {
|
||||
headers: {
|
||||
'Authorization': userinfo.token,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
if (response.data.code === 200) {
|
||||
userinfo.user.u_introduction = newintro.value
|
||||
newintro.value = ''
|
||||
dialogVisibleChangeIntro.value = false
|
||||
ElMessage.success('修改成功')
|
||||
} else {
|
||||
ElMessage.error(response.data.msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 发送验证码
|
||||
const sendVerificationCode = () => {
|
||||
formRef.value.validateField('newaccount', (isValid) => {
|
||||
console.log(isValid);
|
||||
if (isValid) {
|
||||
// 如果邮箱验证通过
|
||||
ElMessage.success('邮箱格式正确,正在发送验证码...');
|
||||
axios({
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
method: "post",
|
||||
url: "/api/code/sendcode",
|
||||
data: JSON.stringify({ u_account: formData.value.newaccount })
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.data && response.data.code !== 200) {
|
||||
ElMessageBox.alert("注册失败,请重新填写 msg:" + (response.data.msg || '未知错误'), '注册失败');
|
||||
} else {
|
||||
startCountdown();
|
||||
formData.value.v_id = response.data.data;
|
||||
console.log(formData.value.v_id);
|
||||
ElMessageBox.alert('发送成功,请耐心等待', '请求成功');
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("请求失败:", error);
|
||||
ElMessageBox.alert("请求失败,请稍后再试", '网络错误');
|
||||
});
|
||||
} else {
|
||||
// 如果邮箱验证失败
|
||||
ElMessage.error('请输入有效的邮箱地址');
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
|
||||
// 开始倒计时
|
||||
const startCountdown = () => {
|
||||
isCountingDown.value = true;
|
||||
const interval = setInterval(() => {
|
||||
countdownTime.value--;
|
||||
if (countdownTime.value <= 0) {
|
||||
clearInterval(interval);
|
||||
isCountingDown.value = false;
|
||||
countdownTime.value = 60;
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
|
||||
// 验证验证码,并进行邮箱更改
|
||||
const changeAccount = async () => {
|
||||
|
||||
// 验证新邮箱字段
|
||||
formRef.value.validateField(['newaccount', 'code'], (errorMessage) => {
|
||||
console.log(errorMessage);
|
||||
if (errorMessage) {
|
||||
// 如果验证通过(errorMessage为空)
|
||||
ElMessage.success('正在修改邮箱...');
|
||||
// 验证验证码
|
||||
axios({
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
method: "post",
|
||||
url: "/api/code/verifycode",
|
||||
data: {
|
||||
v_id: formData.value.v_id,
|
||||
code: formData.value.code
|
||||
}
|
||||
}).then((response) => {
|
||||
if (response.data.code !== 200) {
|
||||
ElMessage.error(response.data.msg || '验证码错误');
|
||||
return;
|
||||
} else {
|
||||
// 验证码验证通过,发起修改邮箱的请求
|
||||
axios({
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': userinfo.token
|
||||
},
|
||||
method: "post",
|
||||
url: "/api/user/updateaccount",
|
||||
data: {
|
||||
u_account: formData.value.newaccount
|
||||
}
|
||||
}).then((response) => {
|
||||
if (response.data.code !== 200) {
|
||||
ElMessage.error(response.data.msg || '修改失败');
|
||||
return;
|
||||
} else {
|
||||
ElMessage.success('邮箱修改成功');
|
||||
userinfo.user.u_account = formData.value.newaccount; // 更新当前邮箱
|
||||
formRef.value.resetFields(); // 重置表单数据
|
||||
formRef.value.clearValidate(); // 清除验证错误
|
||||
dialogVisibleChangeAccount.value = false; // 关闭弹窗
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// 如果验证失败(errorMessage不为空)
|
||||
ElMessage.error('请输入正确的验证码');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 修改密码
|
||||
const changePassword = async () => {
|
||||
// 验证密码字段
|
||||
formRedPassword.value.validateField(['oldpassword', 'password', 'confirmPassword'], (errorMessage) => {
|
||||
console.log(errorMessage);
|
||||
if (password.value.password !== password.value.confirmPassword) {
|
||||
ElMessage.error('两次密码输入不一致');
|
||||
return;
|
||||
}
|
||||
if (password.value.password === password.value.oldpassword) {
|
||||
ElMessage.error('密码一样你改密码呢');
|
||||
return;
|
||||
}
|
||||
if (errorMessage) {
|
||||
// 如果验证通过(errorMessage为空)
|
||||
ElMessage.success('正在修改密码...');
|
||||
// 发送修改密码请求
|
||||
axios({
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Authorization': userinfo.token
|
||||
},
|
||||
method: "post",
|
||||
url: "/api/user/updatepassword",
|
||||
data: password.value
|
||||
}).then((response) => {
|
||||
if (response.data.code !== 200) {
|
||||
ElMessage.error(response.data.msg || '修改失败');
|
||||
return;
|
||||
} else {
|
||||
ElMessage.success('密码修改成功');
|
||||
formRedPassword.value.resetFields(); // 重置表单数据
|
||||
formRedPassword.value.clearValidate(); // 清除验证错误
|
||||
dialogVisibleChangePassword.value = false; // 关闭弹窗
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// 如果验证失败(errorMessage不为空)
|
||||
ElMessage.error('请输入正确的密码');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
</script>
|
||||
<style>
|
||||
.updateProfile {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.profile {
|
||||
top: 50px;
|
||||
left: 100px;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
border-radius: 50%;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.profile img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.userinfo {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.re-profile {
|
||||
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.el-row {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
position: relative;
|
||||
margin-left: 300px;
|
||||
}
|
||||
|
||||
.codebutton {
|
||||
width: 75px;
|
||||
padding: 8px;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.codebutton:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
.codebutton:disabled {
|
||||
background-color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -1,204 +1,218 @@
|
||||
import { ref } from "vue";
|
||||
import { userInfoStore } from "@/store/store";
|
||||
import { messageStore } from "@/store/message";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { messageSignStore } from "@/store/message_sign";
|
||||
|
||||
// userinfo 实例
|
||||
const userinfo = userInfoStore();
|
||||
// message 实例
|
||||
const message = messageStore();
|
||||
// groupMessage 实例
|
||||
// const groupMessage = groupMessageStore();
|
||||
// messageSignStoregn 实例
|
||||
// const messageSign = messageSignStore();
|
||||
|
||||
// WebSocket 实例
|
||||
const socket = ref(null);
|
||||
const messageSign = messageSignStore();
|
||||
|
||||
const isManualClose = ref(false);
|
||||
|
||||
const reconnectScheduled = ref(false);
|
||||
|
||||
const retryCount = ref(0);
|
||||
|
||||
export const getRetryCount = () => {
|
||||
return retryCount.value;
|
||||
};
|
||||
|
||||
export const addRetryCount = () => {
|
||||
retryCount.value = retryCount.value + 1;
|
||||
};
|
||||
|
||||
export const resetRetryCount = () => {
|
||||
retryCount.value = 0;
|
||||
};
|
||||
export const setReconnectScheduled = (value) => {
|
||||
reconnectScheduled.value = value;
|
||||
};
|
||||
|
||||
export const getReconnectScheduled = () => {
|
||||
return reconnectScheduled.value;
|
||||
};
|
||||
|
||||
export const setIsManualClose = (value) => {
|
||||
isManualClose.value = value;
|
||||
};
|
||||
|
||||
export const getIsManualClose = () => {
|
||||
return isManualClose.value;
|
||||
};
|
||||
|
||||
// 连接WebSocket
|
||||
export const connectWebSocket = () => {
|
||||
const protocol = window.location.protocol === "https:" ? "wss://" : "ws://";
|
||||
const host = window.location.host;
|
||||
const socketUrl = `${protocol}${host}/ws/online?name=${userinfo.user.u_name}`;
|
||||
|
||||
// const socketUrl = `ws://localhost:8080/online?u_id=${userinfo.user.u_id}&u_name=${userinfo.user.u_name}`;
|
||||
if (socket.value && socket.value.readyState !== WebSocket.CLOSED) {
|
||||
console.log("还在重连中...");
|
||||
return;
|
||||
}
|
||||
const retrytime = getRetryCount();
|
||||
if (retrytime >= 10) {
|
||||
console.log("重连失败,请稍后再试");
|
||||
return;
|
||||
}
|
||||
console.log(retrytime);
|
||||
socket.value = new WebSocket(socketUrl, "token-"+ userinfo.token);
|
||||
|
||||
socket.value.onopen = (event) => {
|
||||
console.log("WebSocket连接已建立", event);
|
||||
setReconnectScheduled(false);
|
||||
setIsManualClose(false);
|
||||
resetRetryCount();
|
||||
};
|
||||
|
||||
//处理消息逻辑
|
||||
socket.value.onmessage = (event) => {
|
||||
console.log("从服务器收到消息:", event.data);
|
||||
try{
|
||||
const MessageData = JSON.parse(event.data);
|
||||
const cmd = MessageData.cmd;
|
||||
switch(cmd){
|
||||
case "MESSAGE":
|
||||
message.addMessage(MessageData);
|
||||
// messageSign.setMessageSign(true);
|
||||
// ElMessage.info("您有一条新的消息");
|
||||
break;
|
||||
case "PERSONAL_NOTIFY":
|
||||
messageSign.setMessageSign(true);
|
||||
ElMessage.info("您有一条新的邀请消息");
|
||||
break;
|
||||
}
|
||||
}catch(error){
|
||||
console.error("解析 JSON 失败:", error);
|
||||
}
|
||||
// try {
|
||||
// const MessageData = JSON.parse(event.data);
|
||||
// //是否为上下线消息(三类消息)
|
||||
// if (!MessageData.system) {
|
||||
// //是否为新消息
|
||||
// if (MessageData.message) {
|
||||
// messagePoint.hasNewMessage = true;
|
||||
// if (MessageData.group) {
|
||||
// //四类消息,群聊消息
|
||||
// console.log("有新群消息");
|
||||
// groupMessage.addMessage(MessageData);
|
||||
// console.log(groupMessage.messages);
|
||||
// messageSign.addSign({
|
||||
// sender_name: MessageData.sender_name,
|
||||
// g_id: MessageData.g_id,
|
||||
// g_name: MessageData.g_name,
|
||||
// });
|
||||
// } else {
|
||||
// //一类消息,私聊消息
|
||||
// console.log("有新消息");
|
||||
// message.addMessage(MessageData);
|
||||
// console.log(message.messages);
|
||||
// messageSign.addSign({
|
||||
// sender: MessageData.sender,
|
||||
// sender_name: MessageData.sender_name,
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
// } else {
|
||||
// if (MessageData.engaged) {
|
||||
// // 五类消息,账号重复登录逻辑
|
||||
// setIsManualClose(true);
|
||||
// disconnectWebSocket();
|
||||
// ElMessage("账号重复登录,请注意密码泄露");
|
||||
// console.log("账号重复登陆");
|
||||
// window.location.replace("/");
|
||||
// } else {
|
||||
// //三类消息,指示用户上下线
|
||||
// if (MessageData.status === "online")
|
||||
// ElMessage("用户:" + MessageData.u_name + "上线");
|
||||
// else {
|
||||
// ElMessage("用户:" + MessageData.u_name + "下线");
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// } catch (error) {
|
||||
// console.error("解析 JSON 失败:", error);
|
||||
// }
|
||||
};
|
||||
|
||||
socket.value.onerror = (error) => {
|
||||
console.error("WebSocket发生错误:", error);
|
||||
// console.log(error);
|
||||
setReconnectScheduled(true);
|
||||
socket.value.close();
|
||||
};
|
||||
|
||||
socket.value.onclose = (event) => {
|
||||
message.saveMessagesHistory(userinfo.user.id);
|
||||
if (!getIsManualClose()) {
|
||||
if (getReconnectScheduled()) {
|
||||
socket.value = null;
|
||||
addRetryCount();
|
||||
setTimeout(reConnectWebSocket, 5000);
|
||||
setReconnectScheduled(false);
|
||||
} else {
|
||||
// console.log("websocket因为浏览器省电设置断开");
|
||||
console.log("WebSocket连接已关闭", event);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// 断开WebSocket连接
|
||||
export const disconnectWebSocket = () => {
|
||||
if (socket.value && socket.value.readyState === WebSocket.OPEN) {
|
||||
socket.value.close();
|
||||
}
|
||||
};
|
||||
|
||||
// 重连机制
|
||||
export const reConnectWebSocket = () => {
|
||||
connectWebSocket();
|
||||
};
|
||||
|
||||
// 发送消息
|
||||
export const sendMessage = (message) => {
|
||||
if (socket.value && socket.value.readyState === WebSocket.OPEN) {
|
||||
socket.value.send(message);
|
||||
} else {
|
||||
console.warn("WebSocket未连接,无法发送消息");
|
||||
}
|
||||
};
|
||||
|
||||
//没有错误的重连,只是浏览器在后台断开了连接
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) {
|
||||
} else {
|
||||
if (!getIsManualClose() && socket.value.readyState === WebSocket.CLOSED) {
|
||||
if (getReconnectScheduled()) {
|
||||
return;
|
||||
}
|
||||
reConnectWebSocket();
|
||||
}
|
||||
}
|
||||
});
|
||||
import { ref } from "vue";
|
||||
import { userInfoStore } from "@/store/user";
|
||||
import { messageStore } from "@/store/message";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { messageSignStore } from "@/store/message_sign";
|
||||
import { groupMessageStore } from "@/store/group_message";
|
||||
import { onCallStore } from "@/store/VoiceTarget";
|
||||
import { handleOffer, handleAnswer, closeConnection, handleCandidate } from "@/store/Voice.ts";
|
||||
|
||||
// userinfo 实例
|
||||
const userinfo = userInfoStore();
|
||||
// message 实例
|
||||
const message = messageStore();
|
||||
// groupMessage 实例
|
||||
const groupMessage = groupMessageStore();
|
||||
// oncall 实例
|
||||
const oncall = onCallStore();
|
||||
// messageSignStoregn 实例
|
||||
// const messageSign = messageSignStore();
|
||||
|
||||
// WebSocket 实例
|
||||
const socket = ref(null);
|
||||
const messageSign = messageSignStore();
|
||||
|
||||
const isManualClose = ref(false);
|
||||
|
||||
const reconnectScheduled = ref(false);
|
||||
|
||||
const retryCount = ref(0);
|
||||
|
||||
|
||||
export const getRetryCount = () => {
|
||||
return retryCount.value;
|
||||
};
|
||||
|
||||
export const addRetryCount = () => {
|
||||
retryCount.value = retryCount.value + 1;
|
||||
};
|
||||
|
||||
export const resetRetryCount = () => {
|
||||
retryCount.value = 0;
|
||||
};
|
||||
export const setReconnectScheduled = (value) => {
|
||||
reconnectScheduled.value = value;
|
||||
};
|
||||
|
||||
export const getReconnectScheduled = () => {
|
||||
return reconnectScheduled.value;
|
||||
};
|
||||
|
||||
export const setIsManualClose = (value) => {
|
||||
isManualClose.value = value;
|
||||
};
|
||||
|
||||
export const getIsManualClose = () => {
|
||||
return isManualClose.value;
|
||||
};
|
||||
|
||||
// 连接WebSocket
|
||||
export const connectWebSocket = () => {
|
||||
const protocol = window.location.protocol === "https:" ? "wss://" : "ws://";
|
||||
const host = window.location.host;
|
||||
const socketUrl = `${protocol}${host}/ws/online?name=${userinfo.user.u_name}`;
|
||||
|
||||
// const socketUrl = `ws://localhost:8080/online?u_id=${userinfo.user.u_id}&u_name=${userinfo.user.u_name}`;
|
||||
if (socket.value && socket.value.readyState !== WebSocket.CLOSED) {
|
||||
console.log("还在重连中...");
|
||||
return;
|
||||
}
|
||||
const retrytime = getRetryCount();
|
||||
if (retrytime >= 10) {
|
||||
console.log("重连失败,请稍后再试");
|
||||
return;
|
||||
}
|
||||
console.log(retrytime);
|
||||
socket.value = new WebSocket(socketUrl, "token-"+ userinfo.token);
|
||||
|
||||
socket.value.onopen = (event) => {
|
||||
console.log("WebSocket连接已建立", event);
|
||||
setReconnectScheduled(false);
|
||||
setIsManualClose(false);
|
||||
resetRetryCount();
|
||||
};
|
||||
|
||||
//处理消息逻辑
|
||||
socket.value.onmessage = async (event) => {
|
||||
console.log("从服务器收到消息:", event.data);
|
||||
try{
|
||||
const MessageData = JSON.parse(event.data);
|
||||
const cmd = MessageData.cmd;
|
||||
switch(cmd){
|
||||
case "MESSAGE":
|
||||
message.addMessage(MessageData);
|
||||
// TODO:需要使用u_name或者u_id进行消息标记
|
||||
// messageSign.setMessageSign(true);
|
||||
// ElMessage.info("您有一条新的消息");
|
||||
break;
|
||||
case "PERSONAL_NOTIFY":
|
||||
messageSign.setMessageSign(true);
|
||||
ElMessage.info("您有一条新的邀请消息");
|
||||
break;
|
||||
case "GROUP_MESSAGE":
|
||||
groupMessage.recieveMessage(userinfo.user.id, MessageData);
|
||||
|
||||
// TODO: 需要使用groupId进行消息标记
|
||||
break;
|
||||
case "VIDEO_SYNC":
|
||||
console.log("视频同步消息", MessageData);
|
||||
break;
|
||||
|
||||
// 语音通话相关:
|
||||
case "VOICE_CALL_REQUEST":
|
||||
ElMessage.info("您有一个新的语音通话请求");
|
||||
oncall.setTarget(MessageData.from, MessageData.from_name, MessageData.from_avatar);
|
||||
oncall.panelOn();
|
||||
oncall.callingOn();
|
||||
oncall.fromOff();
|
||||
oncall.statusOff();
|
||||
break;
|
||||
case "VOICE_CALL_RESPONSE":
|
||||
console.log("收到语音通话响应消息");
|
||||
//直接跳转到icecandidate阶段,不再接收额外的响应消息
|
||||
break;
|
||||
case "VOICE_CALL_DENY":
|
||||
ElMessage.info("对方拒绝了您的语音通话请求");
|
||||
oncall.clear();
|
||||
break;
|
||||
case "VOICE_CALL_END":
|
||||
ElMessage.info("语音通话已结束");
|
||||
closeConnection();
|
||||
oncall.clear();
|
||||
break;
|
||||
case "VOICE_ICE_CANDIDATE":
|
||||
// stage3 : 收到ICE候选,添加到RTC连接中
|
||||
console.log("收到新的ICE候选");
|
||||
await handleCandidate(MessageData.content);
|
||||
break;
|
||||
case "VOICE_SDP_OFFER":
|
||||
// stage1 :收到offer,发送answer
|
||||
console.log("收到SDP Offer");
|
||||
await handleOffer(MessageData.content, MessageData.from, MessageData.to);
|
||||
break;
|
||||
case "VOICE_SDP_ANSWER":
|
||||
// stage2 : 收到answer,设置远端SDP
|
||||
console.log("收到SDP Answer");
|
||||
await handleAnswer(MessageData.content, MessageData.from, MessageData.to);
|
||||
break;
|
||||
|
||||
}
|
||||
}catch(error){
|
||||
console.error("解析 JSON 失败:", error);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
socket.value.onerror = (error) => {
|
||||
console.error("WebSocket发生错误:", error);
|
||||
// console.log(error);
|
||||
setReconnectScheduled(true);
|
||||
socket.value.close();
|
||||
};
|
||||
|
||||
socket.value.onclose = (event) => {
|
||||
message.saveMessagesHistory(userinfo.user.id);
|
||||
if (!getIsManualClose()) {
|
||||
if (getReconnectScheduled()) {
|
||||
socket.value = null;
|
||||
addRetryCount();
|
||||
setTimeout(reConnectWebSocket, 5000);
|
||||
setReconnectScheduled(false);
|
||||
} else {
|
||||
// console.log("websocket因为浏览器省电设置断开");
|
||||
console.log("WebSocket连接已关闭", event);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// 断开WebSocket连接
|
||||
export const disconnectWebSocket = () => {
|
||||
if (socket.value && socket.value.readyState === WebSocket.OPEN) {
|
||||
socket.value.close();
|
||||
}
|
||||
};
|
||||
|
||||
// 重连机制
|
||||
export const reConnectWebSocket = () => {
|
||||
connectWebSocket();
|
||||
};
|
||||
|
||||
// 发送消息
|
||||
export const sendMessage = (message) => {
|
||||
try {
|
||||
const jsonmessage = JSON.stringify(message);
|
||||
if (socket.value && socket.value.readyState === WebSocket.OPEN) {
|
||||
socket.value.send(jsonmessage);
|
||||
} else {
|
||||
console.warn("WebSocket未连接,无法发送消息");
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Failed to stringify message:", error);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
//没有错误的重连,只是浏览器在后台断开了连接
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) {
|
||||
} else {
|
||||
if (!getIsManualClose() && socket.value.readyState === WebSocket.CLOSED) {
|
||||
if (getReconnectScheduled()) {
|
||||
return;
|
||||
}
|
||||
reConnectWebSocket();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
191
src/websocket/roomSocket.ts
Normal file
191
src/websocket/roomSocket.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { ref } from "vue";
|
||||
import { userInfoStore } from "@/store/user";
|
||||
|
||||
export const ROOM_SOCKET_VIDEO_SYNC_EVENT = "room:video_sync";
|
||||
export const ROOM_SOCKET_VIDEO_PLAY_EVENT = "room:video_play";
|
||||
export const ROOM_SOCKET_VIDEO_PAUSE_EVENT = "room:video_pause";
|
||||
|
||||
// userinfo 实例
|
||||
const userinfo = userInfoStore();
|
||||
|
||||
const roomid = ref<number>(0);
|
||||
|
||||
|
||||
// WebSocket 实例
|
||||
const socket = ref<WebSocket | null>(null);
|
||||
|
||||
const isManualClose = ref<boolean>(false);
|
||||
|
||||
const reconnectScheduled = ref<boolean>(false);
|
||||
|
||||
const retryCount = ref<number>(0);
|
||||
|
||||
const getRetryCount = () => {
|
||||
return retryCount.value;
|
||||
};
|
||||
|
||||
const addRetryCount = () => {
|
||||
retryCount.value = retryCount.value + 1;
|
||||
};
|
||||
|
||||
const resetRetryCount = () => {
|
||||
retryCount.value = 0;
|
||||
};
|
||||
const setReconnectScheduled = (value: boolean) => {
|
||||
reconnectScheduled.value = value;
|
||||
};
|
||||
|
||||
const getReconnectScheduled = () => {
|
||||
return reconnectScheduled.value;
|
||||
};
|
||||
|
||||
export const setIsManualClose = (value: boolean) => {
|
||||
isManualClose.value = value;
|
||||
};
|
||||
|
||||
const getIsManualClose = () => {
|
||||
return isManualClose.value;
|
||||
};
|
||||
|
||||
// 连接WebSocket
|
||||
export const connectWebSocket = (r_id: number) => {
|
||||
roomid.value = r_id;
|
||||
const protocol = window.location.protocol === "https:" ? "wss://" : "ws://";
|
||||
const host = window.location.host;
|
||||
const socketUrl = `${protocol}${host}/ws/playroom?r_id=${r_id}`;
|
||||
|
||||
// const socketUrl = `ws://localhost:8080/online?u_id=${userinfo.user.u_id}&u_name=${userinfo.user.u_name}`;
|
||||
if (socket.value && socket.value.readyState !== WebSocket.CLOSED) {
|
||||
console.log("还在重连中...");
|
||||
return;
|
||||
}
|
||||
const retrytime = getRetryCount();
|
||||
if (retrytime >= 10) {
|
||||
console.log("重连失败,请稍后再试");
|
||||
return;
|
||||
}
|
||||
console.log(retrytime);
|
||||
// 调试:房间 WS 不依赖 user.id(URL 只有 r_id,鉴权在子协议 token);id 只影响你主动发的消息里的 from
|
||||
console.log("[playroom-debug][connectWebSocket]", {
|
||||
r_id,
|
||||
"user.id": userinfo.user?.id,
|
||||
"user.id > 0": typeof userinfo.user?.id === "number" && userinfo.user.id > 0,
|
||||
"user.u_id": userinfo.user?.u_id,
|
||||
hasToken: Boolean(userinfo.token),
|
||||
socketUrl,
|
||||
});
|
||||
socket.value = new WebSocket(socketUrl, "token-"+ userinfo.token);
|
||||
|
||||
socket.value.onopen = (event: any) => {
|
||||
console.log("[playroom-debug][ws open] 连接已建立,此时 user.id =", userinfo.user?.id, "u_id =", userinfo.user?.u_id);
|
||||
console.log("WebSocket for video 连接已建立", event);
|
||||
setReconnectScheduled(false);
|
||||
setIsManualClose(false);
|
||||
resetRetryCount();
|
||||
};
|
||||
|
||||
//处理消息逻辑
|
||||
socket.value.onmessage = (event) => {
|
||||
console.log("从服务器收到消息:", event.data);
|
||||
try{
|
||||
const MessageData = JSON.parse(event.data);
|
||||
const cmd = MessageData.cmd;
|
||||
switch(cmd){
|
||||
case "PING":
|
||||
console.log("收到PING消息");
|
||||
const msg = {
|
||||
cmd: "PONG",
|
||||
from: MessageData.to,
|
||||
// 可扩展字段
|
||||
time: new Date().toLocaleString()
|
||||
}
|
||||
sendMessage(msg);
|
||||
break;
|
||||
case "VIDEO_SYNC":
|
||||
console.log("视频同步消息", MessageData);
|
||||
// 通过事件分发给页面(避免 websocket 层直接依赖具体视图)
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(ROOM_SOCKET_VIDEO_SYNC_EVENT, { detail: MessageData })
|
||||
);
|
||||
break;
|
||||
case "VIDEO_PLAY":
|
||||
console.log("视频播放");
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(ROOM_SOCKET_VIDEO_PLAY_EVENT, { detail: MessageData })
|
||||
);
|
||||
break;
|
||||
case "VIDEO_PAUSE":
|
||||
console.log("视频暂停");
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(ROOM_SOCKET_VIDEO_PAUSE_EVENT, { detail: MessageData })
|
||||
);
|
||||
break;
|
||||
}
|
||||
}catch(error){
|
||||
console.error("解析 JSON 失败:", error);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
socket.value.onerror = (error) => {
|
||||
console.error("WebSocket for video 发生错误:", error);
|
||||
// console.log(error);
|
||||
setReconnectScheduled(true);
|
||||
socket.value.close();
|
||||
};
|
||||
|
||||
socket.value.onclose = (event) => {
|
||||
if (!getIsManualClose()) {
|
||||
if (getReconnectScheduled()) {
|
||||
socket.value = null;
|
||||
addRetryCount();
|
||||
setTimeout(reConnectWebSocket, 5000);
|
||||
setReconnectScheduled(false);
|
||||
} else {
|
||||
// console.log("websocket因为浏览器省电设置断开");
|
||||
console.log("WebSocket for video 连接已关闭", event);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// 断开WebSocket连接
|
||||
export const disconnectWebSocket = () => {
|
||||
if (socket.value && socket.value.readyState === WebSocket.OPEN) {
|
||||
roomid.value = 0;
|
||||
socket.value.close();
|
||||
}
|
||||
};
|
||||
|
||||
// 重连机制
|
||||
export const reConnectWebSocket = () => {
|
||||
connectWebSocket(roomid.value);
|
||||
};
|
||||
|
||||
// 发送消息
|
||||
export const sendMessage = (message: any) => {
|
||||
try{
|
||||
const jsonmessage = JSON.stringify(message);
|
||||
if (socket.value && socket.value.readyState === WebSocket.OPEN) {
|
||||
socket.value.send(jsonmessage);
|
||||
} else {
|
||||
console.warn("WebSocket for video 未连接,无法发送消息");
|
||||
}
|
||||
}
|
||||
catch(error){
|
||||
console.error("Failed to stringify message:", error);
|
||||
}
|
||||
};
|
||||
|
||||
//没有错误的重连,只是浏览器在后台断开了连接
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) {
|
||||
} else {
|
||||
if (!getIsManualClose() && socket.value.readyState === WebSocket.CLOSED) {
|
||||
if (getReconnectScheduled()) {
|
||||
return;
|
||||
}
|
||||
reConnectWebSocket();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,287 +1,286 @@
|
||||
import { ref } from "vue";
|
||||
import { userInfoStore } from "@/store/store";
|
||||
import { onCallStore } from "@/store/VoiceTarget";
|
||||
import { ElMessage } from "element-plus";
|
||||
|
||||
// userinfo 实例
|
||||
const userinfo = userInfoStore();
|
||||
// oncall 实例
|
||||
const oncall = onCallStore();
|
||||
// WebSocket 实例
|
||||
const socket = ref(null);
|
||||
|
||||
const isManualClose = ref(false);
|
||||
const reconnectScheduled = ref(false);
|
||||
const retryCount = ref(0);
|
||||
|
||||
const getRetryCount = () => {
|
||||
return retryCount.value;
|
||||
};
|
||||
const addRetryCount = () => {
|
||||
retryCount.value = retryCount.value + 1;
|
||||
};
|
||||
const resetRetryCount = () => {
|
||||
retryCount.value = 0;
|
||||
};
|
||||
const setReconnectScheduled = (value) => {
|
||||
reconnectScheduled.value = value;
|
||||
};
|
||||
const getReconnectScheduled = () => {
|
||||
return reconnectScheduled.value;
|
||||
};
|
||||
const setIsManualClose = (value) => {
|
||||
isManualClose.value = value;
|
||||
};
|
||||
const getIsManualClose = () => {
|
||||
return isManualClose.value;
|
||||
};
|
||||
|
||||
const iceserver = {
|
||||
iceServers: [
|
||||
{
|
||||
// urls: "stun:stun.l.google.com:19302"
|
||||
urls: "stun:8.134.92.199:3478",
|
||||
},
|
||||
{
|
||||
urls: "turn:8.134.92.199:3478",
|
||||
username: "test",
|
||||
credential: "123456",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// 连接WebSocket
|
||||
export const connectVoicesocket = () => {
|
||||
const protocol = window.location.protocol === "https:" ? "wss://" : "ws://";
|
||||
const host = window.location.host;
|
||||
const socketUrl = `${protocol}${host}/voice?u_id=${userinfo.user.u_id}&u_name=${userinfo.user.u_name}`;
|
||||
|
||||
if (socket.value && socket.value.readyState !== WebSocket.CLOSED) {
|
||||
console.log("还在重连中...");
|
||||
return;
|
||||
}
|
||||
const retrytime = getRetryCount();
|
||||
if (retrytime >= 10) {
|
||||
console.log("重连失败,请稍后再试");
|
||||
return;
|
||||
}
|
||||
console.log(retrytime);
|
||||
socket.value = new WebSocket(socketUrl);
|
||||
|
||||
socket.value.onopen = (event) => {
|
||||
console.log("Voice连接已建立", event);
|
||||
setReconnectScheduled(false);
|
||||
setIsManualClose(false);
|
||||
resetRetryCount();
|
||||
};
|
||||
|
||||
// 处理消息逻辑
|
||||
socket.value.onmessage = async (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === "status") {
|
||||
ElMessage.error("对方已离线");
|
||||
oncall.panelOff();
|
||||
oncall.callingOff();
|
||||
} else if (data.type === "incomingcall") {
|
||||
// 通话请求处理
|
||||
oncall.setTarget(data.from, data.from_name, data.from_avatar);
|
||||
oncall.panelOn();
|
||||
oncall.callingOn();
|
||||
oncall.fromOff();
|
||||
oncall.statusOff();
|
||||
} else if (data.type === "pickup") {
|
||||
ElMessage.success("对方已接听,等待连接中...");
|
||||
try {
|
||||
await initRTCconnection();
|
||||
RTCpeerConnection.value.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
console.log("发送candidate", event.candidate);
|
||||
const message = {
|
||||
type: "single",
|
||||
content: "candidate",
|
||||
from: userinfo.user.u_id,
|
||||
to: oncall.target.u_id,
|
||||
candidate: event.candidate,
|
||||
};
|
||||
sendMessage(JSON.stringify(message));
|
||||
}
|
||||
};
|
||||
|
||||
RTCpeerConnection.value.ontrack = (event) => {
|
||||
console.log("收到对方音频流", event);
|
||||
oncall.setRemoteStream(event.streams[0]);
|
||||
};
|
||||
} catch (e) {
|
||||
console.log("webRTC初始化失败", e);
|
||||
}
|
||||
|
||||
const offer = await RTCpeerConnection.value.createOffer({
|
||||
offerToReceiveAudio: 1,
|
||||
});
|
||||
|
||||
await RTCpeerConnection.value.setLocalDescription(offer);
|
||||
|
||||
// 发送offer
|
||||
const message = {
|
||||
type: "single",
|
||||
content: "offer",
|
||||
from: userinfo.user.u_id,
|
||||
to: oncall.target.u_id,
|
||||
offer: offer,
|
||||
};
|
||||
sendMessage(JSON.stringify(message));
|
||||
} else if (data.type === "hangup") {
|
||||
ElMessage.success("对方已挂断");
|
||||
oncall.panelOff();
|
||||
oncall.callingOff();
|
||||
oncall.fromOff();
|
||||
oncall.statusOff();
|
||||
RTCpeerConnection.value.close();
|
||||
RTCpeerConnection.value = null;
|
||||
oncall.resetStream();
|
||||
} else if (data.content === "offer") {
|
||||
ElMessage.success("收到对方offer");
|
||||
console.log("收到对方offer", data);
|
||||
|
||||
try {
|
||||
await initRTCconnection();
|
||||
|
||||
RTCpeerConnection.value.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
console.log("发送candidate", event.candidate);
|
||||
const message = {
|
||||
type: "single",
|
||||
content: "candidate",
|
||||
from: userinfo.user.u_id,
|
||||
to: oncall.target.u_id,
|
||||
candidate: event.candidate,
|
||||
};
|
||||
sendMessage(JSON.stringify(message));
|
||||
}
|
||||
};
|
||||
|
||||
RTCpeerConnection.value.ontrack = (event) => {
|
||||
console.log("收到对方音频流", event);
|
||||
oncall.setRemoteStream(event.streams[0]);
|
||||
};
|
||||
} catch (e) {
|
||||
console.log("webRTC初始化失败", e);
|
||||
}
|
||||
|
||||
await RTCpeerConnection.value.setRemoteDescription(data.offer);
|
||||
const answer = await RTCpeerConnection.value.createAnswer();
|
||||
await RTCpeerConnection.value.setLocalDescription(answer);
|
||||
|
||||
// 发送answer
|
||||
const message = {
|
||||
type: "single",
|
||||
content: "answer",
|
||||
from: userinfo.user.u_id,
|
||||
to: oncall.target.u_id,
|
||||
answer: answer,
|
||||
};
|
||||
sendMessage(JSON.stringify(message));
|
||||
} else if (data.content === "answer") {
|
||||
ElMessage.success("对方已接受");
|
||||
console.log("对方已接受", data);
|
||||
await RTCpeerConnection.value.setRemoteDescription(data.answer);
|
||||
} else if (data.content === "candidate") {
|
||||
console.log("收到candidate", data);
|
||||
await RTCpeerConnection.value.addIceCandidate(data.candidate);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("VoiceSocket消息格式错误", e);
|
||||
}
|
||||
};
|
||||
|
||||
socket.value.onerror = (error) => {
|
||||
console.error("Voicesocket发生错误:", error);
|
||||
setReconnectScheduled(true);
|
||||
socket.value.close();
|
||||
};
|
||||
|
||||
socket.value.onclose = (event) => {
|
||||
if (!getIsManualClose()) {
|
||||
if (getReconnectScheduled()) {
|
||||
socket.value = null;
|
||||
addRetryCount();
|
||||
setTimeout(reConnectVoicesocket, 5000);
|
||||
setReconnectScheduled(false);
|
||||
} else {
|
||||
console.log("Voicesocket因为浏览器省电设置断开");
|
||||
console.log("Voicesocket连接已关闭", event);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// 断开Voicesocket连接
|
||||
export const disconnectVoicesocket = () => {
|
||||
if (socket.value && socket.value.readyState === WebSocket.OPEN) {
|
||||
socket.value.close();
|
||||
}
|
||||
};
|
||||
|
||||
// 重连机制
|
||||
export const reConnectVoicesocket = () => {
|
||||
connectVoicesocket();
|
||||
};
|
||||
|
||||
// 发送消息
|
||||
export const sendMessage = (message) => {
|
||||
if (socket.value && socket.value.readyState === WebSocket.OPEN) {
|
||||
socket.value.send(message);
|
||||
} else {
|
||||
console.warn("Voicesocket未连接,无法发送消息");
|
||||
}
|
||||
};
|
||||
|
||||
//没有错误的重连,只是浏览器在后台断开了连接
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) {
|
||||
} else {
|
||||
if (!getIsManualClose() && socket.value.readyState === WebSocket.CLOSED) {
|
||||
if (getReconnectScheduled()) {
|
||||
return;
|
||||
}
|
||||
reConnectVoicesocket();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
//WebRTC连接相关代码
|
||||
const RTCpeerConnection = ref(null);
|
||||
|
||||
export const getlocalStream = async () => {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
console.log("获取本地音频流成功", stream);
|
||||
return stream;
|
||||
// // 获取音频和视频轨道
|
||||
// const audioTrack = stream.getAudioTracks()[0];
|
||||
// // 将轨道添加到 RTCPeerConnection
|
||||
// peerConnection.addTrack(audioTrack, stream);
|
||||
};
|
||||
|
||||
const initRTCconnection = async () => {
|
||||
RTCpeerConnection.value = new RTCPeerConnection(iceserver);
|
||||
const stream = await getlocalStream();
|
||||
RTCpeerConnection.value.addTrack(stream.getAudioTracks()[0], stream);
|
||||
};
|
||||
|
||||
export const hangup = () => {
|
||||
oncall.panelOff();
|
||||
oncall.callingOff();
|
||||
oncall.fromOff();
|
||||
oncall.statusOff();
|
||||
oncall.resetStream();
|
||||
RTCpeerConnection.value.close();
|
||||
RTCpeerConnection.value = null;
|
||||
|
||||
const message = {
|
||||
type: "hangup",
|
||||
from: userinfo.user.u_id,
|
||||
to: oncall.target.u_id,
|
||||
};
|
||||
sendMessage(JSON.stringify(message));
|
||||
};
|
||||
import { ref } from "vue";
|
||||
import { userInfoStore } from "@/store/user";
|
||||
import { onCallStore } from "@/store/VoiceTarget";
|
||||
import { ElMessage } from "element-plus";
|
||||
|
||||
// userinfo 实例
|
||||
const userinfo = userInfoStore();
|
||||
// oncall 实例
|
||||
const oncall = onCallStore();
|
||||
// WebSocket 实例
|
||||
const socket = ref(null);
|
||||
|
||||
const isManualClose = ref(false);
|
||||
const reconnectScheduled = ref(false);
|
||||
const retryCount = ref(0);
|
||||
|
||||
const getRetryCount = () => {
|
||||
return retryCount.value;
|
||||
};
|
||||
const addRetryCount = () => {
|
||||
retryCount.value = retryCount.value + 1;
|
||||
};
|
||||
const resetRetryCount = () => {
|
||||
retryCount.value = 0;
|
||||
};
|
||||
const setReconnectScheduled = (value) => {
|
||||
reconnectScheduled.value = value;
|
||||
};
|
||||
const getReconnectScheduled = () => {
|
||||
return reconnectScheduled.value;
|
||||
};
|
||||
const setIsManualClose = (value) => {
|
||||
isManualClose.value = value;
|
||||
};
|
||||
const getIsManualClose = () => {
|
||||
return isManualClose.value;
|
||||
};
|
||||
|
||||
const iceserver = {
|
||||
iceServers: [
|
||||
{
|
||||
urls: process.env.STUN_URL,
|
||||
},
|
||||
{
|
||||
urls: process.env.TURN_URL,
|
||||
username: process.env.TURN_USERNAME,
|
||||
credential: process.env.TURN_CREDENTIAL,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// 连接WebSocket
|
||||
export const connectVoicesocket = () => {
|
||||
const protocol = window.location.protocol === "https:" ? "wss://" : "ws://";
|
||||
const host = window.location.host;
|
||||
const socketUrl = `${protocol}${host}/voice?u_id=${userinfo.user.u_id}&u_name=${userinfo.user.u_name}`;
|
||||
|
||||
if (socket.value && socket.value.readyState !== WebSocket.CLOSED) {
|
||||
console.log("还在重连中...");
|
||||
return;
|
||||
}
|
||||
const retrytime = getRetryCount();
|
||||
if (retrytime >= 10) {
|
||||
console.log("重连失败,请稍后再试");
|
||||
return;
|
||||
}
|
||||
console.log(retrytime);
|
||||
socket.value = new WebSocket(socketUrl);
|
||||
|
||||
socket.value.onopen = (event) => {
|
||||
console.log("Voice连接已建立", event);
|
||||
setReconnectScheduled(false);
|
||||
setIsManualClose(false);
|
||||
resetRetryCount();
|
||||
};
|
||||
|
||||
// 处理消息逻辑
|
||||
socket.value.onmessage = async (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === "status") {
|
||||
ElMessage.error("对方已离线");
|
||||
oncall.panelOff();
|
||||
oncall.callingOff();
|
||||
} else if (data.type === "incomingcall") {
|
||||
// 通话请求处理
|
||||
oncall.setTarget(data.from, data.from_name, data.from_avatar);
|
||||
oncall.panelOn();
|
||||
oncall.callingOn();
|
||||
oncall.fromOff();
|
||||
oncall.statusOff();
|
||||
} else if (data.type === "pickup") {
|
||||
ElMessage.success("对方已接听,等待连接中...");
|
||||
try {
|
||||
await initRTCconnection();
|
||||
RTCpeerConnection.value.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
console.log("发送candidate", event.candidate);
|
||||
const message = {
|
||||
type: "single",
|
||||
content: "candidate",
|
||||
from: userinfo.user.u_id,
|
||||
to: oncall.target.u_id,
|
||||
candidate: event.candidate,
|
||||
};
|
||||
sendMessage(JSON.stringify(message));
|
||||
}
|
||||
};
|
||||
|
||||
RTCpeerConnection.value.ontrack = (event) => {
|
||||
console.log("收到对方音频流", event);
|
||||
oncall.setRemoteStream(event.streams[0]);
|
||||
};
|
||||
} catch (e) {
|
||||
console.log("webRTC初始化失败", e);
|
||||
}
|
||||
|
||||
const offer = await RTCpeerConnection.value.createOffer({
|
||||
offerToReceiveAudio: 1,
|
||||
});
|
||||
|
||||
await RTCpeerConnection.value.setLocalDescription(offer);
|
||||
|
||||
// 发送offer
|
||||
const message = {
|
||||
type: "single",
|
||||
content: "offer",
|
||||
from: userinfo.user.u_id,
|
||||
to: oncall.target.u_id,
|
||||
offer: offer,
|
||||
};
|
||||
sendMessage(JSON.stringify(message));
|
||||
} else if (data.type === "hangup") {
|
||||
ElMessage.success("对方已挂断");
|
||||
oncall.panelOff();
|
||||
oncall.callingOff();
|
||||
oncall.fromOff();
|
||||
oncall.statusOff();
|
||||
RTCpeerConnection.value.close();
|
||||
RTCpeerConnection.value = null;
|
||||
oncall.resetStream();
|
||||
} else if (data.content === "offer") {
|
||||
ElMessage.success("收到对方offer");
|
||||
console.log("收到对方offer", data);
|
||||
|
||||
try {
|
||||
await initRTCconnection();
|
||||
|
||||
RTCpeerConnection.value.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
console.log("发送candidate", event.candidate);
|
||||
const message = {
|
||||
type: "single",
|
||||
content: "candidate",
|
||||
from: userinfo.user.u_id,
|
||||
to: oncall.target.u_id,
|
||||
candidate: event.candidate,
|
||||
};
|
||||
sendMessage(JSON.stringify(message));
|
||||
}
|
||||
};
|
||||
|
||||
RTCpeerConnection.value.ontrack = (event) => {
|
||||
console.log("收到对方音频流", event);
|
||||
oncall.setRemoteStream(event.streams[0]);
|
||||
};
|
||||
} catch (e) {
|
||||
console.log("webRTC初始化失败", e);
|
||||
}
|
||||
|
||||
await RTCpeerConnection.value.setRemoteDescription(data.offer);
|
||||
const answer = await RTCpeerConnection.value.createAnswer();
|
||||
await RTCpeerConnection.value.setLocalDescription(answer);
|
||||
|
||||
// 发送answer
|
||||
const message = {
|
||||
type: "single",
|
||||
content: "answer",
|
||||
from: userinfo.user.u_id,
|
||||
to: oncall.target.u_id,
|
||||
answer: answer,
|
||||
};
|
||||
sendMessage(JSON.stringify(message));
|
||||
} else if (data.content === "answer") {
|
||||
ElMessage.success("对方已接受");
|
||||
console.log("对方已接受", data);
|
||||
await RTCpeerConnection.value.setRemoteDescription(data.answer);
|
||||
} else if (data.content === "candidate") {
|
||||
console.log("收到candidate", data);
|
||||
await RTCpeerConnection.value.addIceCandidate(data.candidate);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("VoiceSocket消息格式错误", e);
|
||||
}
|
||||
};
|
||||
|
||||
socket.value.onerror = (error) => {
|
||||
console.error("Voicesocket发生错误:", error);
|
||||
setReconnectScheduled(true);
|
||||
socket.value.close();
|
||||
};
|
||||
|
||||
socket.value.onclose = (event) => {
|
||||
if (!getIsManualClose()) {
|
||||
if (getReconnectScheduled()) {
|
||||
socket.value = null;
|
||||
addRetryCount();
|
||||
setTimeout(reConnectVoicesocket, 5000);
|
||||
setReconnectScheduled(false);
|
||||
} else {
|
||||
console.log("Voicesocket因为浏览器省电设置断开");
|
||||
console.log("Voicesocket连接已关闭", event);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// 断开Voicesocket连接
|
||||
export const disconnectVoicesocket = () => {
|
||||
if (socket.value && socket.value.readyState === WebSocket.OPEN) {
|
||||
socket.value.close();
|
||||
}
|
||||
};
|
||||
|
||||
// 重连机制
|
||||
export const reConnectVoicesocket = () => {
|
||||
connectVoicesocket();
|
||||
};
|
||||
|
||||
// 发送消息
|
||||
export const sendMessage = (message) => {
|
||||
if (socket.value && socket.value.readyState === WebSocket.OPEN) {
|
||||
socket.value.send(message);
|
||||
} else {
|
||||
console.warn("Voicesocket未连接,无法发送消息");
|
||||
}
|
||||
};
|
||||
|
||||
//没有错误的重连,只是浏览器在后台断开了连接
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) {
|
||||
} else {
|
||||
if (!getIsManualClose() && socket.value.readyState === WebSocket.CLOSED) {
|
||||
if (getReconnectScheduled()) {
|
||||
return;
|
||||
}
|
||||
reConnectVoicesocket();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
//WebRTC连接相关代码
|
||||
export const RTCpeerConnection = ref(null);
|
||||
|
||||
const getlocalStream = async () => {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
console.log("获取本地音频流成功", stream);
|
||||
return stream;
|
||||
// // 获取音频和视频轨道
|
||||
// const audioTrack = stream.getAudioTracks()[0];
|
||||
// // 将轨道添加到 RTCPeerConnection
|
||||
// peerConnection.addTrack(audioTrack, stream);
|
||||
};
|
||||
|
||||
const initRTCconnection = async () => {
|
||||
RTCpeerConnection.value = new RTCPeerConnection(iceserver);
|
||||
const stream = await getlocalStream();
|
||||
RTCpeerConnection.value.addTrack(stream.getAudioTracks()[0], stream);
|
||||
};
|
||||
|
||||
export const hangup = () => {
|
||||
oncall.panelOff();
|
||||
oncall.callingOff();
|
||||
oncall.fromOff();
|
||||
oncall.statusOff();
|
||||
oncall.resetStream();
|
||||
RTCpeerConnection.value.close();
|
||||
RTCpeerConnection.value = null;
|
||||
|
||||
const message = {
|
||||
type: "hangup",
|
||||
from: userinfo.user.u_id,
|
||||
to: oncall.target.u_id,
|
||||
};
|
||||
sendMessage(JSON.stringify(message));
|
||||
};
|
||||
124
vite.config.js
124
vite.config.js
@@ -1,59 +1,65 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': '/src',
|
||||
}
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080', // 后端服务器地址
|
||||
changeOrigin: true, // 允许跨域
|
||||
rewrite: (path) => path.replace(/^\/api/, ''), // 重写路径,去掉 /api 前缀
|
||||
},
|
||||
'/proxy': {
|
||||
target: 'http://localhost:3000', // 代理服务器的地址
|
||||
changeOrigin: true, // 必须设置为 true,才能避免跨域问题
|
||||
// rewrite: (path) => path.replace(/^\/proxy/, ''), // 重写路径,去掉 /api 前缀
|
||||
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:8080',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
// '/ws': {
|
||||
// target: 'ws://localhost:8080',
|
||||
// changeOrigin: true,
|
||||
// ws: true,
|
||||
// }
|
||||
},
|
||||
},
|
||||
// server: {
|
||||
// https:{
|
||||
// key: fs.readFileSync('./cert/merlin.xin.key'),
|
||||
// cert: fs.readFileSync('./cert/merlin.xin.pem'),
|
||||
// },
|
||||
// proxy: {
|
||||
// '/api': {
|
||||
// target: 'https://localhost:8443', // 后端服务器地址
|
||||
// changeOrigin: true, // 允许跨域
|
||||
// rewrite: (path) => path.replace(/^\/api/, ''), // 重写路径,去掉 /api 前缀
|
||||
// },
|
||||
// '/online':{
|
||||
// target:'wss://localhost:8443/online',
|
||||
// changeOrigin:true,
|
||||
// ws:true,
|
||||
// },
|
||||
// '/voice':{
|
||||
// target:'wss://localhost:8443/voice',
|
||||
// changeOrigin:true,
|
||||
// ws:true,
|
||||
// }
|
||||
// },
|
||||
// },
|
||||
});
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': '/src',
|
||||
}
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080', // 后端服务器地址
|
||||
changeOrigin: true, // 允许跨域
|
||||
rewrite: (path) => path.replace(/^\/api/, ''), // 重写路径,去掉 /api 前缀
|
||||
},
|
||||
'/proxy': {
|
||||
target: 'http://localhost:3000', // 代理服务器的地址
|
||||
changeOrigin: true, // 必须设置为 true,才能避免跨域问题
|
||||
// rewrite: (path) => path.replace(/^\/proxy/, ''), // 重写路径,去掉 /api 前缀
|
||||
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:8080',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
},
|
||||
// server: {
|
||||
// https:{
|
||||
// key: fs.readFileSync('./cert/merlin.xin.key'),
|
||||
// cert: fs.readFileSync('./cert/merlin.xin.pem'),
|
||||
// },
|
||||
// proxy: {
|
||||
// '/api': {
|
||||
// target: 'https://localhost:8443', // 后端服务器地址
|
||||
// changeOrigin: true, // 允许跨域
|
||||
// rewrite: (path) => path.replace(/^\/api/, ''), // 重写路径,去掉 /api 前缀
|
||||
// },
|
||||
// '/online':{
|
||||
// target:'wss://localhost:8443/online',
|
||||
// changeOrigin:true,
|
||||
// ws:true,
|
||||
// },
|
||||
// '/voice':{
|
||||
// target:'wss://localhost:8443/voice',
|
||||
// changeOrigin:true,
|
||||
// ws:true,
|
||||
// }
|
||||
// },
|
||||
// },
|
||||
define: {
|
||||
'process.env.STUN_URL': JSON.stringify(process.env.STUN_URL),
|
||||
'process.env.TURN_URL': JSON.stringify(process.env.TURN_URL),
|
||||
'process.env.TURN_USERNAME': JSON.stringify(process.env.TURN_USERNAME),
|
||||
'process.env.TURN_CREDENTIAL': JSON.stringify(process.env.TURN_CREDENTIAL),
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user