Compare commits

...

11 Commits

Author SHA1 Message Date
13037d3830 Merge pull request 'fix: upgrade ci base image' (#9) from dev into main
All checks were successful
Docker Image CI / build (push) Successful in 2m51s
Reviewed-on: #9
2026-05-14 14:40:33 +00:00
b0823f2fa5 fix: upgrade ci base image 2026-05-14 22:39:56 +08:00
2abc7edab2 Merge pull request 'PR: merge' (#8) from dev into main
Some checks failed
Docker Image CI / build (push) Failing after 28s
Reviewed-on: #8
2026-05-14 14:11:58 +00:00
5dbaff904c feat: add ci 2026-05-14 22:09:37 +08:00
49f54a2168 fix: for thesis 2026-04-17 00:23:18 +08:00
122971200f feat: playroom sync finished 2026-04-04 17:48:34 +08:00
970aae1c5f fix: dplayer can not playing 2026-03-23 18:08:59 +08:00
08ae7414d0 chore: refact webRTC relative logic and update npm module 2026-03-17 18:10:30 +08:00
6f205d2408 fix: update dependences 2026-03-02 15:51:33 +08:00
b40691a4a3 feat: dplayer 2025-12-29 15:40:42 +08:00
27c4a247d3 reafactor: group basic function refactor complete 2025-12-17 18:03:16 +08:00
41 changed files with 8999 additions and 8763 deletions

4
.env_template Normal file
View File

@@ -0,0 +1,4 @@
STUN_URL=''
TURN_URL=''
TURN_USERNAME=''
TURN_CREDENTIAL=''

View 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.2
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
View 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.2
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
View File

@@ -1,30 +1,34 @@
# Logs # Logs
logs logs
*.log *.log
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
node_modules node_modules
.DS_Store .DS_Store
dist dist
dist-ssr dist-ssr
coverage coverage
*.local *.local
/cypress/videos/ /cypress/videos/
/cypress/screenshots/ /cypress/screenshots/
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
.idea .idea
*.suo *.suo
*.ntvs* *.ntvs*
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
*.tsbuildinfo *.tsbuildinfo
# dev environment variables
.env

17
Dockerfile Normal file
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -1,33 +1,38 @@
{ {
"name": "myplayer_vue", "name": "myplayer_vue",
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
}, "proxy": "node proxy.js"
"dependencies": { },
"axios": "^1.12.0", "dependencies": {
"crypto-js": "^4.2.0", "axios": "^1.13.6",
"dplayer": "^1.27.0", "crypto-js": "^4.2.0",
"element-plus": "^2.9.4", "dotenv": "^17.3.1",
"express": "^4.21.2", "dplayer": "^1.27.1",
"hls.js": "^1.5.20", "element-plus": "^2.9.4",
"idb": "^8.0.2", "express": "^4.21.2",
"node-fetch": "^3.3.2", "hls.js": "^1.5.20",
"pinia": "^3.0.1", "idb": "^8.0.2",
"pinia-plugin-persistedstate": "^4.2.0", "node-fetch": "^3.3.2",
"video.js": "^8.21.0", "pinia": "^3.0.1",
"videojs-vtt.js": "^0.15.5", "pinia-plugin-persistedstate": "^4.2.0",
"vue": "^3.5.13", "video.js": "^8.21.0",
"vue-axios": "^3.5.2", "videojs-vtt.js": "^0.15.5",
"vue-router": "^4.5.0" "vue": "^3.5.13",
}, "vue-axios": "^3.5.2",
"devDependencies": { "vue-router": "^4.5.0"
"@vitejs/plugin-vue": "^5.2.1", },
"vite": "^6.0.11", "devDependencies": {
"vite-plugin-vue-devtools": "^7.7.1" "@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
View File

@@ -1,62 +1,124 @@
import express from 'express'; import express from 'express';
import fetch from 'node-fetch'; import fetch from 'node-fetch';
import { URL } from 'url'; import { URL } from 'url';
const app = express(); const app = express();
const port = 3000; const port = 3000;
// 允许跨域请求 // 允许跨域请求
app.use((req, res, next) => { app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Origin', '*');
next(); res.header('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
}); res.header('Access-Control-Allow-Headers', 'Range');
next();
// 通用代理路由 });
app.get('/proxy', async (req, res) => {
console.log('Received Range header:', req.headers.range); // 本地静态文件代理:/local -> C:\Users\xyf17\Merlin\data
// 获取目标 URL const localDataPath = 'C:\\Users\\xyf17\\Merlin\\data';
const targetUrl = req.query.url; app.use('/local', express.static(localDataPath));
console.log('Fetching data from:', targetUrl);
if (!targetUrl) { // 通用代理路由
return res.status(400).send('URL parameter is required'); app.get('/proxy', async (req, res) => {
} console.log('Received Range header:', req.headers.range);
// 获取目标 URL
try { const targetUrl = req.query.url;
// 设置请求头 console.log('Fetching data from:', targetUrl);
const headers = {}; if (!targetUrl) {
if (req.headers.range) { return res.status(400).json({ error: 'URL parameter is required' });
headers['Range'] = req.headers.range; // 转发 Range 请求头 }
}
try {
// 向目标 URL 发起请求 // 设置请求
const response = await fetch(targetUrl, { headers }); const headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
// 设置响应头 };
response.headers.forEach((value, key) => {
if (key !== 'content-encoding') { // 避免某些头信息导致问题 // 避免上游压缩导致 Range/MSE 解析异常mp4 一般不会压缩,但这里保守处理)
res.setHeader(key, value); headers['Accept-Encoding'] = 'identity';
}
}); if (req.headers.range) {
headers['Range'] = req.headers.range; // 转发 Range 请求头,支持视频分段加载
// 特殊处理 Transfer-Encoding 和 Content-Length }
if (response.headers.get('transfer-encoding') === 'chunked') {
res.removeHeader('Content-Length'); // 向目标 URL 发起请求
} const response = await fetch(targetUrl, { headers });
// 将响应体直接流式传输给客户端 // 检查响应状态
response.body.pipe(res, { end: true }); if (!response.ok) {
return res.status(response.status).json({
// 错误处理 error: `Failed to fetch: ${response.statusText}`,
response.body.on('error', (err) => { status: response.status
console.error('Error during data transfer:', err); });
res.status(500).send('Error during data transfer'); }
});
} catch (error) { // 关键Range 请求上游通常返回 206必须把状态码透传给浏览器
console.error('Error fetching data:', error); res.status(response.status);
res.status(500).send('Error fetching data');
} // 设置响应头
}); response.headers.forEach((value, key) => {
// 跳过一些可能导致问题的头信息
app.listen(port, () => { if (key.toLowerCase() !== 'content-encoding' &&
console.log(`Proxy server running at http://localhost:${port}`); 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
View 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
View 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);
}
}

View File

@@ -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 []
}
}

View File

@@ -1,35 +1,37 @@
@import './base.css'; @import './base.css';
#app { #app {
max-width: 1280px; max-width: 1280px;
/* margin: 0 auto; */ width: 100%;
/* padding: 2rem; */ height: 100%;
font-weight: normal; /* margin: 0 auto; */
} /* padding: 2rem; */
font-weight: normal;
a, }
.green {
text-decoration: none; a,
color: hsla(160, 100%, 37%, 1); .green {
transition: 0.4s; text-decoration: none;
padding: 3px; 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 (hover: hover) {
} a:hover {
} /* background-color: hsla(160, 100%, 37%, 0.2); */
}
@media (min-width: 1024px) { }
body {
display: flex; @media (min-width: 1024px) {
place-items: center; body {
} display: flex;
place-items: center;
#app { }
display: grid;
/* grid-template-columns: 1fr 1fr; #app {
padding: 0 2rem; */ display: grid;
} /* grid-template-columns: 1fr 1fr;
} padding: 0 2rem; */
}
}

View File

@@ -1,162 +1,159 @@
<template> <template>
<nav class="navbar"> <nav class="navbar">
<div class="navbar-left"> <div class="navbar-left">
<router-link to="/home" class="navbar-logo"> <router-link to="/home" class="navbar-logo">
<img :src="logo" alt="MyPlayer" /> <img :src="logo" alt="MyPlayer" />
</router-link> </router-link>
</div> </div>
<div class="navbar-right"> <div class="navbar-right">
<router-link to="/home/search">搜索</router-link> <router-link to="/home/search">搜索</router-link>
<router-link to="/home/friends">好友</router-link> <router-link to="/home/friends">好友</router-link>
<router-link to="/home/group">群聊</router-link> <router-link to="/home/group">群聊</router-link>
<router-link to="/home/playroom">Playroom</router-link> <router-link to="/home/playroom">Playroom</router-link>
<div class="more-dropdown" @click="toggleDropdown" @blur="closeDropdown"> <div class="more-dropdown" @click="toggleDropdown" @blur="closeDropdown">
更多 更多
<ul v-if="isDropdownOpen" class="dropdown-content"> <ul v-if="isDropdownOpen" class="dropdown-content">
<li>设置</li> <li>设置</li>
<li @click="aboutUs">关于我们</li> <li @click="aboutUs">关于我们</li>
<li>帮助</li> <li>帮助</li>
<li @click="logout">退出登录</li> <li @click="logout">退出登录</li>
</ul> </ul>
</div> </div>
</div> </div>
</nav> </nav>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onUnmounted, initCustomFormatter } from 'vue'; import { ref, onMounted, onUnmounted } from 'vue';
import logo from '../assets/logo.png'; import logo from '../assets/logo.png';
import { onlineSocketStore } from '@/store/Online'; import { onlineSocketStore } from '@/store/Online';
import router from '@/router'; import router from '@/router';
import { voiceStore } from '@/store/Voice'; import { groupMessageStore } from '@/store/message.ts';
import { groupMessageStore } from '@/store/message.ts'; import { userInfoStore } from '@/store/user';
import { userInfoStore } from '@/store/user';
const userInfo = userInfoStore()
const userInfo = userInfoStore() const socket = onlineSocketStore()
const voice = voiceStore() const groupMessage = groupMessageStore()
const socket = onlineSocketStore()
const groupMessage = groupMessageStore()
const isDropdownOpen = ref(false); // 控制下拉框的显示与隐藏
const isDropdownOpen = ref(false); // 控制下拉框的显示与隐藏 const toggleDropdown = () => {
isDropdownOpen.value = !isDropdownOpen.value; // 切换下拉框的显示状态
const toggleDropdown = () => { };
isDropdownOpen.value = !isDropdownOpen.value; // 切换下拉框的显示状态
}; const closeDropdown = () => {
isDropdownOpen.value = false; // 关闭下拉框
const closeDropdown = () => { };
isDropdownOpen.value = false; // 关闭下拉框
}; // 监听全局点击事件
const handleGlobalClick = (event) => {
// 监听全局点击事件 // 检查点击是否发生在下拉框外部
const handleGlobalClick = (event) => { if (!event.target.closest('.more-dropdown')) {
// 检查点击是否发生在下拉框外部 closeDropdown();
if (!event.target.closest('.more-dropdown')) { }
closeDropdown(); };
}
}; onMounted(() => {
document.addEventListener('click', handleGlobalClick);
onMounted(() => { });
document.addEventListener('click', handleGlobalClick);
}); onUnmounted(() => {
document.removeEventListener('click', handleGlobalClick);
onUnmounted(() => { });
document.removeEventListener('click', handleGlobalClick);
});
//退出登录逻辑
const logout = () => {
//退出登录逻辑 //保存群聊消息
const logout = () => { groupMessage.saveMessagesHistory(socket.u_id,groupMessage.g_id)
//保存群聊消 // 清除用户全局信
groupMessage.saveMessagesHistory(socket.u_id,groupMessage.g_id) userInfo.clearUserInfo();
// 清除用户全局信息 localStorage.clear();
userInfo.clearUserInfo(); // 断开websocket链接
localStorage.clear(); socket.disconnect();
// 断开websocket链接 // 跳转到登录页面
voice.disconnect(); window.location.href = '/';
socket.disconnect();
// 跳转到登录页面 };
window.location.href = '/';
const aboutUs = () => {
}; router.push('/home/about')
}
const aboutUs = () => {
router.push('/home/about')
} </script>
<style scoped>
</script> .navbar {
display: flex;
<style scoped> justify-content: space-between;
.navbar { align-items: center;
display: flex; position: fixed;
justify-content: space-between; top: 0;
align-items: center; left: 0;
position: fixed; width: 100%;
top: 0; padding: 10px 20px;
left: 0; background-color: #60605e;
width: 100%; color: #fff;
padding: 10px 20px; z-index: 1000;
background-color: #60605e; }
color: #fff;
z-index: 1000; .navbar-left,
} .navbar-right {
display: flex;
.navbar-left, align-items: center;
.navbar-right { margin-right: 10%;
display: flex; margin-left: 10%;
align-items: center; }
margin-right: 10%;
margin-left: 10%; .navbar-logo img {
} width: 40px;
height: 40px;
.navbar-logo img { }
width: 40px;
height: 40px; .navbar a {
} color: #fff;
margin: 0 10px;
.navbar a { text-decoration: none;
color: #fff; }
margin: 0 10px;
text-decoration: none; .navbar a.router-link-active {
} font-weight: bold;
}
.navbar a.router-link-active {
font-weight: bold; .more-dropdown {
} position: relative;
cursor: pointer;
.more-dropdown { }
position: relative;
cursor: pointer; .dropdown-content {
} position: absolute;
width: 100px;
.dropdown-content { margin-top: 22px;
position: absolute; right: -50px;
width: 100px; color: #fff;
margin-top: 22px; background-color: #444444;
right: -50px; border: 1px solid #000000;
color: #fff; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
background-color: #444444; padding: 5px;
border: 1px solid #000000; z-index: 1000;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); list-style-type: none;
padding: 5px; }
z-index: 1000;
list-style-type: none; .dropdown-content a {
} display: block;
margin: 5px 0;
.dropdown-content a { }
display: block;
margin: 5px 0; .dropdown-content li {
} height: 40px;
padding: 10px;
.dropdown-content li { }
height: 40px;
padding: 10px; .dropdown-content li:hover {
}
background-color: #ffffff;
.dropdown-content li:hover { color: #000000;
}
background-color: #ffffff;
color: #000000;
}
</style> </style>

View File

@@ -1,153 +1,154 @@
<template> <template>
<el-button class="telegram-btn" @click="showTelegramPanel"> <el-button class="telegram-btn" @click="showTelegramPanel">
<el-icon> <el-icon>
<Mic /> <Mic />
</el-icon> </el-icon>
<div class="telegramTag"></div> <div class="telegramTag"></div>
</el-button> </el-button>
<div class="voice" v-if="onCall.panel"> <div class="voice" v-if="onCall.panel">
<div> <div>
<div class="profilebox"> <div class="profilebox">
<img :src="onCall.target.avatar" alt="头像"> <!-- 原逻辑:src="onCall.target.avatar"远端头像 -->
</div> <img :src="defaultAvatar" alt="头像">
<div class="infobox"> </div>
<p>{{ onCall.target.name }}</p> <div class="infobox">
</div> <p>{{ onCall.target.name }}</p>
<div class="buttonsbox"> </div>
<button @click="answer" v-if="!onCall.from"> <div class="buttonsbox">
接听 <button @click="answer" v-if="!onCall.from">
</button> 接听
<button @click="hangup"> </button>
挂断 <button @click="denyCall(userinfo.user.id, onCall.target.u_id)" v-if="!onCall.from">
</button> 拒绝
</div> </button>
</div> <button @click="hangupCall(userinfo.user.id, onCall.target.u_id)" v-if="onCall.from">
<el-button @click="playRemoteAudio">没有声音试试我</el-button> 挂断
</div> </button>
<audio ref="remoteAudio" autoplay></audio> </div>
</div>
</template> <el-button @click="playRemoteAudio">没有声音试试我</el-button>
<script setup> </div>
import { userInfoStore } from '@/store/store'; <audio ref="remoteAudio" id="remoteAudio" autoplay playsinline></audio>
import { voiceStore } from '@/store/Voice';
import { onCallStore } from '@/store/VoiceTarget'; </template>
import { Mic } from '@element-plus/icons-vue'; <script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'; import { userInfoStore } from '@/store/user';
import { onCallStore } from '@/store/VoiceTarget';
const voice = voiceStore(); import { Mic } from '@element-plus/icons-vue';
const onCall = onCallStore(); import { ref, onMounted, onBeforeUnmount } from 'vue';
const userinfo = userInfoStore() import { denyCall, hangupCall,sendOffer } from '@/store/Voice.ts';
import defaultAvatar from '@/assets/defaultavatar.jpg';
const localAudio = ref(null);
const remoteAudio = ref(null); const onCall = onCallStore();
const userinfo = userInfoStore()
onMounted(() => {
onCall.setRemoteElement(remoteAudio.value); const localAudio = ref(null);
}); const remoteAudio = ref(null);
onBeforeUnmount(() => { onMounted(() => {
// 清理音频流和元素 onCall.setRemoteElement(remoteAudio.value);
if (localAudio.value.srcObject) { });
localAudio.value.srcObject.getTracks().forEach(track => track.stop());
} onBeforeUnmount(() => {
if (remoteAudio.value.srcObject) { // 清理音频流和元素
remoteAudio.value.srcObject.getTracks().forEach(track => track.stop()); if (localAudio.value && localAudio.value.srcObject) {
} localAudio.value.srcObject.getTracks().forEach(track => track.stop());
}) }
if (remoteAudio.value && remoteAudio.value.srcObject) {
const answer = () => { remoteAudio.value.srcObject.getTracks().forEach(track => track.stop());
console.log("接听"); }
onCall.callingOff(); })
onCall.fromOn();
voice.pickup(userinfo.user.u_id, onCall.target.u_id) const answer = async () => {
} console.log("接听");
onCall.callingOff();
const hangup = () => { onCall.fromOn();
voice.hangup(); // 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 showTelegramPanel = () => {
onCall.panel = !onCall.panel;
const playRemoteAudio = () => { }
const audio = document.getElementById("remoteAudio");
if (audio) { const playRemoteAudio = () => {
audio.play().catch(err => { const audio = remoteAudio.value;
console.log(err); if (!audio) return;
}); audio.play().catch(err => {
} console.log(err);
} });
}
</script>
<style> </script>
.voice { <style>
position: fixed; .voice {
top: 210px; position: fixed;
right: 100px; top: 210px;
width: 300px; right: 100px;
height: 400px; width: 300px;
background-color: #d7d7d7; height: 400px;
z-index: 999; background-color: #d7d7d7;
} z-index: 999;
}
.profilebox {
width: 100%; .profilebox {
height: 150px; width: 100%;
display: flex; height: 150px;
justify-content: center; display: flex;
align-items: center; justify-content: center;
} align-items: center;
}
.profilebox img {
width: 100px; .profilebox img {
height: 100px; width: 100px;
border-radius: 50%; height: 100px;
background-color: #fff; border-radius: 50%;
} background-color: #fff;
}
.infobox {
width: 100%; .infobox {
height: 50px; width: 100%;
display: flex; height: 50px;
justify-content: center; display: flex;
align-items: center; justify-content: center;
font-size: 18px; align-items: center;
color: #646464; font-size: 18px;
} color: #646464;
}
.buttonsbox {
width: 100%; .buttonsbox {
height: 100px; width: 100%;
display: flex; height: 100px;
justify-content: center; display: flex;
align-items: center; justify-content: center;
} align-items: center;
}
.buttonsbox button {
margin: 0 20px 0 20px; .buttonsbox button {
width: 50px; margin: 0 20px 0 20px;
height: 50px; width: 50px;
border-radius: 50%; height: 50px;
} border-radius: 50%;
}
.telegram-btn {
position: fixed; .telegram-btn {
top: 45%; position: fixed;
right: 0; top: 45%;
z-index: 1000; right: 0;
} z-index: 1000;
}
.telegramTag {
position: absolute; .telegramTag {
right: 0; position: absolute;
bottom: 0; right: 0;
width: 10px; bottom: 0;
height: 10px; width: 10px;
border-radius: 50%; height: 10px;
background-color: #f00; border-radius: 50%;
} background-color: #f00;
}
</style> </style>

View File

@@ -1,72 +1,74 @@
<template> <template>
<router-link to="/home/user" v-if="userinfo.user.u_avatar" class="user-profile-link"> <router-link to="/home/user" class="user-profile-link">
<div class="user-profile"> <div class="user-profile">
<img :src="userinfo.user.u_avatar" alt="User Avatar" /> <!-- 原逻辑:src="userinfo.user.u_avatar"远端头像 -->
<div :class="['status-dot', statusClass]"></div> <img :src="defaultAvatar" alt="User Avatar" />
</div> <div :class="['status-dot', statusClass]"></div>
</router-link> </div>
</router-link>
</template>
</template>
<script setup>
import { ref, computed } from 'vue'; <script setup>
import { userInfoStore } from '@/store/store'; import { ref, computed } from 'vue';
import { userInfoStore } from '@/store/user.ts';
const userinfo = userInfoStore(); import defaultAvatar from '@/assets/defaultavatar.jpg';
const isLoggedIn = ref(true); // 假设用户已登录 const userinfo = userInfoStore();
const statusClass = computed(() => { const isLoggedIn = ref(true); // 假设用户已登录
return isLoggedIn.value ? 'online' : 'offline';
}); const statusClass = computed(() => {
return isLoggedIn.value ? 'online' : 'offline';
</script> });
<style scoped> </script>
.user-profile-link {
display: inline-block; <style scoped>
text-decoration: none; .user-profile-link {
/* 去掉链接的下划线 */ display: inline-block;
} text-decoration: none;
/* 去掉链接的下划线 */
.user-profile { }
position: fixed;
bottom: 30px; .user-profile {
right: 30px; position: fixed;
width: 150px; bottom: 30px;
height: 150px; right: 30px;
border-radius: 50%; width: 150px;
background-color: #fff; height: 150px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2); border-radius: 50%;
z-index: 500; background-color: #fff;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
} z-index: 500;
.user-profile img { }
width: 100%;
height: 100%; .user-profile img {
object-fit: cover; width: 100%;
border-radius: 50%; height: 100%;
} object-fit: cover;
border-radius: 50%;
.status-dot { }
position: absolute;
bottom: 0; .status-dot {
right: 0; position: absolute;
width: 50px; bottom: 0;
height: 50px; right: 0;
border-radius: 50%; width: 50px;
border: 2px solid white; height: 50px;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.2); border-radius: 50%;
} border: 2px solid white;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.2);
.online { }
background-color: green;
/* 在线状态 */ .online {
} background-color: green;
/* 在线状态 */
.offline { }
background-color: red;
/* 离线状态 */ .offline {
} background-color: red;
/* 离线状态 */
}
</style> </style>

View File

@@ -8,6 +8,10 @@ import DPlayer from 'dplayer';
import Hls from 'hls.js'; import Hls from 'hls.js';
const videoRef = ref(null); const videoRef = ref(null);
const player = ref(null);
const hlsInstance = ref(null);
const emit = defineEmits(['canplay', 'play', 'pause', 'remote-play-failed']);
const props = defineProps({ const props = defineProps({
autoplay: { type: Boolean, default: false }, autoplay: { type: Boolean, default: false },
@@ -15,75 +19,279 @@ const props = defineProps({
danmaku: { type: Object, default: () => ({}) } danmaku: { type: Object, default: () => ({}) }
}); });
onMounted(() => { // 判断是否为 HLS 格式
const playerOptions = { 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, container: videoRef.value,
autoplay: props.autoplay, autoplay: props.autoplay,
video: { video: {},
url: `/proxy?url=${encodeURIComponent(props.videoUrl)}`, danmaku: props.danmaku
};
if (isHls && Hls.isSupported()) {
// HLS 格式使用 customHls
options.video = {
url: proxyUrl,
type: 'customHls', type: 'customHls',
customType: { customType: {
customHls: function (video, player) { customHls: function (video, player) {
if (Hls.isSupported()) { cleanupHls(); // 清理旧的实例
const hls = new Hls();
hls.loadSource(video.src); const hls = new Hls({
hls.attachMedia(video); maxBufferLength: 20,
hls.config.maxBufferLength = 60; // 设置最大缓冲时间为60秒 maxMaxBufferLength: 60,
hls.on(Hls.Events.MEDIA_ATTACHED, () => { enableWorker: true,
video.play(); 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)}`) player.value.on('play', () => {
const player = new DPlayer(playerOptions); 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', () => { const getCurrentTime = () => {
console.log("播放...") const video = player.value?.video;
}) if (!video || typeof video.currentTime !== 'number' || Number.isNaN(video.currentTime)) {
player.on('pause', () => { return 0;
console.log("暂停...") }
}) return video.currentTime;
player.on('ended', () => { };
console.log("结束...")
})
player.on('error', () => {
console.log("出错...")
})
watch(() => props.videoUrl, (newUrl) => { const seekTo = (timeSec) => {
if (player) { const t = Number(timeSec);
console.log("切换视频...") if (!Number.isFinite(t) || t < 0) return;
player.switchVideo({ if (player.value?.seek) {
url: `/proxy?url=${encodeURIComponent(newUrl)}`, try {
type: 'customHls', player.value.seek(t);
customType: { return;
customHls: function (video, player) { } catch (e) {}
if (Hls.isSupported()) { }
const hls = new Hls(); 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.loadSource(video.src);
hls.attachMedia(video); 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);
} }
} }
) });
// 销毁时清理播放器 // 销毁时清理播放器和 HLS 实例
onBeforeUnmount(() => { onBeforeUnmount(() => {
player.destroy(); cleanupHls();
}); if (player.value) {
try {
player.value.destroy();
} catch (e) {
console.warn('销毁播放器时出错:', e);
}
player.value = null;
}
}); });
</script> </script>

View File

@@ -5,7 +5,7 @@ const STORE_NAME = 'groupHistoryMessages';
// 打开数据库 // 打开数据库
const getDB = async () => { const getDB = async () => {
return openDB(DB_NAME, 3, { return openDB(DB_NAME, 4, {
upgrade(db) { upgrade(db) {
if (!db.objectStoreNames.contains(STORE_NAME)) { if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME); // 使用 userId 作为 key db.createObjectStore(STORE_NAME); // 使用 userId 作为 key

View File

@@ -79,7 +79,7 @@ const router = createRouter({
{ {
path: '/room', path: '/room',
name: 'room', name: 'room',
component: () => import('../views/room/room.vue'), component: () => import('../views/room/index.vue'),
} }

View File

@@ -1,37 +1,42 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { connectWebSocket, disconnectWebSocket, sendMessage, setIsManualClose } from '@/websocket/onlineSocket' import { connectWebSocket, disconnectWebSocket, sendMessage, setIsManualClose } from '@/websocket/onlineSocket'
import { messageStore } from './message' import { messageStore } from './message'
const message = messageStore() const message = messageStore()
export const onlineSocketStore = defineStore('onlineSocket', { export const onlineSocketStore = defineStore('onlineSocket', {
state: () => ({ state: () => ({
isConnected: false, isConnected: false,
hasGotMessage: false, hasGotMessage: false,
id: '' id: ''
}), }),
actions: { actions: {
connect(id) { connect(id: number) {
this.id = id; this.id = id;
if (this.isConnected === true) return if (this.isConnected === true) return
connectWebSocket(); connectWebSocket();
this.isConnected = true; this.isConnected = true;
if (!this.hasGotMessage) { if (!this.hasGotMessage) {
message.loadMessagesHistory(this.id) message.loadMessagesHistory(this.id)
this.hasGotMessage = true this.hasGotMessage = true
} }
}, },
disconnect() { disconnect() {
setIsManualClose(true); setIsManualClose(true);
disconnectWebSocket(); disconnectWebSocket();
this.isConnected = false; this.isConnected = false;
if (this.hasGotMessage) { if (this.hasGotMessage) {
message.saveMessagesHistory(this.id) message.saveMessagesHistory(this.id)
} }
}, },
send(message) { send(message: any) {
sendMessage(JSON.stringify(message)); try {
} sendMessage(message);
} } catch (error) {
console.error('Failed to stringify message:', error);
return;
}
}
}
}) })

View File

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

View File

@@ -1,93 +1,115 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
export const onCallStore = defineStore("onCall", { export const onCallStore = defineStore("onCall", {
state: () => ({ state: () => ({
target: { target: {
u_id: "", u_id: "",
name: "", name: "",
avatar: "" avatar: ""
}, },
panel: false, panel: false,
calling: false, calling: false,
from: false, from: false,
status: false, status: false,
localstream: { localstream: {
audioStream: null, audioStream: null,
audioElement: null audioElement: null
}, },
remotestream: { remotestream: {
audioStream: null, audioStream: null,
audioElement: null audioElement: null
} }
}), }),
actions: { actions: {
setTarget(u_id, u_name, u_avatar) { getTarget() {
this.target = { return this.target;
u_id: u_id, },
name: u_name, setTarget(u_id:number, u_name:string, u_avatar:string) {
avatar: u_avatar this.target = {
} u_id: u_id,
}, name: u_name,
panelOn() { avatar: u_avatar
this.panel = true }
}, },
panelOff() { panelOn() {
this.panel = false this.panel = true
}, },
callingOn() { panelOff() {
this.calling = true this.panel = false
}, },
callingOff() { callingOn() {
this.calling = false this.calling = true
}, },
fromOn() { callingOff() {
this.from = true this.calling = false
}, },
fromOff() { fromOn() {
this.from = false this.from = true
}, },
statusOn() { fromOff() {
this.status = true this.from = false
}, },
statusOff() { statusOn() {
this.status = false this.status = true
}, },
setLocalStream(stream) { statusOff() {
this.localstream.audioStream = stream; this.status = false
if (this.localstream.audioStream) { },
this.localstream.audioElement.srcObject = stream; clear(){
} this.target = {
}, u_id: "",
setLocalElement(element) { name: "",
this.localstream.audioElement = element; avatar: ""
if (this.localstream.audioStream) { }
this.localstream.audioElement.srcObject = this.localstream.audioStream; this.panel = false;
} this.calling = false;
}, this.from = false;
setRemoteStream(stream) { this.status = false;
this.remotestream.audioStream = stream; this.localstream = {
if (this.remotestream.audioStream) { audioStream: null,
this.remotestream.audioElement.srcObject = stream; audioElement: null
} }
}, this.remotestream = {
setRemoteElement(element) { audioStream: null,
this.remotestream.audioElement = element; audioElement: null
if (this.remotestream.audioStream) { }
this.remotestream.audioElement.srcObject = this.remotestream.audioStream; },
} setLocalStream(stream) {
}, this.localstream.audioStream = stream;
resetStream() { if (this.localstream.audioStream) {
// 停止本地媒体流 this.localstream.audioElement.srcObject = stream;
if (this.localstream.audioStream) { }
this.localstream.audioStream.getTracks().forEach(track => track.stop()); },
this.localstream.audioStream = null; setLocalElement(element) {
} this.localstream.audioElement = element;
if (this.localstream.audioStream) {
// 停止远程媒体流 this.localstream.audioElement.srcObject = this.localstream.audioStream;
if (this.remotestream.videoStream) { }
this.remotestream.videoStream.getTracks().forEach(track => track.stop()); },
this.remotestream.videoStream = null; 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;
}
}
}
}) })

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

View File

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

View File

@@ -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:{
}
})

View File

@@ -1,33 +1,33 @@
<template> <template>
<el-row> <el-row>
<el-col :span="6"> <el-col :span="6">
<h1>关于我们</h1> <h1>关于我们</h1>
<p>前端是我</p> <p>前端是我</p>
<p>后端是我</p> <p>后端是我</p>
<p>测试是我</p> <p>测试是我</p>
<p>运维是我</p> <p>运维是我</p>
<p>UI设计是我</p> <p>UI设计是我</p>
<p>项目管理是我</p> <p>项目管理是我</p>
<p>架构设计是我</p> <p>架构设计是我</p>
<p>数据库设计还是我</p> <p>数据库设计还是我</p>
<p>感谢使用与支持</p> <p>感谢使用</p>
</el-col> </el-col>
<el-col :span="9"> <!-- <el-col :span="9">
可以添加微信询问详情 可以添加微信询问详情
<img class="QRcode" src="@/assets/微信二维码.png" alt="candlelight_official"> <img class="QRcode" src="@/assets/微信二维码.png" alt="candlelight_official">
</el-col> </el-col> -->
<!-- <el-col :span="9"> <!-- <el-col :span="9">
<img class="QRcode" src="@/assets/微信收款码.png" alt="" /> <img class="QRcode" src="@/assets/微信收款码.png" alt="" />
</el-col> </el-col>
<el-col :span="9"> <el-col :span="9">
<img class="QRcode" src="@/assets/支付宝收款码.jpg" alt="" /> <img class="QRcode" src="@/assets/支付宝收款码.jpg" alt="" />
</el-col> --> </el-col> -->
</el-row> </el-row>
</template> </template>
<style> <style>
.QRcode { .QRcode {
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
</style> </style>

View File

@@ -1,23 +1,24 @@
<template> <template>
<div class="home-default"> <div class="home-default">
<h1>欢迎使用Myplayer</h1> <h1>欢迎使用Myplayer</h1>
<h2>当前版本v0.2.0测试版</h2> <h2>当前版本v0.4.0测试版</h2>
<p>v0.1.0 websocket实时聊天实装</p> <!-- <p>v0.1.0 websocket实时聊天实装</p>
<p>v0.1.1 面板更新websocket重连机制增加</p> <p>v0.1.1 面板更新websocket重连机制增加</p>
<p>v0.1.2 修复bug群聊功能性实现webRTC点对点语言聊天实装</p> <p>v0.1.2 修复bug群聊功能性实现webRTC点对点语言聊天实装</p>
<p>v0.2.0 修复若干bug完善了部分ui和逻辑</p> <p>v0.2.0 修复若干bug完善了部分ui和逻辑</p>
<p>预期开发计划1播放器相关开发 2ui重绘 3群聊功能完善......</p> <p>v0.3.0 重构了后端以及前端逻辑</p>
<p>总之还有好多事慢慢写吧</p> <p>预期开发计划1播放器相关开发 2ui重绘 3群聊功能完善......</p> -->
</div> <p>总之还有好多事慢慢写吧</p>
</template> </div>
</template>
<script setup>
<script setup>
</script>
</script>
<style scoped>
.home-default { <style scoped>
text-align: center; .home-default {
padding: 20px; text-align: center;
} padding: 20px;
}
</style> </style>

View File

@@ -1,402 +1,414 @@
<template> <template>
<el-row class="container"> <el-row class="container">
<el-col :span="6"> <el-col :span="6">
<el-button type="primary" @click="dialogVisibleFriendManage = true">好友管理</el-button> <el-button type="primary" @click="dialogVisibleFriendManage = true">好友管理</el-button>
<el-input v-model="tempsearch" :prefix-icon="Search" style="width: 90%;" clearable></el-input> <el-input v-model="tempsearch" :prefix-icon="Search" style="width: 90%;" clearable></el-input>
<el-scrollbar style="height: 500px;width: 90%;"> <el-scrollbar style="height: 500px;width: 90%;">
<ul class="infinitelist" style="overflow: auto"> <ul class="infinitelist" style="overflow: auto">
<li v-for="(item) in tempsearchResult" :key="item.id" class="infinitelistitem" <li v-for="(item) in tempsearchResult" :key="item.id" class="infinitelistitem"
@click="switchTemplate(item.id, item.u_name, item.u_avatar)" @click="switchTemplate(item.id, item.u_name, item.u_avatar)"
:class="{ 'selected': selectedFriendId === item.id }"> :class="{ 'selected': selectedFriendId === item.id }">
<div class="user-profile"> <div class="user-profile">
<img :src="item.u_avatar" alt="User Avatar" /> <!-- 原逻辑:src="item.u_avatar"远端头像 -->
<div :class="['status-dot', statusClass]"></div> <img :src="defaultAvatar" alt="User Avatar" />
</div> <div :class="['status-dot', statusClass]"></div>
<div style="display: inline-block;">{{ item.u_name }}#{{ item.u_id }}</div> </div>
</li> <div style="display: inline-block;">{{ item.u_name }}#{{ item.u_id }}</div>
</ul> </li>
</el-scrollbar> </ul>
</el-col> </el-scrollbar>
<el-col :span="18"> </el-col>
<el-row class="messagebox"> <el-col :span="18">
<el-scrollbar ref="scrollbarRef" style="width: 100%;height: 100%;"> <el-row class="messagebox">
<!-- 聊天消息指示框 头像时间内容 --> <el-scrollbar ref="scrollbarRef" style="width: 100%;height: 100%;">
<div v-for="(item) in messagebox" class="message-item"> <!-- 聊天消息指示框 头像时间内容 -->
<div <div v-for="(item) in messagebox" class="message-item">
:class="{ 'message-item-profile': true, 'left': item.from !== userinfo.user.id, 'right': item.from === userinfo.user.id }"> <div
<img :src="item.from !== userinfo.user.id ? oppositeAvatar : userinfo.user.u_avatar" :class="{ 'message-item-profile': true, 'left': item.from !== userinfo.user.id, 'right': item.from === userinfo.user.id }">
alt="User Avatar" /> <!-- 原逻辑:src="item.from !== userinfo.user.id ? oppositeAvatar : userinfo.user.u_avatar"远端头像 -->
</div> <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 }"> <div
{{ item.content }}</div> :class="{ 'message-item-content': true, 'left': item.from !== userinfo.user.id, 'right': item.from === userinfo.user.id }">
<div {{ item.content }}</div>
:class="{ 'message-item-time': true, 'left': item.from === userinfo.user.id, 'right': item.from !== userinfo.user.id }"> <div
{{ item.time }}</div> :class="{ 'message-item-time': true, 'left': item.from === userinfo.user.id, 'right': item.from !== userinfo.user.id }">
</div> {{ item.time }}</div>
</el-scrollbar> </div>
</el-row> </el-scrollbar>
<el-row> </el-row>
<el-input class="inputbox" v-model="inputValue" :placeholder="placeholder" @keyup.enter="handleEnter" clearable <el-row>
:disabled="inputDisabled"></el-input> <el-input class="inputbox" v-model="inputValue" :placeholder="placeholder" @keyup.enter="handleEnter" clearable
<el-button class="call" :disabled="inputDisabled" @click="confirmCall()">语音通话</el-button> :disabled="inputDisabled"></el-input>
</el-row> <el-button class="call" :disabled="inputDisabled" @click="confirmCall()">语音通话</el-button>
</el-col> </el-row>
</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;"> <el-dialog v-model="dialogVisibleCallConfirm" title="确认通话对象" width="30%" :before-close="handleClose">
<div class="user-profile"> <el-row style="height: 80px;align-items: center;">
<img :src="oppositeAvatar" alt="User Avatar" /> <div class="user-profile">
</div> <!-- 原逻辑:src="oppositeAvatar"远端头像 -->
<div>{{ oppositeName }}</div> <img :src="defaultAvatar" alt="User Avatar" />
</el-row> </div>
<el-row> <div>{{ oppositeName }}</div>
<el-button type="primary" @click="handleConfirmCall()" style="margin: auto;">确认</el-button> </el-row>
</el-row> <el-row>
</el-dialog> <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-dialog v-model="dialogVisibleFriendManage" title="好友管理" style="width: 850px;height: 500px;">
<el-table-column prop="u_avatar" label="" width="100"> <el-input v-model="friendSearch" placeholder="搜索好友" style="margin-bottom: 10px;width: 100%;"></el-input>
<template #default="scope"> <el-table :data="friendSearchResult" style="width: 100%;height: 400px;">
<!-- 使用 el-avatar 组件显示头像 --> <el-table-column prop="u_avatar" label="" width="100">
<el-avatar :src="friendSearchResult[scope.$index].u_avatar" size="large" /> <template #default="scope">
</template> <!-- 使用 el-avatar 组件显示头像 -->
</el-table-column> <!-- 原逻辑:src="friendSearchResult[scope.$index].u_avatar"远端头像 -->
<el-table-column prop="u_name" label="姓名" width="100"></el-table-column> <el-avatar :src="defaultAvatar" size="large" />
<!-- <el-table-column prop="id" label="id" width="0"></el-table-column> --> </template>
<el-table-column prop="u_id" label="u_id" width="100"></el-table-column> </el-table-column>
<el-table-column prop="u_introduction" label="个性签名" width="200"></el-table-column> <el-table-column prop="u_name" label="名" width="100"></el-table-column>
<el-table-column> <!-- <el-table-column prop="id" label="id" width="0"></el-table-column> -->
<template #default="scope"> <el-table-column prop="u_id" label="u_id" width="100"></el-table-column>
<el-button @click="deleteChatHisory(scope.row.u_id)">删除聊天记录</el-button> <el-table-column prop="u_introduction" label="个性签名" width="200"></el-table-column>
</template> <el-table-column>
</el-table-column> <template #default="scope">
<el-table-column> <el-button @click="deleteChatHisory(scope.row.u_id)">删除聊天记录</el-button>
<template #default="scope"> </template>
<el-button @click="deletefriend(scope.row.id)">删除好友</el-button> </el-table-column>
</template> <el-table-column>
</el-table-column> <template #default="scope">
<el-button @click="deletefriend(scope.row.id)">删除好友</el-button>
</el-table> </template>
</el-dialog> </el-table-column>
</template> </el-table>
</el-dialog>
<script setup>
import { ref, onMounted, nextTick, watch, computed } from 'vue' </template>
import { userInfoStore } from '@/store/user';
import { ElMessage } from 'element-plus' <script setup>
import { onlineSocketStore } from '@/store/Online'; import { ref, onMounted, nextTick, watch, computed } from 'vue'
import { messageStore } from '@/store/message.ts'; import { userInfoStore } from '@/store/user';
import { Search } from '@element-plus/icons-vue' import { ElMessage } from 'element-plus'
import { voiceStore } from '@/store/Voice'; import { sendMessage } from '@/websocket/onlineSocket';
import { onCallStore } from '@/store/VoiceTarget'; import { messageStore } from '@/store/message.ts';
import { getFriends, deleteFriend } from '@/api/friend'; import { Search } from '@element-plus/icons-vue'
import { onCallStore } from '@/store/VoiceTarget.ts';
const socket = onlineSocketStore() import { getFriends, deleteFriend } from '@/api/friend';
const userinfo = userInfoStore() import defaultAvatar from '@/assets/defaultavatar.jpg';
const message = messageStore()
const voice = voiceStore()
const oncall = onCallStore() const userinfo = userInfoStore()
const message = messageStore()
const messagebox = ref([]) const oncall = onCallStore()
const scrollbarRef = ref(null)
const dialogVisibleFriendManage = ref(false) const messagebox = ref([])
const dialogVisibleCallConfirm = ref(false) const scrollbarRef = ref(null)
const dialogVisibleFriendManage = ref(false)
const tempsearch = ref('') const dialogVisibleCallConfirm = ref(false)
const inputValue = ref('')
const statusClass = ref('online') const tempsearch = ref('')
const inputDisabled = ref(true) const inputValue = ref('')
const placeholder = ref('请选择聊天对象') const statusClass = ref('online')
const oppositeId = ref('') const inputDisabled = ref(true)
const oppositeName = ref('') const placeholder = ref('请选择聊天对象')
const oppositeAvatar = ref('') const oppositeId = ref('')
const oppositeName = ref('')
const friends = ref([]) const oppositeAvatar = ref('')
const friendSearch = ref('')
const selectedFriendId = ref('') const friends = ref([])
const friendSearch = ref('')
const tempsearchResult = computed(() => { const selectedFriendId = ref('')
if (!tempsearch.value)
return friends.value const tempsearchResult = computed(() => {
friends.value.filter(item => item.u_name.includes(tempsearch.value)) if (!tempsearch.value)
}) return friends.value
else
const friendSearchResult = computed(() => { return friends.value.filter(item => item.u_name.includes(tempsearch.value))
if (!friendSearch.value) })
return friends.value
else const friendSearchResult = computed(() => {
return friends.value.filter(item => item.u_name.includes(friendSearch.value)) 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 const switchTemplate = (id, u_name, u_avatar) => {
oppositeAvatar.value = u_avatar message.from = id
message.setCorresponding() oppositeId.value = id
messagebox.value = message.corresponding oppositeName.value = u_name
inputDisabled.value = false oppositeAvatar.value = u_avatar
placeholder.value = '请输入消息内容' message.setCorresponding()
scrollToBottom() messagebox.value = message.corresponding
selectedFriendId.value = id inputDisabled.value = false
console.log(message.corresponding) placeholder.value = '请输入消息内容'
} scrollToBottom()
selectedFriendId.value = id
// 定义回车键的处理函数 console.log(message.corresponding)
const handleEnter = () => { }
if (inputValue.value === '') {
ElMessage.error('消息不能为空') // 定义回车键的处理函数
return const handleEnter = () => {
} if (inputValue.value === '') {
ElMessage.error('消息不能为空')
// 发送一类消息给目标用户 return
const msg = { }
cmd: "MESSAGE",
from: userinfo.user.id, // 发送一类消息给目标用户
to: message.from, const msg = {
content: inputValue.value, cmd: "MESSAGE",
time: new Date().toLocaleString() from: userinfo.user.id,
}; to: message.from,
content: inputValue.value,
socket.send(msg) time: new Date().toLocaleString()
message.addMessage(msg) };
inputValue.value = ''
scrollToBottom() sendMessage(msg);
}; message.addMessage(msg)
inputValue.value = ''
const scrollToBottom = () => { scrollToBottom()
nextTick(() => { };
const scrollbar = scrollbarRef.value?.$el; // 获取 el-scrollbar 的根元素
if (scrollbar) { const scrollToBottom = () => {
const scrollWrap = scrollbar.querySelector(".el-scrollbar__wrap"); // 找到滚动区域 nextTick(() => {
if (scrollWrap) { const scrollbar = scrollbarRef.value?.$el; // 获取 el-scrollbar 的根元素
scrollWrap.scrollTop = scrollWrap.scrollHeight; // 滚动到底部 if (scrollbar) {
} else { const scrollWrap = scrollbar.querySelector(".el-scrollbar__wrap"); // 找到滚动区域
console.error("滚动容器未找到,请检查 el-scrollbar 的 DOM 结构"); 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)) { watch(() => message.messages.length, (newLength, oldLength) => {
messagebox.value.push(msg) const msg = message.messages[newLength - 1]
scrollToBottom() 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 const deletefriend = (id) => {
placeholder.value = '请选择聊天对象' if(deleteFriend(id)){
oppositeAvatar.value = '' inputValue.value = ''
ElMessage.success('删除好友成功') inputDisabled.value = true
friends.value = getFriends() placeholder.value = '请选择聊天对象'
return oppositeAvatar.value = ''
}else{ ElMessage.success('删除好友成功')
ElMessage.error('删除好友失败') friends.value = getFriends()
return 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) const deleteChatHisory = (u_id) => {
} catch (e) { try {
ElMessage.error('删除聊天记录失败') 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 confirmCall = () => {
const handleConfirmCall = () => { dialogVisibleCallConfirm.value = true
dialogVisibleCallConfirm.value = false }
oncall.setTarget(oppositeId.value, oppositeName.value, oppositeAvatar.value)
oncall.panelOn() const handleConfirmCall = () => {
oncall.callingOn() dialogVisibleCallConfirm.value = false
oncall.fromOn() oncall.setTarget(oppositeId.value, oppositeName.value, oppositeAvatar.value)
voice.startCall(userinfo.user.u_id, userinfo.user.u_name, userinfo.user.u_avatar, oppositeId.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 () => { const msg = {
friends.value = await getFriends() cmd: "VOICE_CALL_REQUEST",
await nextTick() from: userinfo.user.id,
console.log(friends.value) from_name: userinfo.user.u_name,
console.log(message.from) from_avatar: userinfo.user.u_avatar,
if (message.from !== '' || message.from !== null) { to: oppositeId.value,
console.log("切换聊天对象") time: new Date().toLocaleString()
const foundUser = friends.value.find((friend) => friend.id === message.from); }
switchTemplate(message.from, foundUser.u_name, foundUser.u_avatar) sendMessage(msg);
} }
})
</script> onMounted(async () => {
friends.value = await getFriends()
<style> await nextTick()
.container { console.log(friends.value)
height: 70%; console.log(message.from)
margin-top: 4%; if (message.from !== '' || message.from !== null) {
width: 80%; console.log("切换聊天对象")
} const foundUser = friends.value.find((friend) => friend.id === message.from);
switchTemplate(message.from, foundUser.u_name, foundUser.u_avatar)
.infinitelist { }
list-style: none; })
padding: 0;
margin: 0; </script>
width: 100%;
} <style>
.container {
.infinitelistitem { height: 70%;
padding: 10px; margin-top: 4%;
width: 100%; width: 80%;
} }
.infinitelistitem.selected { .infinitelist {
background-color: #f0f0f0; list-style: none;
/* 灰色背景 */ padding: 0;
} margin: 0;
width: 100%;
.messagebox { }
height: 400px;
border: 1px solid #cccccc; .infinitelistitem {
border-radius: 20px; padding: 10px;
} width: 100%;
}
.inputbox {
margin-top: 10px; .infinitelistitem.selected {
margin-left: 10px; background-color: #f0f0f0;
width: 80%; /* 灰色背景 */
} }
.user-profile-link { .messagebox {
display: inline-block; height: 400px;
text-decoration: none; border: 1px solid #cccccc;
/* 去掉链接的下划线 */ border-radius: 20px;
} }
.user-profile { .inputbox {
position: relative; margin-top: 10px;
width: 50px; margin-left: 10px;
height: 50px; width: 80%;
border-radius: 50%; }
background-color: #fff;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2); .user-profile-link {
display: inline-block;
} text-decoration: none;
/* 去掉链接的下划线 */
.user-profile img { }
width: 100%;
height: 100%; .user-profile {
object-fit: cover; position: relative;
border-radius: 50%; width: 50px;
} height: 50px;
border-radius: 50%;
.status-dot { background-color: #fff;
position: absolute; box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
bottom: -5px;
right: -5px; }
z-index: 1;
width: 20px; .user-profile img {
height: 20px; width: 100%;
border-radius: 50%; height: 100%;
border: 2px solid white; object-fit: cover;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.2); border-radius: 50%;
} }
.online { .status-dot {
background-color: green; position: absolute;
/* 在线状态 */ bottom: -5px;
} right: -5px;
z-index: 1;
.offline { width: 20px;
background-color: red; height: 20px;
/* 离线状态 */ border-radius: 50%;
} border: 2px solid white;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.2);
.message-item { }
padding: 10px;
height: 80px; .online {
width: 100%; background-color: green;
position: relative; /* 在线状态 */
} }
.message-item-profile { .offline {
position: absolute; background-color: red;
width: 40px; /* 离线状态 */
height: 40px; }
border-radius: 50%;
background-color: #fff; .message-item {
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2); padding: 10px;
display: inline-block; height: 80px;
} width: 100%;
position: relative;
.message-item-profile img { }
width: 100%;
height: 100%; .message-item-profile {
object-fit: cover; position: absolute;
border-radius: 50%; width: 40px;
} height: 40px;
border-radius: 50%;
.message-item-profile.left { background-color: #fff;
left: 20px; box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
} display: inline-block;
}
.message-item-profile.right {
right: 20px; .message-item-profile img {
} width: 100%;
height: 100%;
.message-item-content { object-fit: cover;
position: absolute; border-radius: 50%;
display: inline-block; }
bottom: 28px;
} .message-item-profile.left {
left: 20px;
.message-item-content.left { }
left: 90px;
} .message-item-profile.right {
right: 20px;
.message-item-content.right { }
right: 90px;
} .message-item-content {
position: absolute;
.message-item-time { display: inline-block;
position: absolute; bottom: 28px;
bottom: 23px; }
height: 20px;
text-align: left; .message-item-content.left {
font-size: small; left: 90px;
} }
.message-item-time.left { .message-item-content.right {
left: 20px; right: 90px;
} }
.message-item-time.right { .message-item-time {
right: 20px; position: absolute;
} bottom: 23px;
height: 20px;
.call { text-align: left;
margin: auto; font-size: small;
margin-top: 10px; }
}
.message-item-time.left {
left: 20px;
}
.message-item-time.right {
right: 20px;
}
.call {
margin: auto;
margin-top: 10px;
}
</style> </style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,88 +1,85 @@
<template> <template>
<!-- 顶部导航栏 --> <!-- 顶部导航栏 -->
<Navbar /> <Navbar />
<div class="home-container"> <div class="home-container">
<div id="mainContent" class="main-content"> <div id="mainContent" class="main-content">
<!-- 左侧主空间 --> <!-- 左侧主空间 -->
<div class="left-content"> <div class="left-content">
<router-view /> <router-view />
</div> </div>
</div> </div>
<!-- 语音面板 --> <!-- 语音面板 -->
<phonePanel /> <phonePanel />
<!-- 消息按钮 --> <!-- 消息按钮 -->
<GlobalMessageButton /> <GlobalMessageButton />
<!-- 右下角用户头像框 --> <!-- 右下角用户头像框 -->
<UserProfile /> <UserProfile />
</div> </div>
</template> </template>
<script setup> <script setup>
import { onMounted, watchEffect } from 'vue'; import { onMounted, watchEffect } from 'vue';
import Navbar from '../../components/navBar.vue'; import Navbar from '../../components/navBar.vue';
import UserProfile from '../../components/userProfile.vue'; import UserProfile from '../../components/userProfile.vue';
import { userInfoStore } from '@/store/user'; import { userInfoStore } from '@/store/user';
import { onlineSocketStore } from '@/store/Online'; import { onlineSocketStore } from '@/store/Online.ts';
import { getUserInfo } from '@/api/user'; import { getUserInfo } from '@/api/user';
import GlobalMessageButton from '@/components/GlobalMessageButton.vue'; import GlobalMessageButton from '@/components/GlobalMessageButton.vue';
import { messageStore } from '@/store/message.ts'; import { messageStore } from '@/store/message.ts';
import phonePanel from '@/components/phonePanel.vue'; import phonePanel from '@/components/phonePanel.vue';
import { voiceStore } from '@/store/Voice'; import { initDB } from '@/functions/historyMessages';
import { initDB } from '@/functions/historyMessages';
const userinfo = userInfoStore();
const voice = voiceStore(); const socket = onlineSocketStore();
const message = messageStore();
const userinfo = userInfoStore();
const socket = onlineSocketStore(); onMounted(() => {
const message = messageStore(); getUserInfo()
onMounted(() => { // 使用 watchEffect 监听 u_id 是否为空
getUserInfo() watchEffect(() => {
if (userinfo.user.u_id) { // 如果 u_id 不为空
// 使用 watchEffect 监听 u_id 是否为空 console.log('User ID is available:', userinfo.user.u_id);
watchEffect(() => { socket.connect(userinfo.user.id); // 建立 WebSocket 连接
if (userinfo.user.u_id) { // 如果 u_id 不为空 message.to = userinfo.user.id; // 设置消息发送者为当前用户 ID
console.log('User ID is available:', userinfo.user.u_id);
socket.connect(userinfo.user.id); // 建立 WebSocket 连接 // voice.connect(userinfo.user.u_id); // 建立语音通话连接
message.to = userinfo.user.id; // 设置消息发送者为当前用户 ID }
});
// voice.connect(userinfo.user.u_id); // 建立语音通话连接
} initDB(); // 初始化历史消息数据库
}); })
initDB(); // 初始化历史消息数据库
})
</script>
<style scoped>
</script> .home-container {
position: relative;
<style scoped> top: 0px;
.home-container { display: flex;
position: relative; flex-direction: column;
top: 0px; height: 100vh;
display: flex; width: 100vw;
flex-direction: column; }
height: 100vh;
width: 100vw; .main-content {
} position: relative;
left: 0;
.main-content { display: flex;
position: relative; flex: 1;
left: 0; margin-top: 72px;
display: flex;
flex: 1; }
margin-top: 72px;
.left-content {
} flex: 1;
padding: 20px;
.left-content { }
flex: 1;
padding: 20px;
}
</style> </style>

View File

@@ -1,142 +1,119 @@
<template> <template>
<div class="container"> <div class="container">
<el-button @Click="createWindow = true">创建房间</el-button> <el-button @Click="createWindow = true">创建房间</el-button>
<el-table :data="rooms"> <el-table :data="rooms">
<el-table-column label="" prop="r_avatar" width="100"> <el-table-column label="" prop="r_avatar" width="100">
<template #default="scope"> <template #default="scope">
<!-- 使用 el-avatar 组件显示头像 --> <!-- 使用 el-avatar 组件显示头像 -->
<el-avatar :src="rooms[scope.$index].r_avatar" size="large" /> <el-avatar :src="rooms[scope.$index].r_avatar" size="large" />
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="房间名" prop="r_name"></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="房间ID" prop="r_id"></el-table-column>
<el-table-column label="角色" prop="role"></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="简介" prop="r_introduction"></el-table-column>
<el-table-column label=""> <el-table-column label="">
<template #default="scope"> <template #default="scope">
<el-button @click="goToRoom(scope.row.r_id)">进入房间</el-button> <el-button @click="goToRoom(scope.row)">进入房间</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</div> </div>
<el-dialog v-model="createWindow" title="创建房间" style="width: 400px;height: 300px;"> <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 ref="form" :model="formData" label-width="80px" @submit.prevent="createroom">
<el-form-item label="房间名" prop="roomName"> <el-form-item label="房间名" prop="roomName">
<el-input v-model="formData.roomName" placeholder="请输入房间名" clearable></el-input> <el-input v-model="formData.roomName" placeholder="请输入房间名" clearable></el-input>
</el-form-item> </el-form-item>
<el-button type="primary" native-type="submit">创建</el-button> <el-button type="primary" native-type="submit">创建</el-button>
</el-form> </el-form>
</el-dialog> </el-dialog>
</template> </template>
<script setup> <script setup>
import { onMounted, reactive, ref } from 'vue' import { onMounted, reactive, ref } from 'vue'
import { roomStore } from '@/store/room'; import { PlayroomStore } from '@/store/playroom';
import axios from 'axios'; import { ElMessage } from 'element-plus';
import { userInfoStore } from '@/store/store'; import { getPlayrooms, createPlayroom } from '@/api/playroom';
import { ElMessage } from 'element-plus';
const roominfo = PlayroomStore()
const userinfo = userInfoStore() const createWindow = ref(false)
const roominfo = roomStore() const formData = reactive({
roomName: ''
const createWindow = ref(false) })
const formData = reactive({
roomName: '' const rooms = ref([])
})
const goToRoom = (r) => {
const rooms = ref([]) roominfo.setCurrentPlayroom(r);
// 跳转到房间页面
const goToRoom = (r_id) => { const baseUrl = window.location.origin;
console.log(r_id) const targetUrl = `${baseUrl}/room?r_id=${r.r_id}`; // 替换为你的目标路由
roominfo.r_id = r_id window.open(targetUrl, "room");
// 跳转到房间页面 };
const baseUrl = window.location.origin;
const targetUrl = `${baseUrl}/room`; // 替换为你的目标路由 const createroom = async () => {
window.open(targetUrl, "room"); if (!formData.roomName || formData.roomName.trim() === '') {
}; ElMessage.error('房间名不能为空')
return
const createRoom = async () => { }
if (!formData.roomName || formData.roomName.trim() === '') { try {
ElMessage.error('房间名不能为空') if(await createPlayroom(formData.roomName)){
return ElMessage.success('创建成功')
} }else{
try { ElMessage.error('创建失败')
const response = await axios.post('/api/room/create',{ }
r_name: formData.roomName formData.roomName = ''
},{ createWindow.value = false
headers: { rooms.value = await getPlayrooms()
'Content-Type': 'application/json', }
'Authorization': userinfo.token catch (error) {
}, console.log(error)
}) }
if (response.data.code === 200) { }
console.log(formData.roomName + '创建成功')
} else { const getrooms = async () => {
console.log(response.data.msg) try {
} rooms.value = await getPlayrooms()
formData.roomName = '' }
createWindow.value = false catch (error) {
getRooms() ElMessage.error('获取房间列表失败' + error)
} }
catch (error) { }
console.log(error)
} onMounted(() => {
} getrooms()
})
const getRooms = async () => { </script>
try {
const response = await axios.get('/api/room/getrooms',{ <style>
headers: { .container {
'Content-Type': 'application/json', margin-top: 20px;
'Authorization': userinfo.token width: 800px;
}, height: 600px;
}) }
if (response.data.code === 200) {
console.log(response.data.data) .profile {
rooms.value = response.data.data top: 50px;
} else { left: 100px;
console.log(response.data.msg) width: 100px;
} height: 100px;
} border-radius: 50%;
catch (error) { background-color: #fff;
console.log(error) box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
} }
}
.profile img {
onMounted(() => { width: 100%;
getRooms() height: 100%;
}) object-fit: cover;
</script> border-radius: 50%;
}
<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> </style>

View File

@@ -50,7 +50,7 @@
<el-table-column prop="r_introduction" label="个性签名" width="200"></el-table-column> <el-table-column prop="r_introduction" label="个性签名" width="200"></el-table-column>
<el-table-column> <el-table-column>
<template #default="scope"> <template #default="scope">
<el-button @click="joinRoom(scope.row.r_id)">加入房间</el-button> <el-button @click="joinroom(scope.row.r_id)">加入房间</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -69,7 +69,7 @@ import { userInfoStore } from '@/store/user'
import { onlineSocketStore } from '@/store/Online' import { onlineSocketStore } from '@/store/Online'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { searchFriend,addFriend } from '@/api/friend' import { searchFriend,addFriend } from '@/api/friend'
import { searchPlayRoom } from '@/api/room' import { searchPlayRoom, joinPlayroom } from '@/api/playroom'
const socket = onlineSocketStore() const socket = onlineSocketStore()
const inputValue = ref('') const inputValue = ref('')
@@ -123,26 +123,16 @@ const addfriend = async (f_id) => {
} }
//加入房间申请逻辑 //加入房间申请逻辑
const joinRoom = (r_id) => { const joinroom = (r_id) => {
axios({ try{
headers: { if(joinPlayroom(r_id)){
'Authorization': userinfo.token, ElMessage.success("请求发送成功,请耐心等待审核")
}, return
url: '/api/inviting/sendinviting', }
method: 'POST', }catch(error){
data: { ElMessage.error("加入房间失败:",error)
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("请勿重复发送!")
}
})
} }
</script> </script>

View File

@@ -1 +1 @@
<template></template> <template>设置</template>

View File

@@ -25,7 +25,8 @@
<ul class="memberlist" style="overflow: auto"> <ul class="memberlist" style="overflow: auto">
<li v-for="(item) in members" :key="item.u_id" class="memberlistitem"> <li v-for="(item) in members" :key="item.u_id" class="memberlistitem">
<div class="user-profile"> <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 :class="['status-dot', item.status]"></div>
</div> </div>
<el-text style="width: 100%;" truncated>{{ item.u_name }}#{{ item.u_id }}</el-text> <el-text style="width: 100%;" truncated>{{ item.u_name }}#{{ item.u_id }}</el-text>
@@ -36,45 +37,16 @@
</el-row> </el-row>
</el-col> </el-col>
<el-col :span="16"> <el-col :span="20">
<video-player :autoplay="false" :videoUrl="videoUrl" /> <video-player
</el-col> ref="videoPlayerRef"
<el-col :span="4"> :autoplay="false"
<el-row style="height: 50%"> :videoUrl="currentURL"
<!-- 语音大厅 --> @canplay="handlePlayerCanplay"
<el-col :span="24" class="voice-room"> @play="handleLocalPlay"
<!-- <audio-player :audioUrl="audioUrl" /> --> @pause="handleLocalPause"
<el-row> @remote-play-failed="handleRemotePlayFailed"
<!-- 加入的语言用户 --> />
<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> </el-col>
</el-row> </el-row>
</div> </div>
@@ -96,7 +68,8 @@
<el-col :span="24"> <el-col :span="24">
<div class="updateProfile"> <div class="updateProfile">
<div class="profile"> <div class="profile">
<img :src="avatarPreview" alt="头像"> <!-- <img :src="avatarPreview" alt="头像"> -->
<img :src="defaultAvatar" alt="User Avatar" />
</div> </div>
<el-col :span="12" v-if="role === 1 || role === 0"> <el-col :span="12" v-if="role === 1 || role === 0">
<input type="file" id="avatar" @change="handleAvatarChange" accept="image/*" /> <input type="file" id="avatar" @change="handleAvatarChange" accept="image/*" />
@@ -140,7 +113,7 @@
<el-dialog title="设置/替换视频流" v-model="dialogVisibleVideo"> <el-dialog title="设置/替换视频流" v-model="dialogVisibleVideo">
<el-row> <el-row>
<el-col :span="20"> <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>
<el-col :span="4"> <el-col :span="4">
<el-button>测试</el-button> <el-button>测试</el-button>
@@ -157,24 +130,69 @@
</template> </template>
<script setup> <script setup>
import { onMounted, ref, watchEffect } from 'vue'; import { nextTick, onBeforeUnmount, onMounted, ref, watchEffect } from 'vue';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import { userInfoStore } from '@/store/store'; import { userInfoStore } from '@/store/user';
import videoPlayer from '@/components/videoPlayer.vue'; 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'; // //import audioPlayer from '@/components/audioPlayer.vue'; //
const curruentRoomInfo = roomStore(); const curruentRoomInfo = PlayroomStore();
const userinfo = userInfoStore(); 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 dialogVisibleCode = ref(false)
const dialogVisibleVideo = ref(false) const dialogVisibleVideo = ref(false)
const invoice = ref(false) const invoice = ref(false);
const drawer = ref(false); const drawer = ref(false);
const role = ref(null); const role = ref(null);
const invitingCode = ref('666666') 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 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 avatar = ref(null)
const avatarPreview = ref('') 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 audioUrl = ref('https://example.com/audio/stream'); //
const danmakuOptions = ref({ 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) => { const handleAvatarChange = (event) => {
@@ -300,32 +323,7 @@ const handleAvatarChange = (event) => {
// //
const uploadAvatar = () => { 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 = () => { const getInvitingCode = () => {
@@ -333,10 +331,150 @@ const getInvitingCode = () => {
} }
const replaceURL = () => { 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; 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 = () => { const joinVoiceRoom = () => {
membersInVoice.value.push({ membersInVoice.value.push({
@@ -359,31 +497,44 @@ const goback = () => {
} }
onMounted(() => { const syncListener = (e) => handleVideoSync(e?.detail);
getRoomInfo() const playListener = (e) => applyRemotePlayPause(e?.detail, 'play');
const pauseListener = (e) => applyRemotePlayPause(e?.detail, 'pause');
// onMounted(async () => {
curruentRoomInfo.r_id = '123456'; const r_id = window.location.search.split('=')[1];
curruentRoomInfo.r_name = '弹幕聊天室'; // homegetUserInfo id/u_id
curruentRoomInfo.currentURL = 'https://www.5dm.link/api/dd.php?vid=ccccxhndnys1&cid=ccccxhndnys1&xid=0&pid=55293&tid=1742788904&t=616d5131b6ade51a0e20814466b13515&ext=.mp4'; if (
curruentRoomInfo.r_avatar = 'https://merlin.xin/avatars/avatar'; userinfo.token &&
role.value = 0; (!String(userinfo.user?.u_id || '').trim() || userinfo.user?.id === 0)
avatarPreview.value = curruentRoomInfo.r_avatar; ) {
const ok = await getUserInfo();
if (!ok && getSelfFrom() === 0) {
watchEffect(() => { ElMessage.warning('用户信息未就绪,播放同步里的 from 可能为 0请重新登录或从首页进入房间');
if (curruentRoomInfo.r_id !== '' && curruentRoomInfo.r_id !== null) { // r_id
console.log('Room ID is available:', curruentRoomInfo.r_id);
//
//
avatarPreview.value = curruentRoomInfo.r_avatar;
} }
}) }
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 子协议,不校验 idfrom 为 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> </script>
@@ -396,11 +547,12 @@ onMounted(() => {
.container { .container {
position: relative; position: relative;
top: 50px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 80vh; height: 80%;
width: 80vw; width: 100%;
padding: 50px;
max-height: 100vh;
} }
/* .maincontent {} */ /* .maincontent {} */

View File

@@ -1,496 +1,498 @@
<template> <template>
<el-row> <el-row>
<el-col :span="12"> <el-col :span="12">
<div class="updateProfile"> <div class="updateProfile">
<div class="profile"> <div class="profile">
<img :src="avatarPreview" alt="头像"> <!-- 原逻辑:src="avatarPreview"远端头像/本地预览 -->
</div> <img :src="defaultAvatar" alt="头像">
<el-col span="12"> </div>
<input type="file" id="avatar" @change="handleAvatarChange" accept="image/*" /> <el-col span="12">
</el-col> <input type="file" id="avatar" @change="handleAvatarChange" accept="image/*" />
<el-col span="12"> </el-col>
<el-button class="re-profile" @click="uploadAvatar">修改头像</el-button> <el-col span="12">
</el-col> <el-button class="re-profile" @click="uploadAvatar">修改头像</el-button>
<el-col span="12"> </el-col>
<span>推荐头像参数<br> <el-col span="12">
分辨率1051*1051 96dpi <br> <span>推荐头像参数<br>
格式jpgpng <br> 分辨率1051*1051 96dpi <br>
大小1M 格式jpgpng <br>
</span> 大小1M
</el-col> </span>
</div> </el-col>
</el-col> </div>
<el-col :span="12"> </el-col>
<el-row> <el-col :span="12">
<span style="width: 80%;">用户名{{ userinfo.user.u_name }}</span> <el-row>
<el-button @click="dialogVisibleChangeName = true">修改名字</el-button> <span style="width: 80%;">用户名{{ userinfo.user.u_name }}</span>
</el-row> <el-button @click="dialogVisibleChangeName = true">修改名字</el-button>
<el-row> </el-row>
<span style="width: 80%;">账号{{ userinfo.account }}</span> <el-row>
<!-- <el-button @click="dialogVisibleChangeAccount = true">修改账号</el-button> --> <span style="width: 80%;">账号{{ userinfo.account }}</span>
</el-row> <!-- <el-button @click="dialogVisibleChangeAccount = true">修改账号</el-button> -->
<el-row> </el-row>
<span style="width: 80%;">个性签名{{ userinfo.user.u_introduction }}</span> <el-row>
<el-button @click="dialogVisibleChangeIntro = true">修改简介</el-button> <span style="width: 80%;">个性签名{{ userinfo.user.u_introduction }}</span>
</el-row> <el-button @click="dialogVisibleChangeIntro = true">修改简介</el-button>
<el-row> </el-row>
<span style="width: 80%;">密码 *********************</span> <el-row>
<el-button @click="dialogVisibleChangePassword = true">修改密码</el-button> <span style="width: 80%;">密码 *********************</span>
</el-row> <el-button @click="dialogVisibleChangePassword = true">修改密码</el-button>
</el-col> </el-row>
</el-row> </el-col>
<!-- 修改名字弹窗 --> </el-row>
<el-dialog v-model="dialogVisibleChangeName" title="修改名字" width="500"> <!-- 修改名字弹窗 -->
<el-input v-model="newname" autocomplete="off" /> <el-dialog v-model="dialogVisibleChangeName" title="修改名字" width="500">
<template #footer> <el-input v-model="newname" autocomplete="off" />
<div class="dialog-footer"> <template #footer>
<el-button @click="dialogVisibleChangeName = false">算了</el-button> <div class="dialog-footer">
<el-button type="primary" @click="changeName"> <el-button @click="dialogVisibleChangeName = false">算了</el-button>
<el-button type="primary" @click="changeName">
</el-button>
</div> </el-button>
</template> </div>
</el-dialog> </template>
<!-- 修改个签弹窗 --> </el-dialog>
<el-dialog v-model="dialogVisibleChangeIntro" title="修改个签" width="500"> <!-- 修改个签弹窗 -->
<el-input v-model="newintro" autocomplete="off" size="large" /> <el-dialog v-model="dialogVisibleChangeIntro" title="修改个签" width="500">
<template #footer> <el-input v-model="newintro" autocomplete="off" size="large" />
<div class="dialog-footer"> <template #footer>
<el-button @click="dialogVisibleChangeIntro = false">算了</el-button> <div class="dialog-footer">
<el-button type="primary" @click="changeIntro"> <el-button @click="dialogVisibleChangeIntro = false">算了</el-button>
<el-button type="primary" @click="changeIntro">
</el-button>
</div> </el-button>
</template> </div>
</el-dialog> </template>
<!-- 修改邮箱弹窗 --> </el-dialog>
<el-dialog v-model="dialogVisibleChangeAccount" title="修改邮箱" width="500"> <!-- 修改邮箱弹窗 -->
<el-row style="margin-bottom: 10px;"> <el-dialog v-model="dialogVisibleChangeAccount" title="修改邮箱" width="500">
<el-col span="6">当前邮箱</el-col> <el-row style="margin-bottom: 10px;">
<el-col span="18">{{ userinfo.user.u_account }}</el-col> <el-col span="6">当前邮箱</el-col>
</el-row> <el-col span="18">{{ userinfo.user.u_account }}</el-col>
<el-form ref="formRef" :model="formData" :rules="rules"> </el-row>
<el-form-item label="新邮箱" prop="newaccount"> <el-form ref="formRef" :model="formData" :rules="rules">
<el-col span="24"> <el-form-item label="新邮箱" prop="newaccount">
<el-input v-model="formData.newaccount" autocomplete="off" placeholder="请输入邮箱地址" <el-col span="24">
style="width: 300px;" /> <el-input v-model="formData.newaccount" autocomplete="off" placeholder="请输入邮箱地址"
</el-col> style="width: 300px;" />
</el-form-item> </el-col>
<el-form-item label="验证码" prop="code"> </el-form-item>
<el-col span="18"> <el-form-item label="验证码" prop="code">
<el-input v-model="formData.code" autocomplete="off" placeholder="请输入验证码" style="width: 225px;" /> <el-col span="18">
</el-col> <el-input v-model="formData.code" autocomplete="off" placeholder="请输入验证码" style="width: 225px;" />
<el-col span="6"> </el-col>
<button class="codebutton" type="button" :disabled="isCountingDown" @click="sendVerificationCode"> <el-col span="6">
{{ isCountingDown ? `${countdownTime} s` : 'Get Code' }} <button class="codebutton" type="button" :disabled="isCountingDown" @click="sendVerificationCode">
</button> {{ isCountingDown ? `${countdownTime} s` : 'Get Code' }}
</el-col> </button>
</el-form-item> </el-col>
<el-form-item> </el-form-item>
<div class="dialog-footer"> <el-form-item>
<el-button @click="dialogVisibleChangeAccount = false">算了</el-button> <div class="dialog-footer">
<el-button type="primary" @click="changeAccount"> <el-button @click="dialogVisibleChangeAccount = false">算了</el-button>
<el-button type="primary" @click="changeAccount">
</el-button>
</div> </el-button>
</el-form-item> </div>
</el-form> </el-form-item>
</el-dialog> </el-form>
<!-- 修改密码弹窗 --> </el-dialog>
<el-dialog v-model="dialogVisibleChangePassword" title="修改密码" width="500"> <!-- 修改密码弹窗 -->
<el-form ref="formRedPassword" :model="password" :rules="passwordRules"> <el-dialog v-model="dialogVisibleChangePassword" title="修改密码" width="500">
<el-form-item label="原密码" prop="oldpassword"> <el-form ref="formRedPassword" :model="password" :rules="passwordRules">
<el-col span="24"> <el-form-item label="原密码" prop="oldpassword">
<el-input v-model="password.oldpassword" autocomplete="off" placeholder="请输入原密码" <el-col span="24">
show-password="true" style="width: 300px;" /> <el-input v-model="password.oldpassword" autocomplete="off" placeholder="请输入原密码"
</el-col> show-password="true" style="width: 300px;" />
</el-form-item> </el-col>
<el-form-item label="新密码" prop="password"> </el-form-item>
<el-col span="24"> <el-form-item label="新密码" prop="password">
<el-input v-model="password.password" autocomplete="off" placeholder="请输入新密码" show-password="true" <el-col span="24">
style="width: 300px;" /> <el-input v-model="password.password" autocomplete="off" placeholder="请输入新密码" show-password="true"
</el-col> style="width: 300px;" />
</el-form-item> </el-col>
<el-form-item label="确认密码" prop="confirmPassword"> </el-form-item>
<el-col span="24"> <el-form-item label="确认密码" prop="confirmPassword">
<el-input v-model="password.confirmPassword" autocomplete="off" placeholder="确认密码" <el-col span="24">
show-password="true" style="width: 300px;" /> <el-input v-model="password.confirmPassword" autocomplete="off" placeholder="确认密码"
</el-col> show-password="true" style="width: 300px;" />
</el-form-item> </el-col>
<el-form-item> </el-form-item>
<div class="dialog-footer"> <el-form-item>
<el-button @click="dialogVisibleChangePassword = false">算了</el-button> <div class="dialog-footer">
<el-button type="primary" @click="changePassword"> <el-button @click="dialogVisibleChangePassword = false">算了</el-button>
<el-button type="primary" @click="changePassword">
</el-button>
</div> </el-button>
</el-form-item> </div>
</el-form-item>
</el-form>
</el-form>
</el-dialog>
</el-dialog>
</template>
<script setup> </template>
import { userInfoStore } from '@/store/user'; <script setup>
import axios from 'axios'; import { userInfoStore } from '@/store/user';
import { ElMessage, ElMessageBox } from 'element-plus'; import axios from 'axios';
import { reactive, ref } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus';
import { getUserInfo } from '@/functions/user'; import { reactive, ref } from 'vue'
import { getUserInfo } from '@/functions/user';
const userinfo = userInfoStore(); import defaultAvatar from '@/assets/defaultavatar.jpg'
console.log(userinfo.account);
const userinfo = userInfoStore();
console.log(userinfo.account);
const dialogVisibleChangeName = ref(false)
const dialogVisibleChangeIntro = ref(false)
const dialogVisibleChangeAccount = ref(false) const dialogVisibleChangeName = ref(false)
const dialogVisibleChangePassword = ref(false) const dialogVisibleChangeIntro = ref(false)
const isCountingDown = ref(false); const dialogVisibleChangeAccount = ref(false)
const countdownTime = ref(60); const dialogVisibleChangePassword = ref(false)
const isCountingDown = ref(false);
const countdownTime = ref(60);
const newname = ref('')
const newintro = ref('')
const newname = ref('')
const formRef = ref(null); // 表单引用 const newintro = ref('')
const formData = ref({
newaccount: '', const formRef = ref(null); // 表单引用
v_id: '', const formData = ref({
code: '' // 初始化表单数据 newaccount: '',
}); v_id: '',
const formRedPassword = ref(null); // 密码表单引用 code: '' // 初始化表单数据
const password = ref({ });
oldpassword: '', const formRedPassword = ref(null); // 密码表单引用
password: '', const password = ref({
confirmPassword: '' oldpassword: '',
}); // 密码输入框 password: '',
confirmPassword: ''
// 定义验证规则 }); // 密码输入框
const rules = {
newaccount: [ // 定义验证规则
{ required: true, message: '请输入账号', trigger: 'blur' }, // 非空验证 const rules = {
{ type: 'email', message: '请输入有效的邮箱地址', trigger: ['blur', 'change'] } // 邮箱格式验证 newaccount: [
], { required: true, message: '请输入账号', trigger: 'blur' }, // 非空验证
code: [ { type: 'email', message: '请输入有效的邮箱地址', trigger: ['blur', 'change'] } // 邮箱格式验证
{ required: true, message: '请输入验证码', trigger: 'blur' }, // 非空验证 ],
] code: [
}; { required: true, message: '请输入验证码', trigger: 'blur' }, // 非空验证
// 定义密码验证规则 ]
const passwordRules = { };
oldpassword: [ // 定义密码验证规则
{ required: true, message: '请输入原密码', trigger: 'blur' }, // 非空验证 const passwordRules = {
], oldpassword: [
password: [ { required: true, message: '请输入原密码', trigger: 'blur' }, // 非空验证
{ required: true, message: '请输入新密码', trigger: 'blur' }, // 非空验证 ],
{ min: 6, message: '密码长度至少6位', trigger: 'blur' }, // 密码长度验证 password: [
], { required: true, message: '请输入新密码', trigger: 'blur' }, // 非空验证
confirmPassword: [ { min: 6, message: '密码长度至少6位', trigger: 'blur' }, // 密码长度验证
{ 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 avatarPreview = ref(userinfo.user.u_avatar); // 原:远端头像预览
const avatar = ref(null); // 存储上传的头像文件
// 处理头像预览并将文件保存到变量
const handleAvatarChange = (event) => {
const file = event.target.files[0]; // 处理头像预览并将文件保存到变量
if (file) { const handleAvatarChange = (event) => {
avatar.value = file; const file = event.target.files[0];
avatarPreview.value = URL.createObjectURL(file); if (file) {
} avatar.value = file;
}; // avatarPreview.value = URL.createObjectURL(file); // 原:本地预览
}
//上传到服务器 };
const uploadAvatar = () => {
if(!avatar.value){ //上传到服务器
ElMessage('请选择头像') const uploadAvatar = () => {
return if(!avatar.value){
} ElMessage('请选择头像')
const formdata = new FormData(); return
formdata.append('file', avatar.value) }
formdata.append('id', userinfo.user.u_id) const formdata = new FormData();
axios({ formdata.append('file', avatar.value)
headers: { formdata.append('id', userinfo.user.u_id)
'Content-Type': 'multipart/form-data', axios({
'Authorization': userinfo.token headers: {
}, 'Content-Type': 'multipart/form-data',
method: 'post', 'Authorization': userinfo.token
url: '/api/avatar/upload', },
data: formdata method: 'post',
}).then((response) => { url: '/api/avatar/upload',
if (response.data.code !== 200) { data: formdata
ElMessage.error(response.data.msg || '上传失败') }).then((response) => {
return if (response.data.code !== 200) {
} ElMessage.error(response.data.msg || '上传失败')
getUserInfo() return
ElMessage.success('上传成功') }
}).catch((error) => { getUserInfo()
console.error('上传失败:', error); ElMessage.success('上传成功')
ElMessage.error('上传失败') }).catch((error) => {
}) console.error('上传失败:', error);
} ElMessage.error('上传失败')
})
}
const changeName = async () => {
const response = await axios.post('/api/user/updatename', { const changeName = async () => {
u_name: newname.value
} const response = await axios.post('/api/user/updatename', {
, { u_name: newname.value
headers: { }
'Authorization': userinfo.token, , {
'Content-Type': 'application/json' headers: {
} 'Authorization': userinfo.token,
}); 'Content-Type': 'application/json'
if (response.data.code === 200) { }
userinfo.user.u_name = newname.value });
newname.value = '' if (response.data.code === 200) {
dialogVisibleChangeName.value = false userinfo.user.u_name = newname.value
ElMessage.success('修改成功') newname.value = ''
} else { dialogVisibleChangeName.value = false
ElMessage.error(response.data.msg) ElMessage.success('修改成功')
} } else {
} ElMessage.error(response.data.msg)
}
const changeIntro = async () => { }
console.log(newintro.value);
const response = await axios.post('/api/user/updateintroduction', { const changeIntro = async () => {
u_introduction: newintro.value console.log(newintro.value);
}, { const response = await axios.post('/api/user/updateintroduction', {
headers: { u_introduction: newintro.value
'Authorization': userinfo.token, }, {
'Content-Type': 'application/json' headers: {
} 'Authorization': userinfo.token,
}); 'Content-Type': 'application/json'
if (response.data.code === 200) { }
userinfo.user.u_introduction = newintro.value });
newintro.value = '' if (response.data.code === 200) {
dialogVisibleChangeIntro.value = false userinfo.user.u_introduction = newintro.value
ElMessage.success('修改成功') newintro.value = ''
} else { dialogVisibleChangeIntro.value = false
ElMessage.error(response.data.msg) ElMessage.success('修改成功')
} } else {
} ElMessage.error(response.data.msg)
}
// 发送验证码 }
const sendVerificationCode = () => {
formRef.value.validateField('newaccount', (isValid) => { // 发送验证码
console.log(isValid); const sendVerificationCode = () => {
if (isValid) { formRef.value.validateField('newaccount', (isValid) => {
// 如果邮箱验证通过 console.log(isValid);
ElMessage.success('邮箱格式正确,正在发送验证码...'); if (isValid) {
axios({ // 如果邮箱验证通过
headers: { ElMessage.success('邮箱格式正确,正在发送验证码...');
'Content-Type': 'application/json' axios({
}, headers: {
method: "post", 'Content-Type': 'application/json'
url: "/api/code/sendcode", },
data: JSON.stringify({ u_account: formData.value.newaccount }) method: "post",
}) url: "/api/code/sendcode",
.then((response) => { data: JSON.stringify({ u_account: formData.value.newaccount })
if (response.data && response.data.code !== 200) { })
ElMessageBox.alert("注册失败,请重新填写 msg:" + (response.data.msg || '未知错误'), '注册失败'); .then((response) => {
} else { if (response.data && response.data.code !== 200) {
startCountdown(); ElMessageBox.alert("注册失败,请重新填写 msg:" + (response.data.msg || '未知错误'), '注册失败');
formData.value.v_id = response.data.data; } else {
console.log(formData.value.v_id); startCountdown();
ElMessageBox.alert('发送成功,请耐心等待', '请求成功'); formData.value.v_id = response.data.data;
} console.log(formData.value.v_id);
}) ElMessageBox.alert('发送成功,请耐心等待', '请求成功');
.catch((error) => { }
console.error("请求失败:", error); })
ElMessageBox.alert("请求失败,请稍后再试", '网络错误'); .catch((error) => {
}); console.error("请求失败:", error);
} else { ElMessageBox.alert("请求失败,请稍后再试", '网络错误');
// 如果邮箱验证失败 });
ElMessage.error('请输入有效的邮箱地址'); } else {
return; // 如果邮箱验证失败
} ElMessage.error('请输入有效的邮箱地址');
}); return;
}
}; });
};
// 开始倒计时
const startCountdown = () => {
isCountingDown.value = true; // 开始倒计时
const interval = setInterval(() => { const startCountdown = () => {
countdownTime.value--; isCountingDown.value = true;
if (countdownTime.value <= 0) { const interval = setInterval(() => {
clearInterval(interval); countdownTime.value--;
isCountingDown.value = false; if (countdownTime.value <= 0) {
countdownTime.value = 60; clearInterval(interval);
} isCountingDown.value = false;
}, 1000); countdownTime.value = 60;
}; }
}, 1000);
};
// 验证验证码,并进行邮箱更改
const changeAccount = async () => {
// 验证验证码,并进行邮箱更改
// 验证新邮箱字段 const changeAccount = async () => {
formRef.value.validateField(['newaccount', 'code'], (errorMessage) => {
console.log(errorMessage); // 验证新邮箱字段
if (errorMessage) { formRef.value.validateField(['newaccount', 'code'], (errorMessage) => {
// 如果验证通过(errorMessage为空) console.log(errorMessage);
ElMessage.success('正在修改邮箱...'); if (errorMessage) {
// 验证验证码 // 如果验证通过errorMessage为空
axios({ ElMessage.success('正在修改邮箱...');
headers: { // 验证验证码
'Content-Type': 'application/json' axios({
}, headers: {
method: "post", 'Content-Type': 'application/json'
url: "/api/code/verifycode", },
data: { method: "post",
v_id: formData.value.v_id, url: "/api/code/verifycode",
code: formData.value.code data: {
} v_id: formData.value.v_id,
}).then((response) => { code: formData.value.code
if (response.data.code !== 200) { }
ElMessage.error(response.data.msg || '验证码错误'); }).then((response) => {
return; if (response.data.code !== 200) {
} else { ElMessage.error(response.data.msg || '验证码错误');
// 验证码验证通过,发起修改邮箱的请求 return;
axios({ } else {
headers: { // 验证码验证通过,发起修改邮箱的请求
'Content-Type': 'application/json', axios({
'Authorization': userinfo.token headers: {
}, 'Content-Type': 'application/json',
method: "post", 'Authorization': userinfo.token
url: "/api/user/updateaccount", },
data: { method: "post",
u_account: formData.value.newaccount url: "/api/user/updateaccount",
} data: {
}).then((response) => { u_account: formData.value.newaccount
if (response.data.code !== 200) { }
ElMessage.error(response.data.msg || '修改失败'); }).then((response) => {
return; if (response.data.code !== 200) {
} else { ElMessage.error(response.data.msg || '修改失败');
ElMessage.success('邮箱修改成功'); return;
userinfo.user.u_account = formData.value.newaccount; // 更新当前邮箱 } else {
formRef.value.resetFields(); // 重置表单数据 ElMessage.success('邮箱修改成功');
formRef.value.clearValidate(); // 清除验证错误 userinfo.user.u_account = formData.value.newaccount; // 更新当前邮箱
dialogVisibleChangeAccount.value = false; // 关闭弹窗 formRef.value.resetFields(); // 重置表单数据
} formRef.value.clearValidate(); // 清除验证错误
}) dialogVisibleChangeAccount.value = false; // 关闭弹窗
} }
}) })
} else { }
// 如果验证失败errorMessage不为空 })
ElMessage.error('请输入正确的验证码'); } else {
} // 如果验证失败errorMessage不为空
}); ElMessage.error('请输入正确的验证码');
}; }
});
// 修改密码 };
const changePassword = async () => {
// 验证密码字段 // 修改密码
formRedPassword.value.validateField(['oldpassword', 'password', 'confirmPassword'], (errorMessage) => { const changePassword = async () => {
console.log(errorMessage); // 验证密码字段
if (password.value.password !== password.value.confirmPassword) { formRedPassword.value.validateField(['oldpassword', 'password', 'confirmPassword'], (errorMessage) => {
ElMessage.error('两次密码输入不一致'); console.log(errorMessage);
return; if (password.value.password !== password.value.confirmPassword) {
} ElMessage.error('两次密码输入不一致');
if (password.value.password === password.value.oldpassword) { return;
ElMessage.error('密码一样你改密码呢'); }
return; if (password.value.password === password.value.oldpassword) {
} ElMessage.error('密码一样你改密码呢');
if (errorMessage) { return;
// 如果验证通过errorMessage为空 }
ElMessage.success('正在修改密码...'); if (errorMessage) {
// 发送修改密码请求 // 如果验证通过errorMessage为空
axios({ ElMessage.success('正在修改密码...');
headers: { // 发送修改密码请求
'Content-Type': 'application/x-www-form-urlencoded', axios({
'Authorization': userinfo.token headers: {
}, 'Content-Type': 'application/x-www-form-urlencoded',
method: "post", 'Authorization': userinfo.token
url: "/api/user/updatepassword", },
data: password.value method: "post",
}).then((response) => { url: "/api/user/updatepassword",
if (response.data.code !== 200) { data: password.value
ElMessage.error(response.data.msg || '修改失败'); }).then((response) => {
return; if (response.data.code !== 200) {
} else { ElMessage.error(response.data.msg || '修改失败');
ElMessage.success('密码修改成功'); return;
formRedPassword.value.resetFields(); // 重置表单数据 } else {
formRedPassword.value.clearValidate(); // 清除验证错误 ElMessage.success('密码修改成功');
dialogVisibleChangePassword.value = false; // 关闭弹窗 formRedPassword.value.resetFields(); // 重置表单数据
} formRedPassword.value.clearValidate(); // 清除验证错误
}) dialogVisibleChangePassword.value = false; // 关闭弹窗
} else { }
// 如果验证失败errorMessage不为空 })
ElMessage.error('请输入正确的密码'); } else {
} // 如果验证失败errorMessage不为空
}); ElMessage.error('请输入正确的密码');
}; }
});
</script> };
<style>
.updateProfile { </script>
position: relative; <style>
} .updateProfile {
position: relative;
.profile { }
top: 50px;
left: 100px; .profile {
width: 150px; top: 50px;
height: 150px; left: 100px;
border-radius: 50%; width: 150px;
background-color: #fff; height: 150px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2); border-radius: 50%;
} background-color: #fff;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
.profile img { }
width: 100%;
height: 100%; .profile img {
object-fit: cover; width: 100%;
border-radius: 50%; height: 100%;
} object-fit: cover;
border-radius: 50%;
.userinfo { }
position: relative;
} .userinfo {
position: relative;
.re-profile { }
margin-top: 20px; .re-profile {
}
margin-top: 20px;
.el-row { }
margin-top: 30px;
} .el-row {
margin-top: 30px;
.dialog-footer { }
position: relative;
margin-left: 300px; .dialog-footer {
} position: relative;
margin-left: 300px;
.codebutton { }
width: 75px;
padding: 8px; .codebutton {
background-color: #007bff; width: 75px;
color: white; padding: 8px;
border: none; background-color: #007bff;
border-radius: 5px; color: white;
cursor: pointer; border: none;
} border-radius: 5px;
cursor: pointer;
.codebutton:hover { }
background-color: #0056b3;
} .codebutton:hover {
background-color: #0056b3;
.codebutton:disabled { }
background-color: #ccc;
cursor: not-allowed; .codebutton:disabled {
} background-color: #ccc;
cursor: not-allowed;
}
</style> </style>

View File

@@ -1,204 +1,218 @@
import { ref } from "vue"; import { ref } from "vue";
import { userInfoStore } from "@/store/store"; import { userInfoStore } from "@/store/user";
import { messageStore } from "@/store/message"; import { messageStore } from "@/store/message";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { messageSignStore } from "@/store/message_sign"; import { messageSignStore } from "@/store/message_sign";
import { groupMessageStore } from "@/store/group_message";
// userinfo 实例 import { onCallStore } from "@/store/VoiceTarget";
const userinfo = userInfoStore(); import { handleOffer, handleAnswer, closeConnection, handleCandidate } from "@/store/Voice.ts";
// message 实例
const message = messageStore(); // userinfo 实例
// groupMessage 实例 const userinfo = userInfoStore();
// const groupMessage = groupMessageStore(); // message 实例
// messageSignStoregn 实例 const message = messageStore();
// const messageSign = messageSignStore(); // groupMessage 实例
const groupMessage = groupMessageStore();
// WebSocket 实例 // oncall 实例
const socket = ref(null); const oncall = onCallStore();
const messageSign = messageSignStore(); // messageSignStoregn 实例
// const messageSign = messageSignStore();
const isManualClose = ref(false);
// WebSocket 实例
const reconnectScheduled = ref(false); const socket = ref(null);
const messageSign = messageSignStore();
const retryCount = ref(0);
const isManualClose = ref(false);
export const getRetryCount = () => {
return retryCount.value; const reconnectScheduled = ref(false);
};
const retryCount = ref(0);
export const addRetryCount = () => {
retryCount.value = retryCount.value + 1;
}; export const getRetryCount = () => {
return retryCount.value;
export const resetRetryCount = () => { };
retryCount.value = 0;
}; export const addRetryCount = () => {
export const setReconnectScheduled = (value) => { retryCount.value = retryCount.value + 1;
reconnectScheduled.value = value; };
};
export const resetRetryCount = () => {
export const getReconnectScheduled = () => { retryCount.value = 0;
return reconnectScheduled.value; };
}; export const setReconnectScheduled = (value) => {
reconnectScheduled.value = value;
export const setIsManualClose = (value) => { };
isManualClose.value = value;
}; export const getReconnectScheduled = () => {
return reconnectScheduled.value;
export const getIsManualClose = () => { };
return isManualClose.value;
}; export const setIsManualClose = (value) => {
isManualClose.value = value;
// 连接WebSocket };
export const connectWebSocket = () => {
const protocol = window.location.protocol === "https:" ? "wss://" : "ws://"; export const getIsManualClose = () => {
const host = window.location.host; return isManualClose.value;
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}`; // 连接WebSocket
if (socket.value && socket.value.readyState !== WebSocket.CLOSED) { export const connectWebSocket = () => {
console.log("还在重连中..."); const protocol = window.location.protocol === "https:" ? "wss://" : "ws://";
return; const host = window.location.host;
} const socketUrl = `${protocol}${host}/ws/online?name=${userinfo.user.u_name}`;
const retrytime = getRetryCount();
if (retrytime >= 10) { // const socketUrl = `ws://localhost:8080/online?u_id=${userinfo.user.u_id}&u_name=${userinfo.user.u_name}`;
console.log("重连失败,请稍后再试"); if (socket.value && socket.value.readyState !== WebSocket.CLOSED) {
return; console.log("还在重连中...");
} return;
console.log(retrytime); }
socket.value = new WebSocket(socketUrl, "token-"+ userinfo.token); const retrytime = getRetryCount();
if (retrytime >= 10) {
socket.value.onopen = (event) => { console.log("重连失败,请稍后再试");
console.log("WebSocket连接已建立", event); return;
setReconnectScheduled(false); }
setIsManualClose(false); console.log(retrytime);
resetRetryCount(); socket.value = new WebSocket(socketUrl, "token-"+ userinfo.token);
};
socket.value.onopen = (event) => {
//处理消息逻辑 console.log("WebSocket连接已建立", event);
socket.value.onmessage = (event) => { setReconnectScheduled(false);
console.log("从服务器收到消息:", event.data); setIsManualClose(false);
try{ resetRetryCount();
const MessageData = JSON.parse(event.data); };
const cmd = MessageData.cmd;
switch(cmd){ //处理消息逻辑
case "MESSAGE": socket.value.onmessage = async (event) => {
message.addMessage(MessageData); console.log("从服务器收到消息:", event.data);
// messageSign.setMessageSign(true); try{
// ElMessage.info("您有一条新的消息"); const MessageData = JSON.parse(event.data);
break; const cmd = MessageData.cmd;
case "PERSONAL_NOTIFY": switch(cmd){
messageSign.setMessageSign(true); case "MESSAGE":
ElMessage.info("您有一条新的邀请消息"); message.addMessage(MessageData);
break; // TODO需要使用u_name或者u_id进行消息标记
} // messageSign.setMessageSign(true);
}catch(error){ // ElMessage.info("您有一条新的消息");
console.error("解析 JSON 失败:", error); break;
} case "PERSONAL_NOTIFY":
// try { messageSign.setMessageSign(true);
// const MessageData = JSON.parse(event.data); ElMessage.info("您有一条新的邀请消息");
// //是否为上下线消息(三类消息) break;
// if (!MessageData.system) { case "GROUP_MESSAGE":
// //是否为新消息 groupMessage.recieveMessage(userinfo.user.id, MessageData);
// if (MessageData.message) {
// messagePoint.hasNewMessage = true; // TODO: 需要使用groupId进行消息标记
// if (MessageData.group) { break;
// //四类消息,群聊消息 case "VIDEO_SYNC":
// console.log("有新群消息"); console.log("视频同步消息", MessageData);
// groupMessage.addMessage(MessageData); break;
// console.log(groupMessage.messages);
// messageSign.addSign({ // 语音通话相关:
// sender_name: MessageData.sender_name, case "VOICE_CALL_REQUEST":
// g_id: MessageData.g_id, ElMessage.info("您有一个新的语音通话请求");
// g_name: MessageData.g_name, oncall.setTarget(MessageData.from, MessageData.from_name, MessageData.from_avatar);
// }); oncall.panelOn();
// } else { oncall.callingOn();
// //一类消息,私聊消息 oncall.fromOff();
// console.log("有新消息"); oncall.statusOff();
// message.addMessage(MessageData); break;
// console.log(message.messages); case "VOICE_CALL_RESPONSE":
// messageSign.addSign({ console.log("收到语音通话响应消息");
// sender: MessageData.sender, //直接跳转到icecandidate阶段不再接收额外的响应消息
// sender_name: MessageData.sender_name, break;
// }); case "VOICE_CALL_DENY":
// } ElMessage.info("对方拒绝了您的语音通话请求");
// } oncall.clear();
// } else { break;
// if (MessageData.engaged) { case "VOICE_CALL_END":
// // 五类消息,账号重复登录逻辑 ElMessage.info("语音通话已结束");
// setIsManualClose(true); closeConnection();
// disconnectWebSocket(); oncall.clear();
// ElMessage("账号重复登录,请注意密码泄露"); break;
// console.log("账号重复登陆"); case "VOICE_ICE_CANDIDATE":
// window.location.replace("/"); // stage3 : 收到ICE候选添加到RTC连接中
// } else { console.log("收到新的ICE候选");
// //三类消息,指示用户上下线 await handleCandidate(MessageData.content);
// if (MessageData.status === "online") break;
// ElMessage("用户:" + MessageData.u_name + "上线"); case "VOICE_SDP_OFFER":
// else { // stage1 收到offer发送answer
// ElMessage("用户:" + MessageData.u_name + "下线"); console.log("收到SDP Offer");
// } await handleOffer(MessageData.content, MessageData.from, MessageData.to);
// } break;
// } case "VOICE_SDP_ANSWER":
// } catch (error) { // stage2 : 收到answer设置远端SDP
// console.error("解析 JSON 失败:", error); console.log("收到SDP Answer");
// } await handleAnswer(MessageData.content, MessageData.from, MessageData.to);
}; break;
socket.value.onerror = (error) => { }
console.error("WebSocket发生错误", error); }catch(error){
// console.log(error); console.error("解析 JSON 失败:", error);
setReconnectScheduled(true); }
socket.value.close();
}; };
socket.value.onclose = (event) => { socket.value.onerror = (error) => {
message.saveMessagesHistory(userinfo.user.id); console.error("WebSocket发生错误", error);
if (!getIsManualClose()) { // console.log(error);
if (getReconnectScheduled()) { setReconnectScheduled(true);
socket.value = null; socket.value.close();
addRetryCount(); };
setTimeout(reConnectWebSocket, 5000);
setReconnectScheduled(false); socket.value.onclose = (event) => {
} else { message.saveMessagesHistory(userinfo.user.id);
// console.log("websocket因为浏览器省电设置断开"); if (!getIsManualClose()) {
console.log("WebSocket连接已关闭", event); if (getReconnectScheduled()) {
} socket.value = null;
} addRetryCount();
}; setTimeout(reConnectWebSocket, 5000);
}; setReconnectScheduled(false);
} else {
// 断开WebSocket连接 // console.log("websocket因为浏览器省电设置断开");
export const disconnectWebSocket = () => { console.log("WebSocket连接已关闭", event);
if (socket.value && socket.value.readyState === WebSocket.OPEN) { }
socket.value.close(); }
} };
}; };
// 重连机制 // 断开WebSocket连接
export const reConnectWebSocket = () => { export const disconnectWebSocket = () => {
connectWebSocket(); if (socket.value && socket.value.readyState === WebSocket.OPEN) {
}; socket.value.close();
}
// 发送消息 };
export const sendMessage = (message) => {
if (socket.value && socket.value.readyState === WebSocket.OPEN) { // 重连机制
socket.value.send(message); export const reConnectWebSocket = () => {
} else { connectWebSocket();
console.warn("WebSocket未连接无法发送消息"); };
}
}; // 发送消息
export const sendMessage = (message) => {
//没有错误的重连,只是浏览器在后台断开了连接 try {
document.addEventListener("visibilitychange", () => { const jsonmessage = JSON.stringify(message);
if (document.hidden) { if (socket.value && socket.value.readyState === WebSocket.OPEN) {
} else { socket.value.send(jsonmessage);
if (!getIsManualClose() && socket.value.readyState === WebSocket.CLOSED) { } else {
if (getReconnectScheduled()) { console.warn("WebSocket未连接无法发送消息");
return; }
} }
reConnectWebSocket(); 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
View 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.idURL 只有 r_id鉴权在子协议 tokenid 只影响你主动发的消息里的 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();
}
}
});

View File

@@ -1,287 +1,286 @@
import { ref } from "vue"; import { ref } from "vue";
import { userInfoStore } from "@/store/store"; import { userInfoStore } from "@/store/user";
import { onCallStore } from "@/store/VoiceTarget"; import { onCallStore } from "@/store/VoiceTarget";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
// userinfo 实例 // userinfo 实例
const userinfo = userInfoStore(); const userinfo = userInfoStore();
// oncall 实例 // oncall 实例
const oncall = onCallStore(); const oncall = onCallStore();
// WebSocket 实例 // WebSocket 实例
const socket = ref(null); const socket = ref(null);
const isManualClose = ref(false); const isManualClose = ref(false);
const reconnectScheduled = ref(false); const reconnectScheduled = ref(false);
const retryCount = ref(0); const retryCount = ref(0);
const getRetryCount = () => { const getRetryCount = () => {
return retryCount.value; return retryCount.value;
}; };
const addRetryCount = () => { const addRetryCount = () => {
retryCount.value = retryCount.value + 1; retryCount.value = retryCount.value + 1;
}; };
const resetRetryCount = () => { const resetRetryCount = () => {
retryCount.value = 0; retryCount.value = 0;
}; };
const setReconnectScheduled = (value) => { const setReconnectScheduled = (value) => {
reconnectScheduled.value = value; reconnectScheduled.value = value;
}; };
const getReconnectScheduled = () => { const getReconnectScheduled = () => {
return reconnectScheduled.value; return reconnectScheduled.value;
}; };
const setIsManualClose = (value) => { const setIsManualClose = (value) => {
isManualClose.value = value; isManualClose.value = value;
}; };
const getIsManualClose = () => { const getIsManualClose = () => {
return isManualClose.value; return isManualClose.value;
}; };
const iceserver = { const iceserver = {
iceServers: [ iceServers: [
{ {
// urls: "stun:stun.l.google.com:19302" urls: process.env.STUN_URL,
urls: "stun:8.134.92.199:3478", },
}, {
{ urls: process.env.TURN_URL,
urls: "turn:8.134.92.199:3478", username: process.env.TURN_USERNAME,
username: "test", credential: process.env.TURN_CREDENTIAL,
credential: "123456", },
}, ],
], };
};
// 连接WebSocket
// 连接WebSocket export const connectVoicesocket = () => {
export const connectVoicesocket = () => { const protocol = window.location.protocol === "https:" ? "wss://" : "ws://";
const protocol = window.location.protocol === "https:" ? "wss://" : "ws://"; const host = window.location.host;
const host = window.location.host; const socketUrl = `${protocol}${host}/voice?u_id=${userinfo.user.u_id}&u_name=${userinfo.user.u_name}`;
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) {
if (socket.value && socket.value.readyState !== WebSocket.CLOSED) { console.log("还在重连中...");
console.log("还在重连中..."); return;
return; }
} const retrytime = getRetryCount();
const retrytime = getRetryCount(); if (retrytime >= 10) {
if (retrytime >= 10) { console.log("重连失败,请稍后再试");
console.log("重连失败,请稍后再试"); return;
return; }
} console.log(retrytime);
console.log(retrytime); socket.value = new WebSocket(socketUrl);
socket.value = new WebSocket(socketUrl);
socket.value.onopen = (event) => {
socket.value.onopen = (event) => { console.log("Voice连接已建立", event);
console.log("Voice连接已建立", event); setReconnectScheduled(false);
setReconnectScheduled(false); setIsManualClose(false);
setIsManualClose(false); resetRetryCount();
resetRetryCount(); };
};
// 处理消息逻辑
// 处理消息逻辑 socket.value.onmessage = async (event) => {
socket.value.onmessage = async (event) => { try {
try { const data = JSON.parse(event.data);
const data = JSON.parse(event.data); if (data.type === "status") {
if (data.type === "status") { ElMessage.error("对方已离线");
ElMessage.error("对方已离线"); oncall.panelOff();
oncall.panelOff(); oncall.callingOff();
oncall.callingOff(); } else if (data.type === "incomingcall") {
} else if (data.type === "incomingcall") { // 通话请求处理
// 通话请求处理 oncall.setTarget(data.from, data.from_name, data.from_avatar);
oncall.setTarget(data.from, data.from_name, data.from_avatar); oncall.panelOn();
oncall.panelOn(); oncall.callingOn();
oncall.callingOn(); oncall.fromOff();
oncall.fromOff(); oncall.statusOff();
oncall.statusOff(); } else if (data.type === "pickup") {
} else if (data.type === "pickup") { ElMessage.success("对方已接听,等待连接中...");
ElMessage.success("对方已接听,等待连接中..."); try {
try { await initRTCconnection();
await initRTCconnection(); RTCpeerConnection.value.onicecandidate = (event) => {
RTCpeerConnection.value.onicecandidate = (event) => { if (event.candidate) {
if (event.candidate) { console.log("发送candidate", event.candidate);
console.log("发送candidate", event.candidate); const message = {
const message = { type: "single",
type: "single", content: "candidate",
content: "candidate", from: userinfo.user.u_id,
from: userinfo.user.u_id, to: oncall.target.u_id,
to: oncall.target.u_id, candidate: event.candidate,
candidate: event.candidate, };
}; sendMessage(JSON.stringify(message));
sendMessage(JSON.stringify(message)); }
} };
};
RTCpeerConnection.value.ontrack = (event) => {
RTCpeerConnection.value.ontrack = (event) => { console.log("收到对方音频流", event);
console.log("收到对方音频流", event); oncall.setRemoteStream(event.streams[0]);
oncall.setRemoteStream(event.streams[0]); };
}; } catch (e) {
} catch (e) { console.log("webRTC初始化失败", e);
console.log("webRTC初始化失败", e); }
}
const offer = await RTCpeerConnection.value.createOffer({
const offer = await RTCpeerConnection.value.createOffer({ offerToReceiveAudio: 1,
offerToReceiveAudio: 1, });
});
await RTCpeerConnection.value.setLocalDescription(offer);
await RTCpeerConnection.value.setLocalDescription(offer);
// 发送offer
// 发送offer const message = {
const message = { type: "single",
type: "single", content: "offer",
content: "offer", from: userinfo.user.u_id,
from: userinfo.user.u_id, to: oncall.target.u_id,
to: oncall.target.u_id, offer: offer,
offer: offer, };
}; sendMessage(JSON.stringify(message));
sendMessage(JSON.stringify(message)); } else if (data.type === "hangup") {
} else if (data.type === "hangup") { ElMessage.success("对方已挂断");
ElMessage.success("对方已挂断"); oncall.panelOff();
oncall.panelOff(); oncall.callingOff();
oncall.callingOff(); oncall.fromOff();
oncall.fromOff(); oncall.statusOff();
oncall.statusOff(); RTCpeerConnection.value.close();
RTCpeerConnection.value.close(); RTCpeerConnection.value = null;
RTCpeerConnection.value = null; oncall.resetStream();
oncall.resetStream(); } else if (data.content === "offer") {
} else if (data.content === "offer") { ElMessage.success("收到对方offer");
ElMessage.success("收到对方offer"); console.log("收到对方offer", data);
console.log("收到对方offer", data);
try {
try { await initRTCconnection();
await initRTCconnection();
RTCpeerConnection.value.onicecandidate = (event) => {
RTCpeerConnection.value.onicecandidate = (event) => { if (event.candidate) {
if (event.candidate) { console.log("发送candidate", event.candidate);
console.log("发送candidate", event.candidate); const message = {
const message = { type: "single",
type: "single", content: "candidate",
content: "candidate", from: userinfo.user.u_id,
from: userinfo.user.u_id, to: oncall.target.u_id,
to: oncall.target.u_id, candidate: event.candidate,
candidate: event.candidate, };
}; sendMessage(JSON.stringify(message));
sendMessage(JSON.stringify(message)); }
} };
};
RTCpeerConnection.value.ontrack = (event) => {
RTCpeerConnection.value.ontrack = (event) => { console.log("收到对方音频流", event);
console.log("收到对方音频流", event); oncall.setRemoteStream(event.streams[0]);
oncall.setRemoteStream(event.streams[0]); };
}; } catch (e) {
} catch (e) { console.log("webRTC初始化失败", e);
console.log("webRTC初始化失败", e); }
}
await RTCpeerConnection.value.setRemoteDescription(data.offer);
await RTCpeerConnection.value.setRemoteDescription(data.offer); const answer = await RTCpeerConnection.value.createAnswer();
const answer = await RTCpeerConnection.value.createAnswer(); await RTCpeerConnection.value.setLocalDescription(answer);
await RTCpeerConnection.value.setLocalDescription(answer);
// 发送answer
// 发送answer const message = {
const message = { type: "single",
type: "single", content: "answer",
content: "answer", from: userinfo.user.u_id,
from: userinfo.user.u_id, to: oncall.target.u_id,
to: oncall.target.u_id, answer: answer,
answer: answer, };
}; sendMessage(JSON.stringify(message));
sendMessage(JSON.stringify(message)); } else if (data.content === "answer") {
} else if (data.content === "answer") { ElMessage.success("对方已接受");
ElMessage.success("对方已接受"); console.log("对方已接受", data);
console.log("对方已接受", data); await RTCpeerConnection.value.setRemoteDescription(data.answer);
await RTCpeerConnection.value.setRemoteDescription(data.answer); } else if (data.content === "candidate") {
} else if (data.content === "candidate") { console.log("收到candidate", data);
console.log("收到candidate", data); await RTCpeerConnection.value.addIceCandidate(data.candidate);
await RTCpeerConnection.value.addIceCandidate(data.candidate); }
} } catch (e) {
} catch (e) { console.log("VoiceSocket消息格式错误", e);
console.log("VoiceSocket消息格式错误", e); }
} };
};
socket.value.onerror = (error) => {
socket.value.onerror = (error) => { console.error("Voicesocket发生错误", error);
console.error("Voicesocket发生错误", error); setReconnectScheduled(true);
setReconnectScheduled(true); socket.value.close();
socket.value.close(); };
};
socket.value.onclose = (event) => {
socket.value.onclose = (event) => { if (!getIsManualClose()) {
if (!getIsManualClose()) { if (getReconnectScheduled()) {
if (getReconnectScheduled()) { socket.value = null;
socket.value = null; addRetryCount();
addRetryCount(); setTimeout(reConnectVoicesocket, 5000);
setTimeout(reConnectVoicesocket, 5000); setReconnectScheduled(false);
setReconnectScheduled(false); } else {
} else { console.log("Voicesocket因为浏览器省电设置断开");
console.log("Voicesocket因为浏览器省电设置断开"); console.log("Voicesocket连接已关闭", event);
console.log("Voicesocket连接已关闭", event); }
} }
} };
}; };
};
// 断开Voicesocket连接
// 断开Voicesocket连接 export const disconnectVoicesocket = () => {
export const disconnectVoicesocket = () => { if (socket.value && socket.value.readyState === WebSocket.OPEN) {
if (socket.value && socket.value.readyState === WebSocket.OPEN) { socket.value.close();
socket.value.close(); }
} };
};
// 重连机制
// 重连机制 export const reConnectVoicesocket = () => {
export const reConnectVoicesocket = () => { connectVoicesocket();
connectVoicesocket(); };
};
// 发送消息
// 发送消息 export const sendMessage = (message) => {
export const sendMessage = (message) => { if (socket.value && socket.value.readyState === WebSocket.OPEN) {
if (socket.value && socket.value.readyState === WebSocket.OPEN) { socket.value.send(message);
socket.value.send(message); } else {
} else { console.warn("Voicesocket未连接无法发送消息");
console.warn("Voicesocket未连接无法发送消息"); }
} };
};
//没有错误的重连,只是浏览器在后台断开了连接
//没有错误的重连,只是浏览器在后台断开了连接 document.addEventListener("visibilitychange", () => {
document.addEventListener("visibilitychange", () => { if (document.hidden) {
if (document.hidden) { } else {
} else { if (!getIsManualClose() && socket.value.readyState === WebSocket.CLOSED) {
if (!getIsManualClose() && socket.value.readyState === WebSocket.CLOSED) { if (getReconnectScheduled()) {
if (getReconnectScheduled()) { return;
return; }
} reConnectVoicesocket();
reConnectVoicesocket(); }
} }
} });
});
//WebRTC连接相关代码
//WebRTC连接相关代码 export const RTCpeerConnection = ref(null);
const RTCpeerConnection = ref(null);
const getlocalStream = async () => {
export const getlocalStream = async () => { const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); console.log("获取本地音频流成功", stream);
console.log("获取本地音频流成功", stream); return stream;
return stream; // // 获取音频和视频轨道
// // 获取音频和视频轨道 // const audioTrack = stream.getAudioTracks()[0];
// const audioTrack = stream.getAudioTracks()[0]; // // 将轨道添加到 RTCPeerConnection
// // 将轨道添加到 RTCPeerConnection // peerConnection.addTrack(audioTrack, stream);
// peerConnection.addTrack(audioTrack, stream); };
};
const initRTCconnection = async () => {
const initRTCconnection = async () => { RTCpeerConnection.value = new RTCPeerConnection(iceserver);
RTCpeerConnection.value = new RTCPeerConnection(iceserver); const stream = await getlocalStream();
const stream = await getlocalStream(); RTCpeerConnection.value.addTrack(stream.getAudioTracks()[0], stream);
RTCpeerConnection.value.addTrack(stream.getAudioTracks()[0], stream); };
};
export const hangup = () => {
export const hangup = () => { oncall.panelOff();
oncall.panelOff(); oncall.callingOff();
oncall.callingOff(); oncall.fromOff();
oncall.fromOff(); oncall.statusOff();
oncall.statusOff(); oncall.resetStream();
oncall.resetStream(); RTCpeerConnection.value.close();
RTCpeerConnection.value.close(); RTCpeerConnection.value = null;
RTCpeerConnection.value = null;
const message = {
const message = { type: "hangup",
type: "hangup", from: userinfo.user.u_id,
from: userinfo.user.u_id, to: oncall.target.u_id,
to: oncall.target.u_id, };
}; sendMessage(JSON.stringify(message));
sendMessage(JSON.stringify(message)); };
};

View File

@@ -1,59 +1,65 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue'; import vue from '@vitejs/plugin-vue';
import dotenv from 'dotenv';
export default defineConfig({
plugins: [vue()], dotenv.config();
resolve: {
alias: { export default defineConfig({
'@': '/src', plugins: [vue()],
} resolve: {
}, alias: {
server: { '@': '/src',
proxy: { }
'/api': { },
target: 'http://localhost:8080', // 后端服务器地址 server: {
changeOrigin: true, // 允许跨域 proxy: {
rewrite: (path) => path.replace(/^\/api/, ''), // 重写路径,去掉 /api 前缀 '/api': {
}, target: 'http://localhost:8080', // 后端服务器地址
'/proxy': { changeOrigin: true, // 允许跨域
target: 'http://localhost:3000', // 代理服务器的地址 rewrite: (path) => path.replace(/^\/api/, ''), // 重写路径,去掉 /api 前缀
changeOrigin: true, // 必须设置为 true才能避免跨域问题 },
// rewrite: (path) => path.replace(/^\/proxy/, ''), // 重写路径,去掉 /api 前缀 '/proxy': {
target: 'http://localhost:3000', // 代理服务器的地址
}, changeOrigin: true, // 必须设置为 true才能避免跨域问题
'/ws': { // rewrite: (path) => path.replace(/^\/proxy/, ''), // 重写路径,去掉 /api 前缀
target: 'ws://localhost:8080',
changeOrigin: true, },
ws: true, '/ws': {
}, target: 'ws://localhost:8080',
// '/ws': { changeOrigin: true,
// target: 'ws://localhost:8080', ws: true,
// changeOrigin: true, },
// ws: true, },
// } host: '0.0.0.0',
}, port: 5173,
}, },
// server: { // server: {
// https:{ // https:{
// key: fs.readFileSync('./cert/merlin.xin.key'), // key: fs.readFileSync('./cert/merlin.xin.key'),
// cert: fs.readFileSync('./cert/merlin.xin.pem'), // cert: fs.readFileSync('./cert/merlin.xin.pem'),
// }, // },
// proxy: { // proxy: {
// '/api': { // '/api': {
// target: 'https://localhost:8443', // 后端服务器地址 // target: 'https://localhost:8443', // 后端服务器地址
// changeOrigin: true, // 允许跨域 // changeOrigin: true, // 允许跨域
// rewrite: (path) => path.replace(/^\/api/, ''), // 重写路径,去掉 /api 前缀 // rewrite: (path) => path.replace(/^\/api/, ''), // 重写路径,去掉 /api 前缀
// }, // },
// '/online':{ // '/online':{
// target:'wss://localhost:8443/online', // target:'wss://localhost:8443/online',
// changeOrigin:true, // changeOrigin:true,
// ws:true, // ws:true,
// }, // },
// '/voice':{ // '/voice':{
// target:'wss://localhost:8443/voice', // target:'wss://localhost:8443/voice',
// changeOrigin:true, // changeOrigin:true,
// ws: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),
},
});