Compare commits

38 Commits
master ... dev

Author SHA1 Message Date
leilei
4fadded872 feat:更新代码
All checks were successful
Deploy Lighttpd Admin / deploy (push) Successful in 4m53s
2026-01-14 17:12:04 +08:00
leilei
f684fd49fb feat:更新代码
Some checks failed
Deploy To Dev / deploy (push) Has been cancelled
2026-01-14 17:08:07 +08:00
leilei
1a6b15ea79 Merge branch 'master' of https://git.gxtech.ltd/GxTech/xSynergy-Web into dev 2025-11-24 14:09:30 +08:00
leilei
c522e5f7f1 feat:更新代码 2025-11-24 14:08:29 +08:00
leilei
042e72925d faet:更新代码 2025-11-24 10:29:13 +08:00
leilei
a1faf63eb8 feat:更新代码 2025-11-24 10:17:34 +08:00
leilei
6600c7c282 feat:统一订阅 2025-11-24 10:10:26 +08:00
b9c3488915 测试webhook
11
2025-11-23 23:36:47 +08:00
ee706b2540 测试webhook
完成测试
2025-11-23 10:31:25 +08:00
6cf53f897b 测试webhook
test
2025-11-23 10:30:39 +08:00
fdd8dfbf98 测试webhook 2025-11-23 02:45:31 +08:00
6a4334797b 测试webhook 2025-11-23 02:42:36 +08:00
b4ba201631 Update .gitea/workflows/deploy.yaml 2025-11-22 00:48:12 +08:00
9faf75265d Update dist/index.html 2025-11-22 00:46:16 +08:00
39b11d0c10 Update dist/index.html 2025-11-22 00:42:34 +08:00
0101bc8ad1 Update .gitea/workflows/deploy.yaml 2025-11-22 00:40:55 +08:00
982a61ddc8 Update .gitea/workflows/deploy.yaml 2025-11-22 00:39:37 +08:00
82d4a0865b Update .gitea/workflows/deploy.yaml 2025-11-22 00:21:22 +08:00
5d853d565c Update .gitea/workflows/deploy.yaml 2025-11-22 00:18:43 +08:00
f10d2dc642 Update .gitea/workflows/deploy.yaml 2025-11-21 18:01:54 +08:00
cdddde25d1 Update .gitea/workflows/deploy.yaml 2025-11-21 18:00:49 +08:00
e213dcf656 Update .gitea/workflows/deploy.yaml 2025-11-21 17:54:05 +08:00
c8361daa8e Update .gitea/workflows/deploy.yaml 2025-11-21 17:49:01 +08:00
fec0b747ea Update .gitea/workflows/deploy.yaml 2025-11-21 17:45:42 +08:00
edc8945735 Update .gitea/workflows/deploy.yaml 2025-11-21 17:43:31 +08:00
ffb141383d Update .gitea/workflows/deploy.yaml 2025-11-21 17:33:33 +08:00
3365c09b73 Update .gitea/workflows/deploy.yaml 2025-11-21 17:28:03 +08:00
bb83625fbf Update .gitea/workflows/deploy.yaml 2025-11-21 17:23:00 +08:00
2e525d5ea1 Update .gitea/workflows/deploy.yaml 2025-11-21 17:17:49 +08:00
6e471815cc Update .gitea/workflows/deploy.yaml 2025-11-21 16:39:33 +08:00
0eb56d5732 Update .gitea/workflows/deploy.yaml 2025-11-21 16:32:35 +08:00
f8c1171656 Update .gitea/workflows/deploy.yaml 2025-11-21 16:25:38 +08:00
0b77dca9da Update .gitea/workflows/deploy.yaml 2025-11-21 16:24:35 +08:00
leilei
c9e5ae8da4 feat:更新代码 2025-11-21 16:18:53 +08:00
leilei
32e0ec0a2c feat:更新进入房间方式 2025-11-21 16:15:05 +08:00
leilei
1457a44f0b feat:更新dev代码 2025-11-21 16:10:35 +08:00
leilei
77b499ffb0 feat:更新dev代码 2025-11-21 16:06:21 +08:00
leilei
5bf4998cbd feat:更新dev代码 2025-11-21 16:04:30 +08:00
54 changed files with 20249 additions and 2 deletions

View File

@@ -0,0 +1,48 @@
name: Deploy Lighttpd Admin
on:
push:
branches:
- 'dev'
jobs:
deploy:
runs-on: ubuntu-22.04
container:
volumes:
- /app/lighttpd-admin:/app/xsy-admin
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Copy specific directory to runner's host machine
run: |
TARGET_DIR="./dist"
# 检查 dist 目录是否存在
if [ ! -d "$TARGET_DIR" ]; then
echo "Error: The source directory '$TARGET_DIR' does not exist in the repository."
# 如果目录不存在,报错并退出当前步骤
exit 1
fi
ls -lha /app/xsy-admin/
rm -rf /app/xsy-admin/*
cp -r ./dist/* /app/xsy-admin/
- name: Find and restart the app container
run: |
# 1. 使用 docker ps 过滤包含 'xsy-admin' 服务的容器
# 2. 提取容器 ID 或名称
CONTAINER_ID=$(docker ps -a --filter "name=xsy-admin" --format "{{.ID}}")
if [ -z "$CONTAINER_ID" ]; then
echo "Error: Could not find any container matching name 'app1'."
exit 1
else
echo "Found container ID: $CONTAINER_ID. Restarting..."
# 使用标准的 docker restart 命令
docker restart "$CONTAINER_ID"
echo "Container restarted successfully."
fi

3
.gitignore vendored
View File

@@ -8,8 +8,7 @@ pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
.DS_Store
dist-ssr
coverage
*.local

BIN
dist.zip

Binary file not shown.

1
dist/assets/401-BSBdQqy4.css vendored Normal file
View File

@@ -0,0 +1 @@
.errPage-container[data-v-2c8b7582]{width:800px;max-width:100%;margin:100px auto}.errPage-container .pan-back-btn[data-v-2c8b7582]{background:#008489;color:#fff;border:none!important}.errPage-container .pan-gif[data-v-2c8b7582]{margin:0 auto;display:block}.errPage-container .pan-img[data-v-2c8b7582]{display:block;margin:0 auto;width:100%}.errPage-container .text-jumbo[data-v-2c8b7582]{font-size:60px;font-weight:700;color:#484848}.errPage-container .list-unstyled[data-v-2c8b7582]{font-size:14px}.errPage-container .list-unstyled li[data-v-2c8b7582]{padding-bottom:5px}.errPage-container .list-unstyled a[data-v-2c8b7582]{color:#008489;text-decoration:none}.errPage-container .list-unstyled a[data-v-2c8b7582]:hover{text-decoration:underline}

1
dist/assets/401-DO7f-liB.js vendored Normal file
View File

@@ -0,0 +1 @@
import{_ as d,g as f,r as m,c as k,f as o,w as n,h as s,o as g,p as a,k as e}from"./index-DRNenl-T.js";const h=""+new URL("401-HGF6Q5qM.gif",import.meta.url).href,w={class:"errPage-container"},x={class:"list-unstyled"},b={class:"link-type"},v=["src"],y={__name:"401",setup(B){let{proxy:r}=f();const u=m(h+"?"+ +new Date);function c(){r.$route.query.noGoBack?r.$router.push({path:"/"}):r.$router.go(-1)}return(C,t)=>{const _=s("el-button"),i=s("router-link"),l=s("el-col"),p=s("el-row");return g(),k("div",w,[o(_,{icon:"arrow-left",class:"pan-back-btn",onClick:c},{default:n(()=>[...t[0]||(t[0]=[a(" 返回 ",-1)])]),_:1}),o(p,null,{default:n(()=>[o(l,{span:12},{default:n(()=>[t[2]||(t[2]=e("h1",{class:"text-jumbo text-ginormous"}," 401错误! ",-1)),t[3]||(t[3]=e("h2",null,"您没有访问权限!",-1)),t[4]||(t[4]=e("h6",null,"对不起,您没有访问权限,请不要进行非法操作!您可以返回主页面",-1)),e("ul",x,[e("li",b,[o(i,{to:"/"},{default:n(()=>[...t[1]||(t[1]=[a(" 回首页 ",-1)])]),_:1})])])]),_:1}),o(l,{span:12},{default:n(()=>[e("img",{src:u.value,width:"313",height:"428",alt:"Girl has dropped her ice cream."},null,8,v)]),_:1})]),_:1})])}}},I=d(y,[["__scopeId","data-v-2c8b7582"]]);export{I as default};

BIN
dist/assets/401-DO7f-liB.js.gz vendored Normal file

Binary file not shown.

BIN
dist/assets/401-HGF6Q5qM.gif vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

1
dist/assets/404-C8Inh8VK.js vendored Normal file
View File

@@ -0,0 +1 @@
import{_ as o,B as l,c as r,k as t,C as _,f as c,q as n,i as d,w as p,h as m,o as u,p as v}from"./index-DRNenl-T.js";const h=""+new URL("404-N4aRkdWY.png",import.meta.url).href,a=""+new URL("404_cloud-CPexjtDj.png",import.meta.url).href,f={class:"wscn-http404-container"},g={class:"wscn-http404"},x={class:"bullshit"},k={class:"bullshit__headline"},w={__name:"404",setup(b){let e=l(()=>"找不到网页!");return(N,s)=>{const i=m("router-link");return u(),r("div",f,[t("div",g,[s[3]||(s[3]=_('<div class="pic-404" data-v-328ae272><img class="pic-404__parent" src="'+h+'" alt="404" data-v-328ae272><img class="pic-404__child left" src="'+a+'" alt="404" data-v-328ae272><img class="pic-404__child mid" src="'+a+'" alt="404" data-v-328ae272><img class="pic-404__child right" src="'+a+'" alt="404" data-v-328ae272></div>',1)),t("div",x,[s[1]||(s[1]=t("div",{class:"bullshit__oops"}," 404错误! ",-1)),t("div",k,n(d(e)),1),s[2]||(s[2]=t("div",{class:"bullshit__info"}," 对不起您正在寻找的页面不存在。尝试检查URL的错误然后按浏览器上的刷新按钮或尝试在我们的应用程序中找到其他内容。 ",-1)),c(i,{to:"/index",class:"bullshit__return-home"},{default:p(()=>[...s[0]||(s[0]=[v(" 返回首页 ",-1)])]),_:1})])])])}}},C=o(w,[["__scopeId","data-v-328ae272"]]);export{C as default};

BIN
dist/assets/404-C8Inh8VK.js.gz vendored Normal file

Binary file not shown.

1
dist/assets/404-Cb2yUGol.css vendored Normal file
View File

@@ -0,0 +1 @@
.wscn-http404-container[data-v-328ae272]{transform:translate(-50%,-50%);position:absolute;top:40%;left:50%}.wscn-http404[data-v-328ae272]{position:relative;width:1200px;padding:0 50px;overflow:hidden}.wscn-http404 .pic-404[data-v-328ae272]{position:relative;float:left;width:600px;overflow:hidden}.wscn-http404 .pic-404__parent[data-v-328ae272]{width:100%}.wscn-http404 .pic-404__child[data-v-328ae272]{position:absolute}.wscn-http404 .pic-404__child.left[data-v-328ae272]{width:80px;top:17px;left:220px;opacity:0;animation-name:cloudLeft-328ae272;animation-duration:2s;animation-timing-function:linear;animation-fill-mode:forwards;animation-delay:1s}.wscn-http404 .pic-404__child.mid[data-v-328ae272]{width:46px;top:10px;left:420px;opacity:0;animation-name:cloudMid-328ae272;animation-duration:2s;animation-timing-function:linear;animation-fill-mode:forwards;animation-delay:1.2s}.wscn-http404 .pic-404__child.right[data-v-328ae272]{width:62px;top:100px;left:500px;opacity:0;animation-name:cloudRight-328ae272;animation-duration:2s;animation-timing-function:linear;animation-fill-mode:forwards;animation-delay:1s}@keyframes cloudLeft-328ae272{0%{top:17px;left:220px;opacity:0}20%{top:33px;left:188px;opacity:1}80%{top:81px;left:92px;opacity:1}to{top:97px;left:60px;opacity:0}}@keyframes cloudMid-328ae272{0%{top:10px;left:420px;opacity:0}20%{top:40px;left:360px;opacity:1}70%{top:130px;left:180px;opacity:1}to{top:160px;left:120px;opacity:0}}@keyframes cloudRight-328ae272{0%{top:100px;left:500px;opacity:0}20%{top:120px;left:460px;opacity:1}80%{top:180px;left:340px;opacity:1}to{top:200px;left:300px;opacity:0}}.wscn-http404 .bullshit[data-v-328ae272]{position:relative;float:left;width:300px;padding:30px 0;overflow:hidden}.wscn-http404 .bullshit__oops[data-v-328ae272]{font-size:32px;font-weight:700;line-height:40px;color:#1482f0;opacity:0;margin-bottom:20px;animation-name:slideUp-328ae272;animation-duration:.5s;animation-fill-mode:forwards}.wscn-http404 .bullshit__headline[data-v-328ae272]{font-size:20px;line-height:24px;color:#222;font-weight:700;opacity:0;margin-bottom:10px;animation-name:slideUp-328ae272;animation-duration:.5s;animation-delay:.1s;animation-fill-mode:forwards}.wscn-http404 .bullshit__info[data-v-328ae272]{font-size:13px;line-height:21px;color:gray;opacity:0;margin-bottom:30px;animation-name:slideUp-328ae272;animation-duration:.5s;animation-delay:.2s;animation-fill-mode:forwards}.wscn-http404 .bullshit__return-home[data-v-328ae272]{display:block;float:left;width:110px;height:36px;background:#1482f0;border-radius:100px;text-align:center;color:#fff;opacity:0;font-size:14px;line-height:36px;cursor:pointer;animation-name:slideUp-328ae272;animation-duration:.5s;animation-delay:.3s;animation-fill-mode:forwards}@keyframes slideUp-328ae272{0%{transform:translateY(60px);opacity:0}to{transform:translateY(0);opacity:1}}

BIN
dist/assets/404-Cb2yUGol.css.gz vendored Normal file

Binary file not shown.

BIN
dist/assets/404-N4aRkdWY.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

BIN
dist/assets/404_cloud-CPexjtDj.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

1
dist/assets/authRole-BGeC_BV4.js vendored Normal file

File diff suppressed because one or more lines are too long

BIN
dist/assets/authRole-BGeC_BV4.js.gz vendored Normal file

Binary file not shown.

1
dist/assets/authRoom-ScM_P5Lw.js vendored Normal file

File diff suppressed because one or more lines are too long

BIN
dist/assets/authRoom-ScM_P5Lw.js.gz vendored Normal file

Binary file not shown.

1
dist/assets/index-27WP78gO.js vendored Normal file

File diff suppressed because one or more lines are too long

BIN
dist/assets/index-27WP78gO.js.gz vendored Normal file

Binary file not shown.

1
dist/assets/index-B6TwTdR5.js vendored Normal file

File diff suppressed because one or more lines are too long

BIN
dist/assets/index-B6TwTdR5.js.gz vendored Normal file

Binary file not shown.

1
dist/assets/index-B9qSM1WT.js vendored Normal file

File diff suppressed because one or more lines are too long

BIN
dist/assets/index-B9qSM1WT.js.gz vendored Normal file

Binary file not shown.

1
dist/assets/index-BFgkfykF.js vendored Normal file
View File

@@ -0,0 +1 @@
import{u as s,a as c,c as n,o as u}from"./index-DRNenl-T.js";const i={__name:"index",setup(p){const e=s(),t=c(),{params:o,query:a}=e,{path:r}=o;return t.replace({path:"/"+r,query:a}),(_,m)=>(u(),n("div"))}};export{i as default};

1
dist/assets/index-CkP6b1E2.js vendored Normal file

File diff suppressed because one or more lines are too long

BIN
dist/assets/index-CkP6b1E2.js.gz vendored Normal file

Binary file not shown.

1
dist/assets/index-DPF0UxJm.js vendored Normal file

File diff suppressed because one or more lines are too long

BIN
dist/assets/index-DPF0UxJm.js.gz vendored Normal file

Binary file not shown.

80
dist/assets/index-DRNenl-T.js vendored Normal file

File diff suppressed because one or more lines are too long

BIN
dist/assets/index-DRNenl-T.js.gz vendored Normal file

Binary file not shown.

1
dist/assets/index-Dz1wU-dg.css vendored Normal file

File diff suppressed because one or more lines are too long

BIN
dist/assets/index-Dz1wU-dg.css.gz vendored Normal file

Binary file not shown.

1
dist/assets/login-C2MWIt8z.css vendored Normal file

File diff suppressed because one or more lines are too long

BIN
dist/assets/login-C2MWIt8z.css.gz vendored Normal file

Binary file not shown.

14
dist/assets/login-dH8WbfOy.js vendored Normal file

File diff suppressed because one or more lines are too long

BIN
dist/assets/login-dH8WbfOy.js.gz vendored Normal file

Binary file not shown.

BIN
dist/assets/loginBg-BZpWbmau.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

1
dist/assets/menu-Ca_cGcaO.js vendored Normal file
View File

@@ -0,0 +1 @@
import{A as e}from"./index-DRNenl-T.js";const i=s=>e({url:"/api/v1/permission/permissions/tree",method:"get",params:s}),o=s=>e({url:"/api/v1/permission/permissions",method:"post",data:s}),t=s=>e({url:"/api/v1/permission/permissions/"+s.uid,method:"put",data:s}),n=s=>e({url:"/api/v1/permission/permissions/"+s,method:"delete"}),p=s=>e({url:"/api/v1/permission/roles/"+s.roleId+"/permissions/add",method:"post",data:s}),a=s=>e({url:"/api/v1/permission/roles/"+s+"/all-permissions",method:"get"});export{o as a,p as b,n as d,a as g,i as l,t as u};

BIN
dist/assets/profile-BkLdKdgI.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

1
dist/assets/role-zDKXfY8i.js vendored Normal file
View File

@@ -0,0 +1 @@
import{A as s}from"./index-DRNenl-T.js";const t=e=>s({url:"/api/v1/permission/roles",method:"get",params:e}),a=e=>s({url:"/api/v1/permission/roles/"+e,method:"get"}),l=e=>s({url:"/api/v1/permission/roles",method:"post",data:e}),i=e=>s({url:"/api/v1/permission/roles/"+e.uid,method:"put",data:e}),u=e=>s({url:"/api/v1/permission/roles/"+e,method:"delete"}),n=e=>s({url:"/api/v1/permission/users/"+e.userId+"/roles",method:"get",params:e}),p=(e,r)=>s({url:"/api/v1/permission/users/"+r+"/roles",method:"get",params:e}),d=e=>s({url:"/api/v1/permission/users/"+e.userId+"/roles/remove",method:"post",data:e}),m=e=>s({url:"/api/v1/permission/users/"+e.userId+"/roles/add",method:"post",data:e});export{l as a,p as b,m as c,u as d,n as e,d as f,a as g,t as l,i as u};

1
dist/assets/room-DnQmkEzy.js vendored Normal file
View File

@@ -0,0 +1 @@
import{A as r}from"./index-DRNenl-T.js";const o=t=>r({url:"/api/v1/rooms/list",method:"get",params:t}),s=(t,a)=>r({url:"/api/v1/rooms/"+t,method:"delete",params:a}),i=t=>r({url:"/api/v1/rooms/participants/list",method:"get",params:t}),p=(t,a)=>r({url:"/api/v1/rooms/"+t+"/participants",method:"delete",params:a}),m=(t,a)=>r({url:"/api/v1/meeting/"+t+"/participant/remove",method:"post",data:a}),n=(t,a)=>r({url:"/api/v1/meeting/"+t+"/participant/mute",method:"post",data:a});export{p as a,s as d,o as l,n as m,i as p,m as r};

BIN
dist/favicon.ico vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

14
dist/index.html vendored Normal file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="./favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>xSynergy远程协作系统</title>
<script type="module" crossorigin src="./assets/index-DRNenl-T.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-Dz1wU-dg.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

View File

@@ -0,0 +1,148 @@
<template>
<el-dialog
v-model="inviteDialog"
title="远程协作"
width="400px"
:close-on-press-escape="false"
:close-on-click-modal="false"
:show-close="false"
>
<div style="width: 100%; margin-bottom: 30px; font-size: 20px">
"
{{
socketInformation.room_name
? socketInformation.room_name
: ''
}}
" 邀请您参加远程协作
</div>
<div style="text-align: center">
<el-button
size="large"
type="danger"
style="font-size: 16px"
@click="clickRefuseJoin"
>
</el-button>
<el-button
size="large"
type="primary"
style="font-size: 16px"
@click="clickJoin"
>
</el-button>
</div>
</el-dialog>
</template>
<script setup>
import { ref ,onMounted} from 'vue'
import { getStatusApi } from '@/api/conferencingRoom.js'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { mqttClient } from "@/utils/mqtt.js";
import { useUserStore } from '@/stores/modules/user.js'
const router = useRouter()
const userStore = useUserStore()
const inviteDialog = ref(false)
const socketInformation = ref(null)
/** 拒绝加入 */
const clickRefuseJoin = async () => {
//status 1: 同意加入, 5: 拒绝加入
try{
const res = await getStatusApi(socketInformation.value.room_uid,{status:5})
if(res.meta.code == 200){
ElMessage({
message: '已拒绝加入该协作',
type: 'error',
})
inviteDialog.value = false
}
} catch (error) {
console.log(error,'error')
inviteDialog.value = false
} finally {
inviteDialog.value = false
}
}
const clickJoin = async () => {
const res = await getStatusApi(socketInformation.value.room_uid,{status:1})
if(res.meta.code == 200){
ElMessage({
message: '成功加入该协作',
type: 'success',
})
inviteDialog.value = false
router.push({
path: '/conferencingRoom',
query:{
room_uid:socketInformation.value.room_uid
}
})
}
inviteDialog.value = false
}
/** 浏览器通知 */
const showNotification = (data) => {
if ('Notification' in window) {
Notification.requestPermission().then((permission) => {
if (permission === 'granted') {
const notification = new Notification('协作邀请', {
// body: String(data.room_name) + '邀请您参加远程协作'
body: '远程协作有新的邀请'
// icon: logo,
})
notification.onclick = () => { clickJoin() }
}
})
}
}
/** 处理加入房间和拒接房间 mqtt 消息 */
const processingSocket = (message) => {
const res = JSON.parse(message)
console.log(res,'收到用户信息 邀请')
if (!res?.status) {
socketInformation.value = res
inviteDialog.value = true
showNotification(socketInformation.value)
}else if(res.status == 5){
ElMessage({
message: `${res?.display_name}拒绝加入该协作`,
type: 'error',
})
}
}
defineExpose({
processingSocket,
});
// onMounted(async () => {
// await mqttClient.connect(`room${Math.random().toString(16).substr(2, 8)}`);
// const res = await userStore.getInfo()
// const topic = `xsynergy/room/+/rooms/${res.uid}`;
// mqttClient.subscribe(topic, async (shapeData) => {
// // console.log(shapeData.toString(),'shapeData发送邀请')
// processingSocket(shapeData.toString())
// });
// })
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,99 @@
import { mqttClient } from "./mqtt";
import { getWhiteboardShapes, getWhiteboardHistory } from "@/views/custom/api";
import { useMeterStore } from '@/stores/modules/meter';
import { encode, decode } from '@msgpack/msgpack'
import { ElMessage } from 'element-plus';
import { emitter } from "@/utils/bus.js";
const meterStore = useMeterStore();
meterStore.initUdid();
let isRemote = false;
let canvasInstance = null;
// 获取本地缓存 userData
function getLocalUserData() {
const dataStr = sessionStorage.getItem('userData');
if (!dataStr) return null;
try {
return JSON.parse(dataStr);
} catch (e) {
console.error("解析 userData 失败:", e);
return null;
}
}
export const WhiteboardSync = {
async init(canvas, roomUid) {
if (!canvas || !roomUid) return;
canvasInstance = canvas;
const localUser = getLocalUserData();
const localUid = localUser?.uid;
try {
// 先连接 MQTT
await mqttClient.connect(meterStore.getSudid());
// 获取历史数据
const res = await getWhiteboardHistory({ after_timestamp: 0 }, roomUid);
if (res.meta.code === 200 && res.data.shapes.length > 0) {
canvasInstance.addShape(res.data.shapes);
}else if(res.meta.code === 401){
emitter.emit('whiteboardFailed',true);
}
// 订阅当前房间
const topic = `xsynergy/room/${roomUid}/whiteboard/#`;
mqttClient.subscribe(topic, async (shapeData) => {
const shapeDataNew = JSON.parse(shapeData.toString())
// const shapeDataNew = decode(message);
try {
isRemote = true;
// 如果 shape 来自本地用户,则跳过
if (shapeDataNew.user_uid === localUid) return;
const res = await getWhiteboardHistory({ after_timestamp: shapeDataNew.created_at }, roomUid);
if (res.meta.code === 200) {
canvasInstance.addShape(res.data.shapes);
} else {
ElMessage.error("获取历史数据失败");
console.error("获取历史数据失败");
}
} catch (e) {
console.error("处理MQTT数据失败:", e);
} finally {
isRemote = false;
}
});
} catch (err) {
console.log("初始化多人同步失败:", err)
// console.error("❌ 连接或订阅失败:", err);
}
// 监听画布事件:新增图形
canvas.on('drawingEnd', async (shape) => {
// 如果来自远程,或不是需要同步的类型,跳过
if (isRemote || !['pencil', 'line', 'rectangle', 'circle', 'eraser'].includes(shape.type)) return;
// 如果是本地用户自己的 shape则不调用接口
if (shape.user_uid && shape.user_uid === localUid) return;
shape.room_uid = roomUid;
try {
await getWhiteboardShapes(shape, roomUid);
} catch (err) {
ElMessage.error("提交形状失败");
console.error("提交形状失败:", err);
}
});
// 监听画布事件:清空
canvas.on('clear', async () => {
if (!isRemote) {
try {
// TODO: 调用接口,后端再发 MQTT
// await clearWhiteboard(roomUid);
} catch (err) {
console.error("提交清空失败:", err);
}
}
});
},
};

View File

@@ -0,0 +1,103 @@
import { mqttClient } from "./mqtt";
import { getWhiteboardShapes, getWhiteboardHistory } from "@/views/custom/api";
import { useMeterStore } from '@/stores/modules/meter';
import { encode, decode } from '@msgpack/msgpack'
import { ElMessage } from 'element-plus';
const meterStore = useMeterStore();
meterStore.initUdid();
let isRemote = false;
let canvasInstance = null;
// 获取本地缓存 userData
function getLocalUserData() {
const dataStr = sessionStorage.getItem('userData');
if (!dataStr) return null;
try {
return JSON.parse(dataStr);
} catch (e) {
console.error("解析 userData 失败:", e);
return null;
}
}
export const WhiteboardSync = {
async init(canvas, roomUid) {
if (!canvas || !roomUid) return;
console.log('初始化多人同步:', roomUid);
canvasInstance = canvas;
const localUser = getLocalUserData();
const localUid = localUser?.uid;
try {
// 先连接 MQTT
await mqttClient.connect(meterStore.getSudid());
console.log("✅ MQTT 已连接");
// 获取历史数据
const res = await getWhiteboardHistory({ after_timestamp: 0 }, roomUid);
if (res.meta.code === 200 && res.data.shapes.length > 0) {
canvasInstance.addShape(res.data.shapes);
}
// 订阅当前房间
const topic = `xsynergy/room/${roomUid}/whiteboard/#`;
mqttClient.subscribe(topic, async (shapeData) => {
const shapeDataNew = JSON.parse(shapeData.toString())
// const shapeDataNew = decode(message);
// console.log(shapeDataNew, '格式解码')
try {
isRemote = true;
// 如果 shape 来自本地用户,则跳过
if (shapeDataNew.user_uid === localUid) return;
const res = await getWhiteboardHistory({ after_timestamp: shapeDataNew.created_at }, roomUid);
if (res.meta.code === 200) {
canvasInstance.addShape(res.data.shapes);
} else {
ElMessage.error("获取历史数据失败");
console.error("获取历史数据失败");
}
} catch (e) {
console.error("处理MQTT数据失败:", e);
} finally {
isRemote = false;
}
});
console.log("✅ 已订阅:", topic);
} catch (err) {
console.log("初始化多人同步失败:", err)
// console.error("❌ 连接或订阅失败:", err);
}
// 监听画布事件:新增图形
canvas.on('drawingEnd', async (shape) => {
// 如果来自远程,或不是需要同步的类型,跳过
if (isRemote || !['pencil', 'line', 'rectangle', 'circle', 'eraser'].includes(shape.type)) return;
// 如果是本地用户自己的 shape则不调用接口
if (shape.user_uid && shape.user_uid === localUid) return;
shape.room_uid = roomUid;
try {
await getWhiteboardShapes(shape, roomUid);
} catch (err) {
ElMessage.error("提交形状失败");
console.error("提交形状失败:", err);
}
});
// 监听画布事件:清空
canvas.on('clear', async () => {
if (!isRemote) {
try {
// TODO: 调用接口,后端再发 MQTT
// await clearWhiteboard(roomUid);
} catch (err) {
console.error("提交清空失败:", err);
}
}
});
},
};

View File

@@ -0,0 +1,870 @@
<template>
<div>
<el-dialog
v-model="dialogFormVisible"
:title="title"
width="80%"
:before-close="handleClose"
class="file-preview-dialog"
>
<div class="preview-container">
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<el-icon class="is-loading" size="48">
<Loading />
</el-icon>
<p>{{ loadingText }}</p>
<!-- <p v-if="convertTaskId" class="task-id">任务ID: {{ convertTaskId }}</p> -->
</div>
<!-- 转换状态 -->
<div v-else-if="converting" class="loading-container">
<el-icon class="is-loading" size="48">
<Loading />
</el-icon>
<p>{{ conversionMessage }}</p>
<!-- <p class="task-id">任务ID: {{ convertTaskId }}</p> -->
</div>
<!-- 下载状态 -->
<div v-else-if="downloading" class="loading-container">
<el-icon class="is-loading" size="48">
<Loading />
</el-icon>
<p>正在下载文件请稍候...</p>
<p class="download-progress" v-if="downloadProgress > 0">
下载进度: {{ downloadProgress }}%
</p>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error-container">
<el-icon size="48" color="#F56C6C">
<CircleClose />
</el-icon>
<p>文件预览失败</p>
<p class="error-message">{{ errorMessage }}</p>
<el-button type="primary" @click="retryPreview">重试</el-button>
</div>
<!-- 文件预览内容 -->
<div v-else class="file-content">
<!-- 图片预览 -->
<div v-if="isImage" class="image-preview">
<img :src="previewUrl" :alt="fileName" @load="handleImageLoad" />
</div>
<!-- PDF预览 - 使用 vue-pdf-embed -->
<div v-else-if="isPdf" class="pdf-preview">
<div class="pdf-controls" v-if="pageCount > 0">
<el-button-group>
<el-button :disabled="currentPage <= 1" @click="previousPage">
<el-icon><ArrowLeft /></el-icon>
上一页
</el-button>
<el-button>
{{ currentPage }} / {{ pageCount }}
</el-button>
<el-button :disabled="currentPage >= pageCount" @click="nextPage">
下一页
<el-icon><ArrowRight /></el-icon>
</el-button>
</el-button-group>
</div>
<div class="pdf-viewer-container">
<VuePdfEmbed
:source="pdfSource"
:page="currentPage"
:scale="scale"
@loaded="handlePdfLoaded"
@rendered="handlePdfRendered"
@error="handlePdfError"
class="pdf-viewer"
/>
</div>
</div>
<!-- 视频预览 -->
<div v-else-if="isVideo" class="video-preview">
<video controls :src="previewUrl" class="video-player">
您的浏览器不支持视频播放
</video>
</div>
<!-- 音频预览 -->
<div v-else-if="isAudio" class="audio-preview">
<audio controls :src="previewUrl" class="audio-player">
您的浏览器不支持音频播放
</audio>
</div>
<!-- 文本预览 -->
<div v-else-if="isText" class="text-preview">
<pre>{{ textContent }}</pre>
</div>
<!-- Office文档预览转换后 -->
<div v-else-if="isConvertedOffice" class="office-preview">
<!-- 转换后的Office文件也是PDF使用相同的PDF预览器 -->
<div class="pdf-controls" v-if="pageCount > 0">
<el-button-group>
<el-button :disabled="currentPage <= 1" @click="previousPage">
<el-icon><ArrowLeft /></el-icon>
上一页
</el-button>
<el-button>
{{ currentPage }} / {{ pageCount }}
</el-button>
<el-button :disabled="currentPage >= pageCount" @click="nextPage">
下一页
<el-icon><ArrowRight /></el-icon>
</el-button>
</el-button-group>
</div>
<div class="pdf-viewer-container">
<VuePdfEmbed
:source="pdfSource"
:page="currentPage"
:scale="scale"
@loaded="handlePdfLoaded"
@rendered="handlePdfRendered"
@error="handlePdfError"
class="pdf-viewer"
/>
</div>
</div>
<!-- 不支持预览的文件类型 -->
<div v-else class="unsupported-preview">
<el-icon size="64" color="#909399">
<Document />
</el-icon>
<p>不支持在线预览此文件类型</p>
<p class="file-name">{{ fileName }}</p>
</div>
</div>
</div>
<!-- 底部操作栏 -->
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="close">
关闭
</el-button>
</div>
</template>
</el-dialog>
<el-dialog
v-model="dialogFileVisible"
title="文件转换"
width="50%"
class="file-preview-dialog"
>
<div class="preview-container">
<div class="loading-container">
<el-icon class="is-loading" size="48">
<Loading />
</el-icon>
<p>正在下载文件请稍候...</p>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, getCurrentInstance, onUnmounted, nextTick, onMounted } from 'vue'
import { convertFileApi, getConvertStatusApi } from '@/api/conferencingRoom'
import { ElMessage ,ElMessageBox} from 'element-plus'
import {
Loading,
CircleClose,
Document,
ArrowLeft,
ArrowRight,
} from '@element-plus/icons-vue'
import VuePdfEmbed from 'vue-pdf-embed'
import { mqttClient } from "@/utils/mqtt.js";
import { emitter } from "@/utils/bus.js";
// 定义props
const props = defineProps({
fileType: {
type: Array,
default: () => ["pdf", "png", "jpg", "jpeg", "gif", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "mp4", "mp3"],
},
roomId: {
type: String,
default: '',
},
})
// 定义emits
const emit = defineEmits(['fetch-data'])
const { proxy } = getCurrentInstance()
const enumType = ["doc", "docx", "xls", "xlsx", "ppt", "pptx"]
// 响应式数据
const dialogFormVisible = ref(false)
const title = ref('')
const loading = ref(false)
const converting = ref(false)
const downloading = ref(false)
const downloadProgress = ref(0)
const loadingText = ref('正在加载...')
const conversionMessage = ref('正在转换文件...')
const error = ref(false)
const errorMessage = ref('')
const previewUrl = ref('')
const fileName = ref('')
const textContent = ref('')
const currentFileData = ref(null)
const convertTaskId = ref('')
const dialogFileVisible = ref(false)
// PDF相关状态
const pdfSource = ref('')
const currentPage = ref(1)
const pageCount = ref(0)
const scale = ref(1.0)
const pdfDocument = ref(null)
// MQTT相关
const isMqttConnected = ref(false)
// 计算属性
const isImage = computed(() => {
const ext = fileName.value.split('.').pop().toLowerCase()
return ['png', 'jpg', 'jpeg', 'gif'].includes(ext)
})
const isPdf = computed(() => {
const ext = fileName.value.split('.').pop().toLowerCase()
return ext === 'pdf'
})
const isVideo = computed(() => {
const ext = fileName.value.split('.').pop().toLowerCase()
return ['mp4'].includes(ext)
})
const isAudio = computed(() => {
const ext = fileName.value.split('.').pop().toLowerCase()
return ['mp3'].includes(ext)
})
const isText = computed(() => {
const ext = fileName.value.split('.').pop().toLowerCase()
return ext === 'txt'
})
const isOffice = computed(() => {
const ext = fileName.value.split('.').pop().toLowerCase()
return enumType.includes(ext)
})
const isConvertedOffice = computed(() => {
return previewUrl.value && previewUrl.value.endsWith('.pdf') && isOffice.value
})
// 组件挂载时初始化MQTT连接
onMounted(async () => {
})
emitter.on('subscribeToFileConversionStatusTopic',subscribeToFileConversionStatusTopic)
emitter.on('fileUploadStatus',fileUploadStatus)
emitter.on('subscribeToFilePreviewTopic',subscribeToFilePreviewTopic)
emitter.on('fileSuccess',fileSuccess)
function fileSuccess(){
dialogFileVisible.value = true
}
//
function subscribeToFileConversionStatusTopic(data){
try {
const userId = JSON.parse(sessionStorage.getItem('userData'))?.uid
if (!userId) {
console.error('用户ID不存在')
return
}
const topic = `xsynergy/room/${data.roomId}/file/${userId}/conversion_status`
mqttClient.subscribe(topic, handlePdfMessage)
} catch (error) {
console.error('订阅pdf转换事件失败:', error)
}
}
function subscribeToFilePreviewTopic(data){
try {
const topic = `xsynergy/room/${data.roomId}/file/preview`
mqttClient.subscribe(topic, handleFileUploadMessage)
} catch (error) {
console.error('订阅文件上传事件失败:', error)
}
}
//提交转换任务,但文件暂未转换完成
function fileUploadStatus(data){
// console.log('文件上传成功mqtt消息')
}
// 初始化MQTT连接 pdf转换成功
async function initMqttConnection() {
try {
if (isMqttConnected.value) return
const clientId = `PdfConversion_${Date.now()}`
await mqttClient.connect(clientId)
isMqttConnected.value = true
// 订阅主题
subscribeToPdfConversionTopic()
} catch (error) {
console.error('MQTT连接失败:', error)
ElMessage.error('文件转换服务连接失败')
}
}
function subscribeToPdfConversionTopic() {
try {
const userId = JSON.parse(sessionStorage.getItem('userData'))?.uid
if (!userId) {
console.error('用户ID不存在')
return
}
const topic = `xsynergy/room/${props.roomId}/file/${userId}/conversion_status`
mqttClient.subscribe(topic, handlePdfMessage)
} catch (error) {
console.error('订阅pdf转换事件失败:', error)
}
}
function handleFileUploadMessage(payload, topic){
try {
const messageStr = payload.toString()
const data = JSON.parse(messageStr)
// emitter.emit('fileUploadStatus')
const userId = JSON.parse(sessionStorage.getItem('userData'))?.uid
if(dialogFileVisible.value){
dialogFileVisible.value = false
}
if(dialogFormVisible.value && userId != data.user_uid){
// 显示确认对话框
ElMessageBox.confirm(
`用户${data.user_uid}上传了${data.file_name}文件,是否立即预览?`,
'文件更新提示',
{
confirmButtonText: '预览',
cancelButtonText: '取消',
type: 'warning',
closeOnClickModal: false,
closeOnPressEscape: false,
showClose: false
}
).then(() => {
// 用户点击"预览"
resetPreviewState()
getPreviewFileUrl(data)
ElMessage({
message: '已切换到新文件预览',
type: 'success'
})
}).catch(() => {
// 用户点击"取消"
ElMessage({
message: '已取消预览新文件,继续查看当前文件',
type: 'info'
})
})
// resetPreviewState()
// getPreviewFileUrl(data)
} else {
ElMessage({
message: `用户${data.user_uid}上传了${data.file_name}文件`,
type: 'info',
})
showEdit(data)
}
} catch (error) {
console.error('处理转换状态消息失败:', error)
handleError('处理转换状态失败')
}
}
// 新增重置预览状态的方法
function resetPreviewState() {
loading.value = true
converting.value = false
downloading.value = false
downloadProgress.value = 0
error.value = false
previewUrl.value = ''
textContent.value = ''
resetPdfState()
// 强制重新渲染
nextTick(() => {
// 确保DOM更新
})
}
function handlePdfMessage(payload, topic){
try {
const messageStr = payload.toString()
const data = JSON.parse(messageStr)
switch (data.status) {
case 'converting':
break
case 'completed':
getConvertedFile(data.task_id,data.room_uid)
break
case 'failed':
break
default:
console.warn('未知的转换状态:', data.status)
}
} catch (error) {
console.error('处理转换状态消息失败:', error)
handleError('处理转换状态失败')
}
}
// 获取转换后的文件
const getConvertedFile = async (taskId,roomId) => {
try {
if (!taskId) {
throw new Error('任务ID不存在')
}
const fileRes = await getConvertStatusApi(taskId,roomId)
} catch (err) {
console.error('获取转换文件失败:', err)
handleError('获取转换文件失败', err)
}
}
// 修改 getPreviewFileUrl 方法,确保状态正确更新
async function getPreviewFileUrl(file){
fileName.value = file.file_name || file.source_url.split('/').pop()
const fileExt = fileName.value.split('.').pop().toLowerCase()
try {
// 根据文件类型处理转换后的文件
if (fileExt === 'pdf' || enumType.includes(fileExt)) {
// PDF和Office文档使用PDF预览器
await loadPdfFile(file.preview_url || file.file_url)
} else if (fileExt === 'txt') {
// 文本文件
await loadTextFile(file.preview_url || file.file_url)
} else if (['png', 'jpg', 'jpeg', 'gif'].includes(fileExt)) {
// 图片文件直接预览
previewUrl.value = file.preview_url || file.file_url
// 确保图片重新加载
nextTick(() => {
loading.value = false
})
} else if (fileExt === 'mp4') {
// 视频文件
previewUrl.value = file.preview_url || file.file_url
nextTick(() => {
loading.value = false
})
} else if (fileExt === 'mp3') {
// 音频文件
previewUrl.value = file.preview_url || file.file_url
nextTick(() => {
loading.value = false
})
} else {
// 其他文件类型
previewUrl.value = file.preview_url || file.file_url
nextTick(() => {
loading.value = false
})
}
} catch (error) {
console.error('加载预览文件失败:', error)
handleError('加载预览文件失败', error)
}
}
// 显示弹框
const showEdit = async (data) => {
// 重置状态
resetPreviewState()
title.value = '文件预览'
dialogFormVisible.value = true
currentFileData.value = data
// fileName.value = data.file_name || data.source_url.split('/').pop()
await getPreviewFileUrl(data)
}
// 加载PDF文件
const loadPdfFile = async (fileUrl) => {
try {
loading.value = false
downloading.value = true
downloadProgress.value = 0
const response = await fetch(fileUrl)
if (!response.ok) {
throw new Error('文件下载失败')
}
const contentLength = response.headers.get('content-length')
const total = parseInt(contentLength, 10)
let loaded = 0
const reader = response.body.getReader()
const chunks = []
while (true) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
loaded += value.length
if (total) {
downloadProgress.value = Math.round((loaded / total) * 100)
}
}
const blob = new Blob(chunks)
const blobUrl = URL.createObjectURL(blob)
// 先清理之前的URL
if (pdfSource.value) {
URL.revokeObjectURL(pdfSource.value)
}
previewUrl.value = fileUrl
pdfSource.value = blobUrl
// 重置PDF状态
currentPage.value = 1
pageCount.value = 0
downloading.value = false
downloadProgress.value = 0
} catch (err) {
downloading.value = false
downloadProgress.value = 0
handleError('PDF文件下载失败', err)
}
}
// 加载文本文件
const loadTextFile = async (fileUrl) => {
try {
const response = await fetch(fileUrl)
if (!response.ok) throw new Error('无法加载文本文件')
const text = await response.text()
textContent.value = text
loading.value = false
} catch (err) {
handleError('文本文件加载失败', err)
}
}
// 上传文件获取office获取pdf文件
async function getFilePdf(fileUrl) {
try {
loadingText.value = '正在转换文件...'
const res = await convertFileApi({ file_url: fileUrl },props.roomId)
if (res.meta.code !== 200) {
throw new Error(res.meta.msg || '文件转换失败')
}
convertTaskId.value = res.data.task_id
// 等待MQTT消息不进行轮询
loading.value = false
converting.value = true
conversionMessage.value = '已提交转换任务,等待处理...'
} catch (err) {
handleError('文件转换失败', err)
}
}
// PDF相关方法
const handlePdfLoaded = (data) => {
pageCount.value = data.numPages
pdfDocument.value = data
loading.value = false
converting.value = false
}
const handlePdfRendered = () => {
// console.log('PDF页面渲染完成')
}
const handlePdfError = (error) => {
console.error('PDF加载错误:', error)
handleError('PDF文件加载失败', error)
}
const previousPage = () => {
if (currentPage.value > 1) {
currentPage.value--
}
}
const nextPage = () => {
if (currentPage.value < pageCount.value) {
currentPage.value++
}
}
const resetPdfState = () => {
pdfSource.value = ''
currentPage.value = 1
pageCount.value = 0
scale.value = 1.0
pdfDocument.value = null
}
// 处理图片加载完成
const handleImageLoad = () => {
loading.value = false
}
// 处理错误
const handleError = (message, error) => {
error.value = true
errorMessage.value = message
loading.value = false
converting.value = false
downloading.value = false
downloadProgress.value = 0
ElMessage.error(message)
}
// 重试预览
const retryPreview = () => {
if (currentFileData.value) {
showEdit(currentFileData.value)
}
}
// 关闭按钮点击事件
const close = () => {
dialogFormVisible.value = false
resetState()
}
// 处理对话框关闭
const handleClose = (done) => {
close()
}
// 重置状态
const resetState = () => {
loading.value = false
converting.value = false
downloading.value = false
downloadProgress.value = 0
error.value = false
previewUrl.value = ''
textContent.value = ''
currentFileData.value = null
convertTaskId.value = ''
resetPdfState()
}
// 组件卸载时清理
onUnmounted(() => {
// 可以在这里取消特定主题的订阅,但不断开连接
// const userId = JSON.parse(sessionStorage.getItem('userData'))?.uid
// if (userId) {
// const topic = `xsynergy/room/${props.roomId}/file/${userId}/conversion_status`
// mqttClient.unsubscribe(topic, handlePdfMessage)
// }
})
// 暴露方法给父组件
defineExpose({
showEdit,
close,
})
</script>
<style lang="scss" scoped>
.file-preview-dialog {
.preview-container {
min-height: 400px;
display: flex;
align-items: center;
justify-content: center;
.loading-container, .error-container {
text-align: center;
padding: 40px 0;
p {
margin-top: 16px;
color: #606266;
}
.error-message {
font-size: 14px;
color: #F56C6C;
margin-top: 8px;
}
.download-progress {
font-size: 14px;
color: #409EFF;
margin-top: 8px;
}
}
.file-content {
width: 100%;
height: 100%;
.image-preview {
text-align: center;
img {
max-width: 100%;
max-height: 70vh;
object-fit: contain;
}
}
.pdf-preview, .office-preview {
.pdf-controls {
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 16px;
padding: 8px;
background: #f5f5f5;
border-radius: 4px;
flex-wrap: wrap;
gap: 8px;
}
.pdf-viewer-container {
height: 70vh;
overflow: auto;
border: 1px solid #e0e0e0;
border-radius: 4px;
background: #f9f9f9;
.pdf-viewer {
width: 100%;
min-height: 100%;
display: flex;
justify-content: center;
:deep(.vue-pdf-embed) {
text-align: center;
}
:deep(canvas) {
max-width: 100%;
height: auto;
}
}
}
}
.video-preview {
text-align: center;
.video-player {
max-width: 100%;
max-height: 70vh;
}
}
.audio-preview {
text-align: center;
padding: 40px 0;
.audio-player {
width: 80%;
}
}
.text-preview {
height: 70vh;
overflow: auto;
background: #f5f5f5;
padding: 16px;
border-radius: 4px;
pre {
white-space: pre-wrap;
word-wrap: break-word;
margin: 0;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 14px;
line-height: 1.5;
}
}
.unsupported-preview {
text-align: center;
padding: 40px 0;
p {
margin-top: 16px;
color: #606266;
}
.file-name {
font-size: 14px;
color: #909399;
margin-top: 8px;
}
}
}
}
.dialog-footer {
display: flex;
justify-content: space-between;
}
}
@media (max-width: 768px) {
.file-preview-dialog {
width: 95% !important;
.preview-container {
min-height: 300px;
.file-content {
.pdf-preview, .office-preview {
.pdf-viewer-container {
height: 50vh;
}
}
.text-preview {
height: 50vh;
}
}
}
.pdf-controls {
flex-direction: column;
gap: 8px;
.el-button-group {
width: 100%;
display: flex;
.el-button {
flex: 1;
font-size: 12px;
padding: 8px 4px;
}
}
}
}
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,683 @@
<template>
<div>
<div class="left-list" v-loading="leftListLoading || loading">
<div class="list-tab">
<div
:class="'list-tab-item ' + (leftTab == 1 ? 'pitch-on' : '')"
@click="() => (leftTab = 1)"
>
<img src="@/assets/images/Gc_114_line-Level-action.png" v-if="leftTab == 1" />
<img src="@/assets/images/Gc_114_line-Level.png" v-else />
</div>
<div
:class="'list-tab-item ' + (leftTab == 2 ? 'pitch-on' : '')"
@click="() => (leftTab = 2)"
>
<img src="@/assets/images/book-read-fill-action.png" v-if="leftTab == 2" />
<img src="@/assets/images/book-read-fill.png" v-else />
</div>
</div>
<div class="list-content">
<div class="content-top-input" v-if="leftTab == 1">
<!-- <el-input
v-model="queryFrom.nickName"
placeholder="搜索成员"
type="text"
prefix-icon="Search"
@change="searchList"
/> -->
<el-select
v-model="participant_user"
multiple
filterable
clearable
remote
reserve-keyword
placeholder="搜索成员"
:remote-method="remoteMethod"
:loading="loading"
collapse-tags
collapse-tags-tooltip
style="width: 100%"
@change="searchList"
>
<el-option
v-for="item in userList"
:key="item.uid"
:label="item.name"
:value="item.uid"
/>
</el-select>
</div>
<div class="content-top-input" v-if="leftTab == 2">
<el-input
v-model="queryFrom.nickName"
placeholder="搜索成员"
type="text"
prefix-icon="Search"
@change="searchList"
/>
</div>
<div class="content-datapicker" v-if="leftTab == 1">
<el-date-picker
v-model="queryFrom.leftDatePicker"
type="datetimerange"
:shortcuts="shortcuts"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
format="YYYY-MM-DD HH:mm"
value-format="YYYY-MM-DD HH:mm"
@change="searchList"
/>
</div>
<el-scrollbar class="left-list-scrollbar" v-if="isShow && leftTab == 1">
<div
class="content-list"
v-infinite-scroll="infinite"
v-if="dataList?.length"
>
<!-- @click="updateDetail(item)" -->
<div
v-for="(item, index) in dataList"
:key="index"
class="content-list-item"
@click="updateDetail(item)"
:style="
item.id == assistanceId
? 'border-color: #409EFF; '
: ''
"
>
<div class="list-item-top">
<span>
{{ parseTime(item.created_at, '{m}月{d}日') }}
{{ weekName[new Date(item.created_at).getDay()] }}
</span>
<span>
{{ parseTime(item.created_at, '{y}年') }}
</span>
</div>
<div class="list-item-content">
<div class="list-item-content-text">
<div style="display: flex; flex-wrap: wrap">
<span
v-for="(items, indexs) in item.all_participants"
:key="indexs"
>
{{
(items.display_name || items.user_uid) +
(indexs + 1 == item.all_participants.length
? ''
: '、')
}}
</span>
</div>
<span>
发起人{{ item.all_participants.find(item => item.participant_role == 'moderator')?.display_name || ''}}
</span>
<span>
时间{{
parseTime(item.created_at, '{h}:{i}') +
' ~ ' +
(item.updated_at ? parseTime(item.updated_at, '{h}:{i}') : '')
}}
</span>
</div>
</div>
</div>
</div>
<div style="text-align: center" v-if="dataList?.length">
<p v-if="more">Loading...</p>
<p v-else>No more</p>
</div>
<div v-else class="list-empty">
<el-empty description="暂无记录" />
</div>
</el-scrollbar>
<div v-if="!isShow && leftTab == 1" class="list-empty">
<el-empty description="暂无记录" />
</div>
<el-scrollbar
class="left-list-scrollbar1"
height="calc(100vh - 120px)"
v-if="leftTab == 2"
>
<el-tree
ref="treeRef"
lazy
:load="HandleLoadNode"
:filter-node-method="filterNode"
highlight-current
:props="treeProps"
style="width: 100%"
@node-click="updateDetail"
>
<template #default="{ data }">
<div class="tree-item">
{{ data.name }}
</div>
</template>
</el-tree>
</el-scrollbar>
</div>
</div>
</div>
</template>
<script setup>
import {
// getAssistanceList,
getParticipantsHistoryApi,
getDirectories,
getDirectoriesUsers,
getInfo,
} from '@/api/coordinate.js'
import { nextTick, reactive, toRefs, watch, onMounted } from 'vue'
import { deepClone,parseTime ,createThrottle} from '@/utils/ruoyi.js'
import { getUserInfo } from '@/utils/auth.js'
// 接收 props
const props = defineProps({
loading: {
type: Boolean,
default: true,
},
})
// 定义 emit
const emit = defineEmits(['updateDetail', 'updateTab'])
// state
const state = reactive({
isFirst: true,
leftTab: 1,
queryFrom: {
page_size: 10,
page: 1,
nickName: '',
leftDatePicker: null,
participant_user_uids:''
},
participant_user:[],
leftListLoading: true,
loading: false,
dataList: [],
more: false,
isShow: false,
shortcuts: [
{
text: '本周',
value: () => {
const now = new Date()
const nowDaty = now.getDay()
const start = new Date(now)
start.setDate(now.getDate() - nowDaty)
const end = new Date(start)
end.setDate(start.getDate() + 6)
return [start, end]
},
},
{
text: '最近三周',
value: () => {
const now = new Date()
const nowDaty = now.getDay()
let end = new Date(now)
end.setDate(now.getDate() + (6 - nowDaty))
const start = new Date(end)
start.setDate(start.getDate() - 20)
return [start, end]
},
},
{
text: '本月',
value: () => {
const now = new Date()
const start = new Date(now.getFullYear(), now.getMonth(), 1)
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0)
return [start, end]
},
},
],
weekName: ['周日', '周一', '周二', '周三', '周四', '周五', '周六'],
treeRef: null,
treeProps: {
children: 'users',
label: 'name',
value: 'uid',
isLeaf: (node) => {
if(node.uid) return true
},
},
assistanceId: '',
userList:[],
})
// state.loading = true;
const remoteMethod = createThrottle(async (query) => {
if (!query || query.trim() === '') {
state.userList = [];
return;
}
try {
const res = await getInfo(query, { search_type: 'name' });
state.userList = res.data || [];
} catch (error) {
console.error('搜索用户失败:', error);
state.userList = [];
}
}, 500);
/**
* 树状列表筛选
*/
const filterNode = (value, data) => {
if (!value) return true
return data.name.includes(value)
}
/**
* 搜索框变化
*/
const searchList = () => {
if (state.leftTab == 1) {
state.queryFrom.page = 1
state.dataList = []
state.queryFrom.participant_user_uids = state.participant_user.join(',')
getList()
} else {
state.treeRef.filter(state.queryFrom.nickName)
}
}
/**
* 变更详情
*/
const updateDetail = (item) => {
if (state.leftTab == 1) {
state.assistanceId = item.id
emit('updateDetail', item)
} else {
if (item.uid) {
emit('updateDetail', item)
}
}
}
/**
* 触底加载
*/
const infinite = () => {
console.log('外面的加载')
if (state.more) {
console.log(state.more,'state.more执行了')
state.queryFrom.page++
getList()
}
}
/**
* 协作记录
*/
const getList = async () => {
try {
state.leftListLoading = true
let query = deepClone(state.queryFrom)
// if (query.leftDatePicker?.length) {
// query.beginSignTime = query.leftDatePicker[0]
// query.endSignTime = query.leftDatePicker[1]
// }
if (query.leftDatePicker?.length) {
const startTime = new Date(query.leftDatePicker[0]);
const endTime = new Date(query.leftDatePicker[1]);
// 添加日期有效性验证
if (!isNaN(startTime.getTime())) {
query.start_time = Math.floor(startTime.getTime());
} else {
console.error('开始时间格式无效:', query.leftDatePicker[0]);
query.start_time = '';
}
if (!isNaN(endTime.getTime())) {
query.end_time = Math.floor(endTime.getTime());
} else {
console.error('结束时间格式无效:', query.leftDatePicker[1]);
query.end_time = '';
}
}
delete query.leftDatePicker
const userData = await getUserInfo()
if(!userData) return
let infoData = await getParticipantsHistoryApi( userData?.uid ,{ ...query })
state.dataList = infoData.data.history?.length
? state.dataList.concat(infoData.data.history)
: []
if (state.isFirst) {
emit('updateDetail', state.dataList?.length ? state.dataList[0] : null)
state.assistanceId = state.dataList?.length
? state.dataList[0].id
: ''
state.isFirst = false
}
state.more = state.dataList?.length < infoData.data.total
state.isShow = Boolean(state.dataList?.length)
state.leftListLoading = false
} catch (err) {
console.log(err)
state.leftListLoading = false
}
}
/**
* 通讯录 人员信息树
*/
const HandleLoadNode = async (node, resolve) => {
if(node?.level === 0){
loadNode(resolve,'',node?.level)
}else if(node?.level > 0){
if(node.data.directory_uid){
loadUserNode(resolve,node.data.directory_uid,node?.level)
}else{
resolve(resolve)
}
}
}
const loadNode = async(resolve,id,level)=>{
try {
state.leftListLoading = true
let res = await getDirectories({level:1})
if(res.meta.code == 200){
resolve(res.data)
}
state.leftListLoading = false
} catch (error) {
console.log(error)
state.leftListLoading = false
}
}
const loadUserNode = async(resolve,id,level)=>{
try {
state.leftListLoading = true
let userData = []
let orgData = []
const resOrg = await getDirectories({level: 1,parent_uuid:id})
if(resOrg?.data){
orgData = resOrg.data
}
if(id){
const res = await getDirectoriesUsers(id,{directory_uuid:id})
userData = res.data
}
resolve([...orgData, ...userData])
state.leftListLoading = false
} catch (error) {
console.log(error)
state.leftListLoading = false
}
}
const getUser = (list) => {
for (let i = 0; i < list.length; i++) {
if (list[i].type == 2) {
return list[i]
} else if (list[i].children?.length) {
let user = getUser(list[i].children)
if (user != null) {
return user
}
}
}
}
/**
* 监听 props.loading
*/
watch(
() => props.loading,
(newValue) => {
state.loading = newValue
}
)
/**
* 监听 tab 切换
*/
watch(
() => state.leftTab,
(newValue) => {
emit('updateTab', newValue)
state.dataList = []
state.isFirst = true
state.queryFrom = {
page_size: 10,
page: 1,
nickName: '',
participant_user_uids:'',
leftDatePicker: null,
}
if (newValue == 1) {
state.isShow = false
getList()
} else {
HandleLoadNode()
}
}
)
onMounted(() => {
state.dataList = []
state.isFirst = true
getList()
})
/**
* 暴露给模板
*/
const {
isFirst, leftTab, queryFrom, leftListLoading, loading,
dataList, more, isShow, shortcuts, weekName,
treeRef, treeProps, assistanceId,userList,participant_user
} = toRefs(state)
</script>
<style lang="scss" scoped>
.flex {
display: flex;
justify-content: center;
align-items: center;
}
.left-list {
display: flex;
height: calc(100vh - 40px);
.list-tab {
@extend .flex;
justify-content: flex-start;
flex-direction: column;
margin-right: 6px;
.list-tab-item {
width: 50px;
height: 50px;
cursor: pointer;
img {
width: 40px;
height: 40px;
}
}
}
.list-content {
@extend .flex;
justify-content: flex-start;
flex-direction: column;
width: calc(100% - 56px);
box-shadow: 0px 5px 15px 0px rgba(153, 153, 153, 0.3);
.content-top-input {
@extend .flex;
width: 100%;
height: 50px;
padding: 6px 20px;
background: #666666;
}
.content-datapicker {
width: 100%;
}
.left-list-scrollbar {
width: 100%;
height: calc(100vh - 170px);
padding: 15px 0;
}
.left-list-scrollbar1 {
width: 100%;
height: calc(100vh - 120px);
margin: 15px 0;
}
.content-list {
width: 100%;
padding: 0 15px;
}
.content-list-item {
width: 100%;
margin-bottom: 15px;
background: #f5f7fa;
border: 1.5px solid #c9d4e6;
cursor: pointer;
.list-item-top {
@extend .flex;
justify-content: space-between;
width: 100%;
height: 50px;
padding: 0 13px;
background: #c9d4e6;
span {
color: #333333;
font-size: 16px;
}
}
.list-item-content {
@extend .flex;
justify-content: flex-start;
align-items: flex-start;
padding: 13px;
background: #f5f7fa;
img {
width: 27px;
height: 27px;
border-radius: 50%;
margin-right: 10px;
}
.list-item-content-text {
@extend .flex;
flex-direction: column;
align-items: flex-start;
div {
@extend .flex;
justify-content: flex-start;
span {
margin: 0;
color: #333;
font-size: 16px;
}
}
span {
display: inline-block;
margin-top: 6px;
color: #999;
font-size: 14px;
}
}
}
}
}
}
.list-empty {
@extend .flex;
width: 100%;
height: calc(100vh - 170px);
}
.tree-item {
@extend .flex;
justify-content: flex-start;
height: 45px;
font-size: 18px;
.tree-item-img1 {
width: 22px;
height: 22px;
}
.tree-item-text1 {
margin-left: 10px;
color: #333333;
font-size: 18px;
font-weight: 600;
}
.tree-item-text2 {
color: #333333;
font-size: 18px;
}
.tree-item-text3 {
margin-left: 15px;
color: #999999;
font-size: 18px;
}
}
::v-deep .el-tree-node__content {
@extend .flex;
justify-content: flex-start;
height: 45px;
}
::v-deep .list-content .el-input__wrapper {
height: 38px;
border-radius: 19px;
}
::v-deep .content-datapicker .el-date-editor {
width: 100%;
height: 50px;
background: #e6f1ff;
border-radius: 0;
border: none;
box-shadow: none;
}
::v-deep .left-list-scrollbar .el-scrollbar__wrap {
height: calc(100vh - 170px);
}
::v-deep .left-list-scrollbar1 .el-scrollbar__wrap {
height: calc(100vh - 120px);
}
</style>

View File

@@ -0,0 +1,732 @@
<template>
<div>
<div class="app-container" v-loading="load" :element-loading-text="loadText">
<el-row :gutter="6" style="padding: 0 10px; background: #fefefe">
<el-col :xs="24" :sm="24" :md="8" :lg="6">
<leftTab
@updateDetail="updateDetail"
@updateTab="updateTab"
:loading="!detail?.appId && !detail?.userId && isShow"
/>
</el-col>
<el-col :xs="24" :sm="24" :md="16" :lg="18">
<div
class="right-content"
v-loading='isShowLoading'
>
<div class="right-content-title">
{{ tabValue == 1 ? '协作信息' : '员工信息' }}
</div>
<div
class="agency-detail-massage-cont right-content-message"
v-if="isShow && tabValue == 1"
>
<div class="agency-detail-cont-item">
<span class="agency-detail-item-title">发起人</span>
<span class="agency-detail-item-content">
{{ detail.initiator }}
</span>
</div>
<div class="agency-detail-cont-item">
<span class="agency-detail-item-title">协作时间</span>
<span class="agency-detail-item-content">
{{
detail.created_at ?
parseTime(detail.created_at, '{y}年{m}月{d}日') +
' ' +
weekName[new Date(detail.created_at).getDay()] +
' ' +
parseTime(detail.created_at, '{h}:{i}')
: '暂无'
}}
</span>
</div>
<div class="agency-detail-cont-item">
<span class="agency-detail-item-title">成员</span>
<span
class="agency-detail-item-content"
v-if="detail?.all_participants?.length"
style="display: flex; flex-wrap: wrap"
>
<span
v-for="(items, indexs) in detail.all_participants"
:key="indexs"
>
{{
(items.display_name || items.user_uid) +
(indexs + 1 == detail.all_participants.length
? ''
: '、')
}}
</span>
</span>
</div>
<div class="agency-detail-cont-item">
<span class="agency-detail-item-title">协作时长</span>
<span class="agency-detail-item-content">
{{ getTime() }}
</span>
</div>
</div>
<div class="right-content-file" v-if="isShow && tabValue == 1" >
<el-row :gutter="15">
<el-col :xs="24" :sm="24" :md="16" :lg="18">
<div class="content-file-video">
<div class="file-top">协作视频</div>
<div class="file-video-bottom">
<!-- autoplay="autoplay" -->
<video
v-if="detail.remoteVideoFile?.storage_url"
:src="detail.remoteVideoFile.storage_url"
id="videoPlayer"
loop
autoplay
controls
></video>
<div v-else class="video-null">暂无视频</div>
</div>
</div>
</el-col>
<el-col :xs="24" :sm="24" :md="8" :lg="6">
<div>
<div class="file-top">
附件{{
detail?.fileList?.length ? detail.fileList.length : 0
}}
</div>
<div class="content-file-list">
<el-scrollbar
class="file-list"
height="calc(100vh - 390px)"
>
<div
class="file-list-content"
v-if="detail?.fileList?.length"
>
<div
class="file-list-item"
v-for="(item, index) in detail.fileList"
:key="index"
>
<div class="file-list-item-icon"></div>
<div class="file-list-item-text">
<div class="list-item-text text-out-of-hiding-1">
{{ item.file_name }}
</div>
<el-icon
:size="18"
color="#0d74ff"
style="cursor: pointer"
@click="handlePreview(item)"
>
<View />
</el-icon>
<el-link
:href="item.source_url"
type="primary"
target="_blank"
:underline="false"
>
<el-icon
:size="18"
color="#0d74ff"
style="cursor: pointer"
>
<Download />
</el-icon>
</el-link>
</div>
</div>
</div>
</el-scrollbar>
</div>
</div>
</el-col>
</el-row>
</div>
<div
class="message-user"
v-else-if="isShow && tabValue == 2"
style="height: calc(100vh - 90px)"
v-loading="userLoading"
>
<div class="message-user-card">
<div class="user-card-nickName">
<img v-if="detail.avatar" :src="detail.avatar" />
<img v-else src="@/assets/images/profile.jpg" />
<span>{{ detail.nickName || detail.name || '暂无信息' }}</span>
</div>
<div class="user-card-information">
<div class="user-information-item">
<div class="user-information-title">
<img src="@/assets/images/user-information1.png" alt="" />
<span>性别</span>
</div>
<div class="user-information-text">
<!-- <dict-tag
v-if="detail.sex"
:options="sys_user_sex"
:value="detail.sex"
/>
<div v-else>
{{ '暂无' }}
</div> -->
</div>
</div>
<div class="user-information-item">
<div class="user-information-title">
<img src="@/assets/images/user-information2.png" alt="" />
<span>手机号</span>
</div>
<div class="user-information-text">
{{ detail.phonenumber || '暂无' }}
</div>
</div>
<div class="user-information-item">
<div class="user-information-title">
<img src="@/assets/images/user-information3.png" alt="" />
<span>邮箱</span>
</div>
<div class="user-information-text">
{{ detail.email || '暂无' }}
</div>
</div>
<div class="user-information-item">
<div class="user-information-title">
<img src="@/assets/images/user-information4.png" alt="" />
<span>所属部门</span>
</div>
<div class="user-information-text">
{{ detail.organization || '暂无' }}
</div>
</div>
</div>
<div class="user-card-btn">
<el-button
type="info"
@click="clickInitiate"
:loading="initiateLoading"
:disabled="initiateLoading"
>
{{ initiateLoading ? '正在发起邀请' : '发起协作' }}
</el-button>
</div>
</div>
</div>
<div v-else class="message-null">
<el-empty description="暂无内容" />
</div>
</div>
</el-col>
</el-row>
<!-- <el-dialog
v-model="inviteDialog"
title="远程协作"
width="400px"
:close-on-press-escape="false"
:close-on-click-modal="false"
:show-close="false"
>
<div style="width: 100%; margin-bottom: 30px; font-size: 20px">
"
{{
socketInformation.room_name
? socketInformation.room_name
: ''
}}
" 邀请您参加远程协作
</div>
<div style="text-align: center">
<el-button
size="large"
type="danger"
style="font-size: 16px"
@click="clickRefuseJoin"
>
</el-button>
<el-button
size="large"
type="primary"
style="font-size: 16px"
@click="clickJoin"
>
</el-button>
</div>
</el-dialog> -->
</div>
<!-- 文件预览 -->
<BrowseFile ref="browseFileRef" />
</div>
</template>
<script setup>
import { onActivated, onMounted, reactive, toRefs, watch, getCurrentInstance ,ref} from 'vue'
import leftTab from './components/leftTab/index.vue'
import AssistWx from './components/assistWx/index.vue'
import BrowseFile from '@/views/conferencingRoom/components/fileUpload/browseFile.vue'
import { getInfo } from '@/api/login.js'
import { getStatusApi ,getFileListApi ,getvideoUrlApi,getRoomToken,getInvite} from '@/api/conferencingRoom.js'
import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { useRoomStore } from '@/stores/modules/room'
import { useUserStore } from '@/stores/modules/user.js'
import { mqttClient } from "@/utils/mqtt.js";
import { getToken ,getUserInfo} from '@/utils/auth.js';
import { deepClone,parseTime } from '@/utils/ruoyi.js'
const roomStore = useRoomStore()
const userStore = useUserStore()
const router = useRouter()
const { proxy } = getCurrentInstance()
const state = reactive({
detail: {},
weekName: ['周日', '周一', '周二', '周三', '周四', '周五', '周六'],
tabValue: 1,
isShow: true,
isShowLoading: false,
load: false,
loadText: '数据加载中',
isLinkKnow: 'F',
socketInformation: null,
inviteDialog: false,
cooperation: import.meta.env.VITE_APP_COOPERATION_TYPE,
initiateLoading: false,
})
const userLoading = ref(false); // 用户信息加载状态
//文件预览
const browseFileRef = ref(null);
const isEmptyObject = (obj) => {
return !obj || Object.keys(obj).length === 0
}
/** 发起协作邀请 */
const clickInitiate = async () => {
state.initiateLoading = true
let userData = null
try {
try {
userData = JSON.parse(sessionStorage.getItem('userData')) || null
} catch (e) {
console.error('解析 userData 失败:', e)
}
if (isEmptyObject(state.detail)) {
ElMessage({
message: '请先选择人员',
type: 'warning',
})
return
}
if(state.detail.uid == userData.uid){
ElMessage({
message: '不能邀请自己',
type: 'warning',
})
return
}
roomStore.setUserUid(userData.uid)
roomStore.setDetailUid(state.detail.uid)
roomStore.setDetailName(state.detail.name)
//创建房间
const res = await getRoomToken({max_participants: 20});
if(res.meta.code != 200){
ElMessage.error(res.meta.message);
return;
}
if(res.data.room.uid){
await getInvite(res.data.room.uid,{participants:[{user_uid:state.detail.uid,display_name:state.detail.name}], participant_role: "participant"})
}
router.push({
path: '/conferencingRoom',
query:{
room_uid:res.data.room.uid
}
})
} catch (error) {
console.error('发起协作失败:', error)
ElMessage.error('发起协作失败,请重试')
} finally {
// 无论成功失败,都重置按钮状态
state.initiateLoading = false
}
}
/** 修改展示列表 */
const updateTab = (newValue) => {
state.detail = {}
state.tabValue = newValue
}
/** 修改展示区内容 */
const updateDetail = async (details) => {
userLoading.value = true
if (details) {
state.detail = {}
if (state.tabValue == 1) {
state.isShowLoading = true
getTheFileList(details)
} else {
const res = await getInfo(details.uid)
state.detail = res.data
userLoading.value = false
}
}
userLoading.value = false
}
const getTheFileList = async (details) => {
try {
let detail = deepClone(details);
const [fileResponse, videoResponse] = await Promise.all([
getFileListApi(details.room_uid),
getvideoUrlApi(details.room_uid)
]);
const processedDetail = {
...details,
fileList: fileResponse.data?.files || [],
remoteVideoFile: videoResponse.data?.recordings?.[0] || {},
initiator: details.all_participants?.find(item =>
item.participant_role === 'moderator' // 使用严格相等
)?.display_name || '未知发起人'
};
state.detail = processedDetail;
state.isShowLoading = false;
} catch (error) {
console.error('获取文件列表失败:', error);
// 可以根据需要添加错误处理逻辑
}
}
/** 获取通话时长 */
const getTime = () => {
let begin = new Date(state.detail.created_at).getTime()
let end = new Date(state.detail.updated_at).getTime()
if (begin && end) {
let diff = end - begin
const h = Math.floor(diff / (1000 * 60 * 60))
const m = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
const s = Math.floor((diff % (1000 * 60)) / 1000)
return h > 0 ? `${h}小时 ${m}分钟 ${s}`
: m > 0 ? `${m}分钟 ${s}`
: `${s}`
} else {
return '暂无'
}
}
//文件预览
function handlePreview(file) {
if (!file.preview_url) {
ElMessage.error('文件链接无效');
return;
}
browseFileRef.value.showEdit(file)
}
/** 加入会议 */
// const clickJoin = async () => {
// const res = await getStatusApi(state.socketInformation.room_uid,{status:1})
// if(res.meta.code == 200){
// ElMessage({
// message: '成功加入该协作',
// type: 'success',
// })
// state.inviteDialog = false
// router.push({
// path: '/conferencingRoom',
// query:{
// type:2,//创建房间,加入房间 2
// room_uid:state.socketInformation.room_uid
// }
// })
// }
// state.inviteDialog = false
// }
/** 拒绝加入 */
// const clickRefuseJoin = async () => {
// //status 1: 同意加入, 5: 拒绝加入
// const res = await getStatusApi(state.socketInformation.room_uid,{status:5})
// if(res.meta.code == 200){
// ElMessage({
// message: '已拒绝加入该协作',
// type: 'error',
// })
// state.inviteDialog = false
// }
// }
/** 处理加入房间和拒接房间 mqtt 消息 */
// const processingSocket = (message) => {
// const res = JSON.parse(message)
// if (!res?.status) {
// state.socketInformation = res
// state.inviteDialog = true
// showNotification(state.socketInformation)
// }else if(res.status == 5){
// ElMessage({
// message: `${res?.display_name}拒绝加入该协作`,
// type: 'error',
// })
// }
// }
/** 浏览器通知 */
// const showNotification = (data) => {
// if ('Notification' in window) {
// Notification.requestPermission().then((permission) => {
// if (permission === 'granted') {
// const notification = new Notification('协作邀请', {
// // body: String(data.room_name) + '邀请您参加远程协作'
// body: '远程协作有新的邀请'
// // icon: logo,
// })
// notification.onclick = () => { clickJoin() }
// }
// })
// }
// }
// 暴露给模板
const { detail, weekName, tabValue, isShow, load, loadText, isLinkKnow, socketInformation, inviteDialog, cooperation,isShowLoading ,initiateLoading } = toRefs(state)
// onMounted(async () => {
// await mqttClient.connect(`room${Math.random().toString(16).substr(2, 8)}`);
// const res = await userStore.getInfo()
// const topic = `xsynergy/room/+/rooms/${res.uid}`;
// mqttClient.subscribe(topic, async (shapeData) => {
// // console.log(shapeData.toString(),'shapeData发送邀请')
// processingSocket(shapeData.toString())
// });
// })
</script>
<style lang="scss" scoped>
.flex {
display: flex;
justify-content: center;
align-items: center;
}
.app-container {
// padding: 20px;
// margin: 0 17px;
height: calc(100vh - 50px);
overflow: auto;
}
.message-null {
@extend .flex;
height: calc(100vh - 90px);
}
.right-content {
height: calc(100vh - 90px);
background: #f4f9ff;
.right-content-title {
@extend .flex;
justify-content: space-between;
width: 100%;
height: 50px;
padding: 0 20px;
background: #666666;
color: #fff;
font-size: 18px;
}
.right-content-message {
width: calc(100% - 30px);
height: 150px;
margin: 15px;
background: #fff;
border-radius: 10px;
}
.right-content-file {
width: calc(100% - 30px);
height:calc(100% - 230px);
margin: 0 15px 15px;
}
.file-top {
@extend .flex;
justify-content: flex-start;
height: 50px;
padding-left: 20px;
background: #e6f1ff;
color: #333;
font-size: 18px;
}
.content-file-video {
.file-video-bottom {
width: 100%;
height: calc(100vh - 360px);
padding: 15px;
background: #fff;
video {
width: 100%;
height: 100%;
}
}
}
.content-file-list {
background: #fff;
padding: 15px 5px 15px 10px;
.file-list {
width: 100%;
padding-right: 10px;
.file-list-content {
@extend .flex;
flex-direction: column;
width: 100%;
.file-list-item {
@extend .flex;
width: 100%;
margin-bottom: 15px;
.file-list-item-icon {
width: 13px;
height: 13px;
margin-right: 10px;
border-radius: 50%;
background: #89b2ff;
}
.file-list-item-text {
@extend .flex;
justify-content: space-between;
width: calc(100% - 23px);
padding: 13px 6px 13px 6px;
border-radius: 4px;
background: #f4f9ff;
.list-item-text {
display: inline-block;
width: calc(100% - 50px);
margin-right: 2px;
}
}
}
}
}
}
}
.message-user {
@extend .flex;
overflow: auto;
.message-user-card {
@extend .flex;
flex-direction: column;
width: 500px;
height: 95%;
max-height: 550px;
background: #fff;
border-radius: 10px;
overflow: hidden;
.user-card-nickName {
@extend .flex;
flex-direction: column;
width: 100%;
height: 40%;
background: linear-gradient(
180deg,
rgba(153, 153, 153, 0.22) 0%,
rgba(153, 153, 153, 0) 100%
);
img {
width: 100px;
height: 100px;
margin-top: 10%;
margin-bottom: 20px;
border-radius: 50%;
background: #167bff;
flex-shrink: 0;
}
span {
color: #051435;
font-size: 24px;
}
}
.user-card-information {
@extend .flex;
flex-direction: column;
justify-content: space-around;
width: 100%;
height: 45%;
.user-information-item {
@extend .flex;
justify-content: space-between;
.user-information-title {
@extend .flex;
justify-content: flex-start;
width: 150px;
img {
width: 24px;
height: 24px;
margin-right: 20px;
}
span {
color: #999;
font-size: 16px;
}
}
.user-information-text {
width: 130px;
color: #333333;
font-size: 16px;
font-weight: 600;
}
}
}
.user-card-btn {
@extend .flex;
width: 100%;
height: 15%;
}
}
}
.video-null {
color: #999;
font-size: 22px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
::v-deep .user-card-btn .el-button {
width: 70%;
height: 55%;
border-radius: 25px;
font-size: 18px;
color: #fff;
}
</style>