Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
Y
yd-middle-front
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
1
Merge Requests
1
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
xingmin
yd-middle-front
Commits
d23ea9b2
Commit
d23ea9b2
authored
Aug 04, 2026
by
zhangxingmin
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
push
parent
7a6969cd
Hide whitespace changes
Inline
Side-by-side
Showing
3 changed files
with
1461 additions
and
0 deletions
+1461
-0
src/api/system/report.js
+118
-0
src/store/modules/user.js
+3
-0
src/views/system/report/index.vue
+1340
-0
No files found.
src/api/system/report.js
0 → 100644
View file @
d23ea9b2
import
request
from
'@/utils/request'
/**
* 查询报告列表(模拟接口)
* 若真实接口存在,替换 url 即可
*/
export
function
listReport
(
query
)
{
return
request
({
url
:
'/system/report/list'
,
method
:
'get'
,
params
:
query
})
}
/**
* 发起大屏讲解(创建协同会话)
*/
export
function
createCoSession
(
data
)
{
return
request
({
url
:
'/communication/api/coSession/create'
,
method
:
'post'
,
data
:
data
})
}
/**
* 结束协同会话
*/
export
function
endCoSession
(
data
)
{
return
request
({
url
:
'/communication/api/coSession/end'
,
method
:
'post'
,
data
:
data
})
}
/**
* 切换控制权(顾问调用)
*/
export
function
transferControl
(
data
)
{
return
request
({
url
:
'/communication/api/coSession/control/transfer'
,
method
:
'post'
,
data
:
data
})
}
/**
* 加入协同会话(顾问调用)
*/
export
function
joinCoSession
(
data
)
{
return
request
({
url
:
'/communication/api/coSession/join'
,
method
:
'post'
,
data
:
data
})
}
// ==================== 录制相关接口 ====================
/**
* 开始录制
* @param {Object} data - { bizType, bizId }
*/
export
function
startRecording
(
data
)
{
return
request
({
url
:
'/communication/api/recordingTask/start'
,
method
:
'post'
,
data
:
data
})
}
/**
* 停止录制并上传视频
* @param {string} taskId - 录制任务ID
* @param {File} file - 视频文件
*/
export
function
stopRecording
(
taskId
,
file
)
{
const
formData
=
new
FormData
()
formData
.
append
(
'taskId'
,
taskId
)
formData
.
append
(
'file'
,
file
)
return
request
({
url
:
'/communication/api/recordingTask/stop'
,
method
:
'post'
,
data
:
formData
,
headers
:
{
'Content-Type'
:
'multipart/form-data'
}
})
}
/**
* 查询录制信息
* @param {string} taskId - 录制任务ID
*/
export
function
queryRecording
(
taskId
)
{
return
request
({
url
:
`/communication/api/recordingTask/query/
${
taskId
}
`
,
method
:
'get'
})
}
/**
* 获取生效的脱敏规则列表
* @param {string} resourceType - 资源类型,如 'report'
* @param {string} resourceId - 资源ID,如 'RPT-DEMO'
*/
export
function
getDesensitizationRules
(
resourceType
,
resourceId
)
{
return
request
({
url
:
'/communication/api/desensitization/enabled'
,
method
:
'get'
,
params
:
{
resourceType
:
resourceType
,
resourceBizId
:
resourceId
}
})
}
\ No newline at end of file
src/store/modules/user.js
View file @
d23ea9b2
...
@@ -10,6 +10,7 @@ const useUserStore = defineStore('user', {
...
@@ -10,6 +10,7 @@ const useUserStore = defineStore('user', {
state
:
()
=>
({
state
:
()
=>
({
token
:
getToken
(),
token
:
getToken
(),
id
:
''
,
id
:
''
,
userBizId
:
''
,
name
:
''
,
name
:
''
,
nickName
:
''
,
nickName
:
''
,
avatar
:
''
,
avatar
:
''
,
...
@@ -31,6 +32,7 @@ const useUserStore = defineStore('user', {
...
@@ -31,6 +32,7 @@ const useUserStore = defineStore('user', {
.
then
(
res
=>
{
.
then
(
res
=>
{
setToken
(
res
.
data
.
token
)
setToken
(
res
.
data
.
token
)
this
.
token
=
res
.
data
.
token
this
.
token
=
res
.
data
.
token
this
.
userBizId
=
res
.
data
.
userBizId
resolve
()
resolve
()
})
})
.
catch
(
error
=>
{
.
catch
(
error
=>
{
...
@@ -70,6 +72,7 @@ const useUserStore = defineStore('user', {
...
@@ -70,6 +72,7 @@ const useUserStore = defineStore('user', {
}
}
this
.
id
=
user
.
userId
this
.
id
=
user
.
userId
this
.
userBizId
=
user
.
userBizId
this
.
name
=
user
.
userName
this
.
name
=
user
.
userName
this
.
nickName
=
user
.
nickName
this
.
nickName
=
user
.
nickName
this
.
avatar
=
avatar
this
.
avatar
=
avatar
...
...
src/views/system/report/index.vue
0 → 100644
View file @
d23ea9b2
<
template
>
<div
class=
"reader-container"
>
<!-- 顶部工具栏 -->
<div
class=
"reader-header"
>
<div
class=
"header-left"
>
<el-button
link
type=
"primary"
@
click=
"goBack"
icon=
"ArrowLeft"
>
返回
</el-button>
<span
class=
"role-badge"
:class=
"role === 'customer' ? 'customer' : 'consultant'"
>
{{
role
===
'customer'
?
'👤 客户'
:
'👤 顾问'
}}
</span>
</div>
<div
class=
"header-center"
>
<span
class=
"title"
>
{{
currentArticle
?.
title
||
'加载中...'
}}
</span>
<span
class=
"page-info"
>
第
{{
currentPage
+
1
}}
/
{{
articles
.
length
}}
篇
</span>
</div>
<div
class=
"header-right"
>
<el-badge
:value=
"recordingStatus === 'recording' ? '录制中' : recordingStatus === 'stopped' ? '已录制' : '未录制'"
:type=
"recordingStatus === 'recording' ? 'danger' : recordingStatus === 'stopped' ? 'success' : 'info'"
>
<el-button
link
type=
"primary"
icon=
"VideoCamera"
@
click=
"toggleRecording"
/>
</el-badge>
<el-badge
:value=
"wsConnected ? '在线' : '离线'"
:type=
"wsConnected ? 'success' : 'danger'"
>
<el-button
link
type=
"primary"
icon=
"Connection"
/>
</el-badge>
</div>
</div>
<!-- 文章内容区域 -->
<div
class=
"reader-body"
ref=
"readerBodyRef"
>
<!-- 顾问且正在接收屏幕流时显示视频 -->
<div
v-if=
"role === 'consultant' && isReceivingScreen"
class=
"video-container"
>
<video
ref=
"remoteVideoRef"
autoplay
playsinline
></video>
<!-- 视频上方的控制覆盖层 -->
<div
class=
"controls-overlay"
>
<div
class=
"nav-controls"
>
<button
class=
"nav-btn"
:class=
"
{ disabled: !canOperate }" @click="prevPage" :disabled="!canOperate">
<span>
◀
</span>
</button>
<span
class=
"page-text"
>
{{
currentPage
+
1
}}
/
{{
articles
.
length
}}
</span>
<button
class=
"nav-btn"
:class=
"
{ disabled: !canOperate }" @click="nextPage" :disabled="!canOperate">
<span>
▶
</span>
</button>
</div>
<div
class=
"zoom-controls-overlay"
>
<button
class=
"zoom-btn"
@
click=
"zoomOut"
:disabled=
"!canOperate"
>
−
</button>
<span>
{{
Math
.
round
(
zoomLevel
*
100
)
}}
%
</span>
<button
class=
"zoom-btn"
@
click=
"zoomIn"
:disabled=
"!canOperate"
>
+
</button>
</div>
<div
class=
"status-tip"
v-if=
"!canOperate"
>
<span>
🔒 顾问正在操作
</span>
</div>
</div>
</div>
<!-- 否则显示文章内容 -->
<div
v-else
class=
"article-wrapper"
:style=
"
{ transform: `scale(${zoomLevel})`, transformOrigin: 'top left' }">
<div
class=
"article-content"
ref=
"articleContentRef"
v-html=
"currentArticle?.content || ''"
></div>
</div>
</div>
<!-- 底部翻页控制(客户可见,顾问在视频模式下隐藏) -->
<div
class=
"reader-footer"
v-if=
"!(role === 'consultant' && isReceivingScreen)"
>
<el-button
type=
"primary"
plain
circle
icon=
"ArrowLeft"
@
click=
"prevPage"
:disabled=
"!canOperate"
/>
<el-slider
v-model=
"currentPage"
:min=
"0"
:max=
"articles.length - 1"
:show-tooltip=
"false"
style=
"flex:1; margin:0 16px;"
@
change=
"onSliderChange"
:disabled=
"!canOperate"
/>
<el-button
type=
"primary"
plain
circle
icon=
"ArrowRight"
@
click=
"nextPage"
:disabled=
"!canOperate"
/>
</div>
<!-- 缩放控制(客户可见,顾问在视频模式下隐藏) -->
<div
class=
"zoom-controls"
v-if=
"!(role === 'consultant' && isReceivingScreen)"
>
<el-button
type=
"primary"
plain
circle
size=
"small"
@
click=
"zoomOut"
:disabled=
"!canOperate || zoomLevel
<
=
0
.
5
"
>
−
</el-button>
<span
class=
"zoom-level"
>
{{
Math
.
round
(
zoomLevel
*
100
)
}}
%
</span>
<el-button
type=
"primary"
plain
circle
size=
"small"
@
click=
"zoomIn"
:disabled=
"!canOperate || zoomLevel >= 2.0"
>
+
</el-button>
</div>
<!-- ★★★ 右下角悬浮按钮组 ★★★ -->
<div
class=
"fab-group"
>
<!-- 客户按钮组 -->
<template
v-if=
"role === 'customer'"
>
<!-- 开始共享 / 结束共享 -->
<el-badge
:value=
"sharingStatus === '讲解中' ? '讲解中' : '未开始'"
:type=
"sharingStatus === '讲解中' ? 'success' : 'info'"
>
<el-button
v-if=
"sharingStatus !== '讲解中'"
class=
"fab-btn fab-share"
type=
"primary"
circle
size=
"large"
@
click=
"startSharing"
>
<span
class=
"fab-btn-text"
>
开始
<br>
共享
</span>
</el-button>
<el-button
v-else
class=
"fab-btn fab-end"
type=
"danger"
circle
size=
"large"
@
click=
"handleEndSharing"
>
<span
class=
"fab-btn-text"
>
结束
<br>
共享
</span>
</el-button>
</el-badge>
<!-- 脱敏开关 -->
<el-badge
:value=
"desensitizationEnabled ? '已脱敏' : '未脱敏'"
:type=
"desensitizationEnabled ? 'success' : 'info'"
>
<el-button
class=
"fab-btn fab-desensitize"
type=
"primary"
circle
size=
"large"
@
click=
"toggleDesensitization"
>
<span
class=
"fab-btn-text"
>
脱敏
</span>
</el-button>
</el-badge>
</
template
>
<!-- 顾问按钮组 -->
<
template
v-if=
"role === 'consultant'"
>
<el-badge
:value=
"sharingStatus === '讲解中' ? '已加入' : '未加入'"
:type=
"sharingStatus === '讲解中' ? 'success' : 'info'"
>
<!-- 未加入时显示“加入” -->
<el-button
v-if=
"sharingStatus !== '讲解中'"
class=
"fab-btn fab-join"
type=
"primary"
circle
size=
"large"
@
click=
"joinDialogVisible = true"
>
<span
class=
"fab-btn-text"
>
加入
</span>
</el-button>
<!-- 已加入时显示“授权客户”或“收回授权” -->
<el-button
v-else
class=
"fab-btn"
:class=
"isController ? 'fab-authorize' : 'fab-revoke'"
type=
"primary"
circle
size=
"large"
@
click=
"toggleControl"
>
<span
class=
"fab-btn-text"
>
{{
isController
?
'授权
\
n客户'
:
'收回
\
n授权'
}}
</span>
</el-button>
</el-badge>
</
template
>
</div>
<!-- 共享信息弹窗 -->
<el-dialog
title=
"大屏讲解"
v-model=
"shareDialogVisible"
width=
"400px"
append-to-body
>
<div
style=
"text-align: center; padding: 16px 0;"
>
<el-alert
title=
"请将以下信息分享给顾问"
type=
"success"
:closable=
"false"
style=
"margin-bottom: 16px;"
/>
<div
style=
"margin-bottom: 16px;"
>
<div
style=
"font-size: 14px; color: #666;"
>
共享码
</div>
<div
style=
"font-size: 28px; font-weight: bold; letter-spacing: 6px; color: #1890ff;"
>
{{ shareInfo.roomPwd || '------' }}
</div>
<el-button
size=
"small"
type=
"primary"
plain
@
click=
"copyCode"
style=
"margin-top: 8px;"
>
复制
</el-button>
</div>
<div>
<div
style=
"font-size: 14px; color: #666;"
>
房间号
</div>
<div
style=
"font-size: 16px; font-weight: 500; color: #333;"
>
{{ shareInfo.roomId || '------' }}
</div>
</div>
</div>
<
template
#
footer
>
<el-button
type=
"primary"
@
click=
"shareDialogVisible = false; connectAsCustomer()"
>
开始协同
</el-button>
</
template
>
</el-dialog>
<!-- 顾问加入对话框 -->
<el-dialog
title=
"加入协同"
v-model=
"joinDialogVisible"
width=
"400px"
append-to-body
>
<el-form
:model=
"joinForm"
label-width=
"80px"
>
<el-form-item
label=
"共享码"
>
<el-input
v-model=
"joinForm.roomPwd"
placeholder=
"请输入6位共享码"
maxlength=
"6"
/>
</el-form-item>
<el-form-item
label=
"房间号"
>
<el-input
v-model=
"joinForm.roomId"
placeholder=
"请输入房间号"
/>
</el-form-item>
</el-form>
<
template
#
footer
>
<el-button
type=
"primary"
@
click=
"submitJoin"
>
加入
</el-button>
<el-button
@
click=
"joinDialogVisible = false"
>
取消
</el-button>
</
template
>
</el-dialog>
</div>
</template>
<
script
setup
name=
"ReportReader"
>
import
{
ref
,
reactive
,
computed
,
onMounted
,
onBeforeUnmount
,
getCurrentInstance
,
nextTick
}
from
'vue'
import
{
useRoute
,
useRouter
}
from
'vue-router'
import
{
ElMessage
}
from
'element-plus'
import
{
createCoSession
,
endCoSession
,
joinCoSession
,
startRecording
,
stopRecording
,
queryRecording
,
getDesensitizationRules
}
from
'@/api/system/report'
import
useUserStore
from
'@/store/modules/user'
import
{
getToken
}
from
'@/utils/auth'
console
.
log
(
'【协同报告阅读器】组件开始加载'
)
const
{
proxy
}
=
getCurrentInstance
()
const
userStore
=
useUserStore
()
const
route
=
useRoute
()
const
router
=
useRouter
()
const
role
=
ref
(
route
.
query
.
role
||
'customer'
)
console
.
log
(
'【角色】当前身份:'
,
role
.
value
)
// ========== 文章数据 ==========
const
articles
=
reactive
([
{
title
:
'2026年宏观经济展望'
,
content
:
`
<h2>2026年宏观经济展望</h2>
<p>2026年全球经济呈现复苏态势,主要经济体政策趋于宽松。国际货币基金组织(IMF)预测全球GDP增长3.2%,其中新兴市场表现亮眼。</p>
<div class="customer-info">
<p><strong>客户姓名:</strong><span class="desensitize-name">张三丰</span></p>
<p><strong>联系电话:</strong><span class="desensitize-phone">13812345678</span></p>
</div>
<img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='200' viewBox='0 0 400 200'%3E%3Crect width='400' height='200' fill='%23f0f2f5'/%3E%3Ctext x='50%25' y='50%25' font-size='20' text-anchor='middle' fill='%23999'%3E📈 经济走势图%3C/text%3E%3C/svg%3E" alt="经济走势" style="max-width:100%; border-radius:8px; margin:12px 0;" />
<p>在通胀压力缓解的背景下,美联储预计将于2026年三季度启动降息周期。欧洲央行紧随其后,维持宽松立场。亚洲地区,中国和印度继续成为全球增长引擎。</p>
<p>值得注意的是,地缘政治风险依然存在,能源价格波动可能对复苏构成扰动。企业需关注供应链多元化布局。</p>
`
},
{
title
:
'人工智能与产业变革'
,
content
:
`
<h2>人工智能与产业变革</h2>
<p>人工智能(AI)正以前所未有的速度渗透各行各业。2026年,生成式AI已广泛应用于内容创作、客户服务和研发设计。</p>
<div class="customer-info">
<p><strong>客户姓名:</strong><span class="desensitize-name">李思思</span></p>
<p><strong>联系电话:</strong><span class="desensitize-phone">15987654321</span></p>
</div>
<img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='200' viewBox='0 0 400 200'%3E%3Crect width='400' height='200' fill='%23f0f2f5'/%3E%3Ctext x='50%25' y='50%25' font-size='20' text-anchor='middle' fill='%23999'%3E🤖 AI应用场景%3C/text%3E%3C/svg%3E" alt="AI应用" style="max-width:100%; border-radius:8px; margin:12px 0;" />
<p>据Gartner预测,到2027年,70%的企业将部署AI助手。金融行业率先采用AI进行风险评估和智能投顾,效率提升超过40%。</p>
<p>然而,AI也带来数据隐私和就业结构调整的挑战。监管部门正加紧制定AI伦理框架,确保技术向善。</p>
`
},
{
title
:
'绿色能源投资新机遇'
,
content
:
`
<h2>绿色能源投资新机遇</h2>
<p>全球碳中和目标驱动绿色能源投资爆发式增长。2026年,可再生能源投资预计达到1.2万亿美元,创历史新高。</p>
<div class="customer-info">
<p><strong>客户姓名:</strong><span class="desensitize-name">王小明</span></p>
<p><strong>联系电话:</strong><span class="desensitize-phone">13612349876</span></p>
</div>
<img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='200' viewBox='0 0 400 200'%3E%3Crect width='400' height='200' fill='%23f0f2f5'/%3E%3Ctext x='50%25' y='50%25' font-size='20' text-anchor='middle' fill='%23999'%3E🌱 新能源投资%3C/text%3E%3C/svg%3E" alt="绿色能源" style="max-width:100%; border-radius:8px; margin:12px 0;" />
<p>光伏和风电成本持续下降,储能技术突破使可再生能源稳定性大幅提升。氢能成为新的投资热点,多个国家公布氢能战略。</p>
<p>在政策利好和技术成熟的双轮驱动下,绿色金融产品创新不断,ESG投资理念深入人心。</p>
`
},
{
title
:
'消费市场趋势洞察'
,
content
:
`
<h2>消费市场趋势洞察</h2>
<p>2026年消费市场呈现"理性务实"和"体验升级"双重特征。Z世代成为消费主力,注重个性化与社交属性。</p>
<div class="customer-info">
<p><strong>客户姓名:</strong><span class="desensitize-name">赵丽颖</span></p>
<p><strong>联系电话:</strong><span class="desensitize-phone">13800008888</span></p>
</div>
<img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='200' viewBox='0 0 400 200'%3E%3Crect width='400' height='200' fill='%23f0f2f5'/%3E%3Ctext x='50%25' y='50%25' font-size='20' text-anchor='middle' fill='%23999'%3E🛍️ 消费趋势%3C/text%3E%3C/svg%3E" alt="消费趋势" style="max-width:100%; border-radius:8px; margin:12px 0;" />
<p>电商领域,直播带货和社交电商持续增长,而线下零售通过数字化改造实现复苏。国潮品牌崛起,文化自信驱动国货消费。</p>
<p>健康消费成为新蓝海,功能性食品、智能健身设备需求激增。同时,可持续消费理念影响购买决策。</p>
`
},
{
title
:
'数字化转型实战指南'
,
content
:
`
<h2>数字化转型实战指南</h2>
<p>数字化转型已从"选修课"变为"必修课"。2026年,企业数字化成熟度成为核心竞争力。</p>
<div class="customer-info">
<p><strong>客户姓名:</strong><span class="desensitize-name">刘德华</span></p>
<p><strong>联系电话:</strong><span class="desensitize-phone">18912348888</span></p>
</div>
<img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='200' viewBox='0 0 400 200'%3E%3Crect width='400' height='200' fill='%23f0f2f5'/%3E%3Ctext x='50%25' y='50%25' font-size='20' text-anchor='middle' fill='%23999'%3E💻 数字化转型%3C/text%3E%3C/svg%3E" alt="数字化" style="max-width:100%; border-radius:8px; margin:12px 0;" />
<p>成功转型的企业注重顶层设计、数据治理和人才梯队建设。云原生、AI和大数据分析成为核心技术底座。</p>
<p>本指南提供从战略规划到实施落地的全流程框架,助您把握数字化时代的先机。</p>
`
}
])
// ========== 页面状态 ==========
const
currentPage
=
ref
(
0
)
const
zoomLevel
=
ref
(
1.0
)
const
scrollRatio
=
ref
(
0
)
const
desensitizationEnabled
=
ref
(
false
)
const
readerBodyRef
=
ref
(
null
)
const
articleContentRef
=
ref
(
null
)
const
originalContentMap
=
new
Map
()
// ========== 协同状态 ==========
const
sharingStatus
=
ref
(
'未共享'
)
const
shareDialogVisible
=
ref
(
false
)
const
joinDialogVisible
=
ref
(
false
)
const
wsConnected
=
ref
(
false
)
const
isController
=
ref
(
false
)
const
shareInfo
=
reactive
({
roomId
:
''
,
roomPwd
:
''
,
sessionBizId
:
''
,
ownerId
:
''
})
const
joinForm
=
reactive
({
roomId
:
''
,
roomPwd
:
''
})
let
ws
=
null
let
wsReconnectTimer
=
null
let
shouldReconnect
=
true
// ========== 录制相关状态 ==========
const
recordingStatus
=
ref
(
'idle'
)
const
recordingTaskId
=
ref
(
null
)
const
mediaRecorder
=
ref
(
null
)
const
recordedChunks
=
ref
([])
const
recordingStream
=
ref
(
null
)
const
recordingStartTime
=
ref
(
null
)
const
recordingDuration
=
ref
(
0
)
let
durationTimer
=
null
const
recordedFileUrl
=
ref
(
''
)
const
uploading
=
ref
(
false
)
// ========== WebRTC 相关 ==========
let
pc
=
null
const
remoteVideoRef
=
ref
(
null
)
const
isReceivingScreen
=
ref
(
false
)
// ★ 共享流(全局)
let
sharedStream
=
null
// ========== 计算属性 ==========
const
currentArticle
=
computed
(()
=>
articles
[
currentPage
.
value
]
||
articles
[
0
])
const
canOperate
=
computed
(()
=>
wsConnected
.
value
&&
isController
.
value
)
// ========== 页面导航 ==========
function
goBack
()
{
router
.
push
(
'/system/report'
)
}
// ========== 翻页、滚动、缩放 ==========
function
prevPage
()
{
if
(
canOperate
.
value
&&
currentPage
.
value
>
0
)
{
changePage
(
currentPage
.
value
-
1
)
}
}
function
nextPage
()
{
if
(
canOperate
.
value
&&
currentPage
.
value
<
articles
.
length
-
1
)
{
changePage
(
currentPage
.
value
+
1
)
}
}
function
onSliderChange
(
val
)
{
if
(
!
canOperate
.
value
)
return
changePage
(
val
)
nextTick
(()
=>
{
sendSyncAction
(
'turn_the_page'
,
0
)
})
}
function
zoomIn
()
{
if
(
canOperate
.
value
&&
zoomLevel
.
value
<
2.0
)
{
zoomLevel
.
value
=
Math
.
min
(
2.0
,
zoomLevel
.
value
+
0.1
)
sendSyncAction
(
'scaling'
,
zoomLevel
.
value
)
}
}
function
zoomOut
()
{
if
(
canOperate
.
value
&&
zoomLevel
.
value
>
0.5
)
{
zoomLevel
.
value
=
Math
.
max
(
0.5
,
zoomLevel
.
value
-
0.1
)
sendSyncAction
(
'scaling'
,
zoomLevel
.
value
)
}
}
function
changePage
(
newPage
)
{
if
(
newPage
<
0
||
newPage
>=
articles
.
length
)
return
currentPage
.
value
=
newPage
if
(
desensitizationEnabled
.
value
)
{
applyDesensitization
()
}
else
{
restoreOriginalContent
()
}
}
// ========== 控制权切换 ==========
function
toggleControl
()
{
if
(
role
.
value
!==
'consultant'
)
{
ElMessage
.
warning
(
'只有顾问可以切换控制权'
)
return
}
if
(
!
wsConnected
.
value
)
{
ElMessage
.
warning
(
'未连接协同'
)
return
}
const
targetHolderType
=
isController
.
value
?
'owner'
:
'participant'
isController
.
value
=
!
isController
.
value
const
msg
=
{
action
:
'control_transfer'
,
holderType
:
targetHolderType
}
ws
.
send
(
JSON
.
stringify
(
msg
))
console
.
log
(
'【控制权切换】发送:'
,
msg
)
}
// ========== 客户发起共享 ==========
async
function
startSharing
()
{
const
userId
=
userStore
.
userBizId
||
'admin'
const
token
=
getToken
()
||
'demo-token'
const
ownerType
=
role
.
value
===
'customer'
?
'customer'
:
'consultant'
try
{
const
res
=
await
createCoSession
({
scope
:
'single'
,
resourceType
:
'report'
,
resourceId
:
'RPT-DEMO'
,
resourceInit
:
JSON
.
stringify
({
url
:
window
.
location
.
href
,
title
:
currentArticle
.
value
.
title
,
page_type
:
'article'
,
data
:
{
currentPage
:
currentPage
.
value
,
totalPages
:
articles
.
length
,
scrollRatio
:
scrollRatio
.
value
,
zoomLevel
:
zoomLevel
.
value
,
desensitizationEnabled
:
desensitizationEnabled
.
value
,
pageTitle
:
currentArticle
.
value
.
title
,
chartIndices
:
[]
}
}),
ownerId
:
userId
,
ownerType
:
ownerType
,
userId
:
userId
,
token
:
token
})
if
(
res
.
code
===
200
)
{
const
data
=
res
.
data
shareInfo
.
roomId
=
data
.
roomId
shareInfo
.
roomPwd
=
data
.
roomPwd
shareInfo
.
sessionBizId
=
data
.
sessionBizId
shareInfo
.
ownerId
=
userId
sharingStatus
.
value
=
'讲解中'
shareDialogVisible
.
value
=
true
ElMessage
.
success
(
'大屏讲解已开启,请分享共享码'
)
}
else
{
ElMessage
.
error
(
res
.
msg
||
'开启失败'
)
}
}
catch
(
e
)
{
shareInfo
.
roomId
=
'room_'
+
Math
.
random
().
toString
(
36
).
substr
(
2
,
8
)
shareInfo
.
roomPwd
=
Math
.
random
().
toString
().
slice
(
2
,
8
)
shareInfo
.
sessionBizId
=
'sess_'
+
Date
.
now
()
shareInfo
.
ownerId
=
userId
sharingStatus
.
value
=
'讲解中'
shareDialogVisible
.
value
=
true
ElMessage
.
success
(
'(模拟)大屏讲解已开启'
)
}
}
// ========== 客户连接 WebSocket ==========
function
connectAsCustomer
()
{
if
(
!
shareInfo
.
roomId
)
return
shouldReconnect
=
true
connectWebSocket
(
shareInfo
.
roomId
,
userStore
.
userBizId
||
'CUST-001'
,
'owner'
)
}
// ========== 顾问加入 ==========
async
function
submitJoin
()
{
if
(
!
joinForm
.
roomPwd
||
!
joinForm
.
roomId
)
{
ElMessage
.
warning
(
'请填写完整信息'
)
return
}
try
{
const
res
=
await
joinCoSession
({
roomId
:
joinForm
.
roomId
,
roomPwd
:
joinForm
.
roomPwd
,
participantId
:
userStore
.
userBizId
||
'CONS-001'
,
participantType
:
'consultant'
})
if
(
res
.
code
===
200
)
{
if
(
res
.
data
?.
sessionBizId
)
shareInfo
.
sessionBizId
=
res
.
data
.
sessionBizId
if
(
res
.
data
?.
ownerId
)
shareInfo
.
ownerId
=
res
.
data
.
ownerId
sharingStatus
.
value
=
'讲解中'
ElMessage
.
success
(
'加入成功'
)
joinDialogVisible
.
value
=
false
shouldReconnect
=
true
connectWebSocket
(
joinForm
.
roomId
,
userStore
.
userBizId
||
'CONS-001'
,
'participant'
,
true
)
}
else
{
ElMessage
.
error
(
res
.
msg
||
'加入失败'
)
}
}
catch
(
error
)
{
console
.
error
(
'【join】接口异常:'
,
error
)
ElMessage
.
error
(
'加入失败,请稍后重试'
)
}
}
// ========== WebSocket 连接 ==========
function
connectWebSocket
(
roomId
,
userId
,
userType
,
sendControl
=
false
)
{
if
(
ws
)
{
try
{
ws
.
close
()
}
catch
(
e
)
{};
ws
=
null
}
if
(
wsReconnectTimer
)
clearTimeout
(
wsReconnectTimer
)
const
wsUrl
=
`ws://139.224.145.34:9482/communication/api/ws/
${
roomId
}
?userId=
${
userId
}
&userType=
${
userType
}
`
console
.
log
(
'【WebSocket】连接地址:'
,
wsUrl
)
ws
=
new
WebSocket
(
wsUrl
)
ws
.
onopen
=
()
=>
{
wsConnected
.
value
=
true
ElMessage
.
success
(
'WebSocket 已连接'
)
if
(
role
.
value
===
'customer'
)
{
startWebRTCScreenShare
(
roomId
)
}
if
(
sendControl
&&
role
.
value
===
'consultant'
)
{
ws
.
send
(
JSON
.
stringify
({
action
:
'control_transfer'
,
holderType
:
'participant'
}))
console
.
log
(
'【顾问】发送 control_transfer 同步消息'
)
}
}
ws
.
onmessage
=
(
event
)
=>
{
try
{
handleWebSocketMessage
(
JSON
.
parse
(
event
.
data
))
}
catch
(
e
)
{
console
.
error
(
'解析消息失败:'
,
e
)
}
}
ws
.
onclose
=
()
=>
{
wsConnected
.
value
=
false
closeWebRTC
()
if
(
shouldReconnect
)
{
ElMessage
.
warning
(
'WebSocket 断开,尝试重连...'
)
if
(
wsReconnectTimer
)
clearTimeout
(
wsReconnectTimer
)
wsReconnectTimer
=
setTimeout
(()
=>
connectWebSocket
(
roomId
,
userId
,
userType
),
3000
)
}
else
{
console
.
log
(
'【WebSocket】主动关闭,不再重连'
)
shouldReconnect
=
true
}
}
ws
.
onerror
=
(
err
)
=>
{
console
.
error
(
'WebSocket 错误:'
,
err
);
wsConnected
.
value
=
false
}
}
// ========== WebRTC 屏幕共享(客户)并复用流给录制 ==========
async
function
startWebRTCScreenShare
(
roomId
)
{
try
{
if
(
pc
)
{
pc
.
close
();
pc
=
null
}
// ★ 获取屏幕流(只弹一次)
const
stream
=
await
navigator
.
mediaDevices
.
getDisplayMedia
({
video
:
{
displaySurface
:
'browser'
,
frameRate
:
{
ideal
:
30
},
cursor
:
'always'
},
audio
:
true
})
// 保存全局流供录制复用
sharedStream
=
stream
// ★ 立即启动录制(复用同一个流)
if
(
shareInfo
.
sessionBizId
)
{
startRecordingWithStream
(
stream
)
}
else
{
console
.
warn
(
'【录制】sessionBizId 未就绪,稍后重试'
)
}
// 创建 PeerConnection
pc
=
new
RTCPeerConnection
({
iceServers
:
[{
urls
:
'stun:stun.l.google.com:19302'
}]
})
stream
.
getTracks
().
forEach
(
track
=>
pc
.
addTrack
(
track
,
stream
))
pc
.
onicecandidate
=
(
event
)
=>
{
if
(
event
.
candidate
)
{
ws
.
send
(
JSON
.
stringify
({
action
:
'webrtc_ice_candidate'
,
roomId
,
candidate
:
event
.
candidate
}))
}
}
pc
.
onconnectionstatechange
=
()
=>
{
console
.
log
(
'【WebRTC】连接状态:'
,
pc
.
connectionState
)
if
(
pc
.
connectionState
===
'connected'
)
{
ElMessage
.
success
(
'屏幕共享已连接'
)
}
else
if
(
pc
.
connectionState
===
'disconnected'
||
pc
.
connectionState
===
'failed'
)
{
ElMessage
.
warning
(
'屏幕共享连接中断'
)
}
}
const
offer
=
await
pc
.
createOffer
()
await
pc
.
setLocalDescription
(
offer
)
ws
.
send
(
JSON
.
stringify
({
action
:
'webrtc_offer'
,
roomId
,
sdp
:
offer
}))
console
.
log
(
'【WebRTC】屏幕共享已启动'
)
}
catch
(
error
)
{
console
.
error
(
'【WebRTC】启动屏幕共享失败:'
,
error
)
ElMessage
.
error
(
'启动屏幕共享失败: '
+
error
.
message
)
if
(
pc
)
{
pc
.
close
();
pc
=
null
}
}
}
// ========== 录制功能(复用共享流) ==========
async
function
startRecordingWithStream
(
stream
)
{
try
{
if
(
recordingStatus
.
value
===
'recording'
)
return
if
(
!
shareInfo
.
sessionBizId
)
{
ElMessage
.
warning
(
'未获取到协同会话ID,无法开始录制'
)
return
}
const
startRes
=
await
startRecording
({
bizType
:
'co_session'
,
bizId
:
shareInfo
.
sessionBizId
})
if
(
startRes
.
code
!==
200
)
{
ElMessage
.
error
(
startRes
.
msg
||
'创建录制任务失败'
)
return
}
recordingTaskId
.
value
=
startRes
.
data
console
.
log
(
'【录制】任务创建成功,taskId:'
,
recordingTaskId
.
value
)
// 直接使用传入的流
const
mimeTypes
=
[
'video/webm;codecs=vp9'
,
'video/webm;codecs=vp8'
,
'video/webm'
,
'video/mp4'
]
let
mimeType
=
mimeTypes
.
find
(
type
=>
MediaRecorder
.
isTypeSupported
(
type
))
||
''
const
recorder
=
new
MediaRecorder
(
stream
,
{
mimeType
,
videoBitsPerSecond
:
2500000
})
recordedChunks
.
value
=
[]
recorder
.
ondataavailable
=
(
event
)
=>
{
if
(
event
.
data
&&
event
.
data
.
size
>
0
)
recordedChunks
.
value
.
push
(
event
.
data
)
}
recorder
.
onstop
=
()
=>
console
.
log
(
'【录制】MediaRecorder 停止,共'
,
recordedChunks
.
value
.
length
,
'个数据块'
)
recorder
.
onerror
=
(
event
)
=>
{
console
.
error
(
'【录制】MediaRecorder 错误:'
,
event
);
ElMessage
.
error
(
'录制发生错误'
)
}
// 监听流结束(用户点击停止共享时,自动停止录制)
if
(
stream
)
{
const
videoTrack
=
stream
.
getVideoTracks
()[
0
]
if
(
videoTrack
)
{
videoTrack
.
addEventListener
(
'ended'
,
()
=>
{
if
(
recordingStatus
.
value
===
'recording'
)
{
ElMessage
.
warning
(
'屏幕共享被中断,录制将停止'
)
stopRecordingAndUpload
()
}
})
}
}
mediaRecorder
.
value
=
recorder
recorder
.
start
(
1000
)
recordingStatus
.
value
=
'recording'
recordingStartTime
.
value
=
Date
.
now
()
recordingDuration
.
value
=
0
if
(
durationTimer
)
clearInterval
(
durationTimer
)
durationTimer
=
setInterval
(()
=>
{
recordingDuration
.
value
=
Math
.
floor
((
Date
.
now
()
-
recordingStartTime
.
value
)
/
1000
)
},
1000
)
ElMessage
.
success
(
'开始录制屏幕'
)
}
catch
(
error
)
{
console
.
error
(
'【录制】启动失败:'
,
error
)
ElMessage
.
error
(
'录制启动失败: '
+
error
.
message
)
recordingStatus
.
value
=
'idle'
recordingTaskId
.
value
=
null
}
}
// ========== 停止录制并上传 ==========
async
function
stopRecordingAndUpload
()
{
if
(
recordingStatus
.
value
!==
'recording'
)
{
ElMessage
.
warning
(
'当前没有正在进行的录制'
)
return
}
if
(
!
mediaRecorder
.
value
)
{
ElMessage
.
warning
(
'录制器不存在'
)
return
}
uploading
.
value
=
true
try
{
mediaRecorder
.
value
.
stop
()
if
(
durationTimer
)
{
clearInterval
(
durationTimer
);
durationTimer
=
null
}
await
new
Promise
(
resolve
=>
setTimeout
(
resolve
,
500
))
if
(
recordedChunks
.
value
.
length
===
0
)
{
ElMessage
.
warning
(
'没有录制到任何数据'
)
recordingStatus
.
value
=
'idle'
uploading
.
value
=
false
return
}
const
blob
=
new
Blob
(
recordedChunks
.
value
,
{
type
:
mediaRecorder
.
value
.
mimeType
||
'video/webm'
})
console
.
log
(
'【录制】文件大小:'
,
(
blob
.
size
/
1024
/
1024
).
toFixed
(
2
),
'MB'
)
const
fileExtension
=
blob
.
type
.
includes
(
'mp4'
)
?
'mp4'
:
'webm'
const
fileName
=
`recording_
${
recordingTaskId
.
value
}
_
${
Date
.
now
()}
.
${
fileExtension
}
`
const
file
=
new
File
([
blob
],
fileName
,
{
type
:
blob
.
type
})
const
stopRes
=
await
stopRecording
(
recordingTaskId
.
value
,
file
)
if
(
stopRes
.
code
===
200
)
{
const
data
=
stopRes
.
data
recordedFileUrl
.
value
=
data
.
fileUrl
||
''
recordingStatus
.
value
=
'stopped'
ElMessage
.
success
(
'录制文件上传成功!'
)
console
.
log
(
'【录制】上传成功,fileUrl:'
,
recordedFileUrl
.
value
)
try
{
const
queryRes
=
await
queryRecording
(
recordingTaskId
.
value
)
if
(
queryRes
.
code
===
200
)
console
.
log
(
'【录制】查询结果:'
,
queryRes
.
data
)
}
catch
(
e
)
{
console
.
warn
(
'查询录制信息失败:'
,
e
)
}
}
else
{
ElMessage
.
error
(
stopRes
.
msg
||
'上传失败'
)
recordingStatus
.
value
=
'idle'
}
}
catch
(
error
)
{
console
.
error
(
'【录制】停止并上传失败:'
,
error
)
ElMessage
.
error
(
'停止录制失败: '
+
(
error
.
message
||
'未知错误'
))
recordingStatus
.
value
=
'idle'
}
finally
{
uploading
.
value
=
false
mediaRecorder
.
value
=
null
recordedChunks
.
value
=
[]
recordingTaskId
.
value
=
null
}
}
// ========== 顾问接收屏幕(自动响应 Offer) ==========
async
function
handleWebRTCOffer
(
roomId
,
sdp
)
{
try
{
if
(
pc
)
{
pc
.
close
();
pc
=
null
}
pc
=
new
RTCPeerConnection
({
iceServers
:
[{
urls
:
'stun:stun.l.google.com:19302'
}]
})
pc
.
onicecandidate
=
(
event
)
=>
{
if
(
event
.
candidate
)
{
ws
.
send
(
JSON
.
stringify
({
action
:
'webrtc_ice_candidate'
,
roomId
,
candidate
:
event
.
candidate
}))
}
}
pc
.
ontrack
=
(
event
)
=>
{
if
(
remoteVideoRef
.
value
)
{
remoteVideoRef
.
value
.
srcObject
=
event
.
streams
[
0
]
remoteVideoRef
.
value
.
play
()
isReceivingScreen
.
value
=
true
console
.
log
(
'【WebRTC】开始接收屏幕流'
)
}
}
pc
.
onconnectionstatechange
=
()
=>
{
console
.
log
(
'【WebRTC】连接状态:'
,
pc
.
connectionState
)
if
(
pc
.
connectionState
===
'connected'
)
{
ElMessage
.
success
(
'已接收屏幕共享'
)
}
else
if
(
pc
.
connectionState
===
'disconnected'
||
pc
.
connectionState
===
'failed'
)
{
ElMessage
.
warning
(
'屏幕共享连接中断'
)
isReceivingScreen
.
value
=
false
}
}
await
pc
.
setRemoteDescription
(
new
RTCSessionDescription
(
sdp
))
const
answer
=
await
pc
.
createAnswer
()
await
pc
.
setLocalDescription
(
answer
)
ws
.
send
(
JSON
.
stringify
({
action
:
'webrtc_answer'
,
roomId
,
sdp
:
answer
}))
console
.
log
(
'【WebRTC】回答已发送'
)
}
catch
(
error
)
{
console
.
error
(
'【WebRTC】处理 Offer 失败:'
,
error
)
ElMessage
.
error
(
'接收屏幕共享失败: '
+
error
.
message
)
}
}
async
function
handleWebRTCAnswer
(
sdp
)
{
if
(
!
pc
)
return
try
{
await
pc
.
setRemoteDescription
(
new
RTCSessionDescription
(
sdp
))
console
.
log
(
'【WebRTC】远程描述已设置'
)
}
catch
(
error
)
{
console
.
error
(
'【WebRTC】设置 Answer 失败:'
,
error
)
}
}
async
function
handleWebRTCIceCandidate
(
candidate
)
{
if
(
!
pc
)
return
try
{
await
pc
.
addIceCandidate
(
new
RTCIceCandidate
(
candidate
))
}
catch
(
error
)
{
console
.
error
(
'【WebRTC】添加 ICE 候选失败:'
,
error
)
}
}
function
closeWebRTC
()
{
if
(
pc
)
{
pc
.
close
();
pc
=
null
}
isReceivingScreen
.
value
=
false
if
(
remoteVideoRef
.
value
)
{
remoteVideoRef
.
value
.
srcObject
=
null
}
console
.
log
(
'【WebRTC】连接已关闭'
)
}
// ========== 处理 WebSocket 消息 ==========
async
function
handleWebSocketMessage
(
msg
)
{
console
.
log
(
'【WS消息】'
,
msg
)
if
(
msg
.
action
===
'control_transfer'
)
{
const
currentUserType
=
role
.
value
===
'customer'
?
'owner'
:
'participant'
const
currentUserId
=
userStore
.
userBizId
||
'admin'
isController
.
value
=
(
msg
.
holderType
===
currentUserType
&&
msg
.
holderId
===
currentUserId
)
ElMessage
.
info
(
`控制权已切换至
${
msg
.
holderType
===
'owner'
?
'客户'
:
'顾问'
}
`
)
return
}
if
(
msg
.
action
===
'init_sync'
)
{
const
currentUserType
=
role
.
value
===
'customer'
?
'owner'
:
'participant'
const
currentUserId
=
userStore
.
userBizId
||
'admin'
isController
.
value
=
(
msg
.
holderType
===
currentUserType
&&
msg
.
holderId
===
currentUserId
)
if
(
msg
.
currentPage
)
{
const
data
=
msg
.
currentPage
.
data
currentPage
.
value
=
data
.
currentPage
||
0
scrollRatio
.
value
=
data
.
scrollRatio
||
0
zoomLevel
.
value
=
data
.
zoomLevel
||
1
desensitizationEnabled
.
value
=
data
.
desensitizationEnabled
||
false
applyScroll
(
scrollRatio
.
value
)
if
(
desensitizationEnabled
.
value
)
applyDesensitization
()
else
restoreOriginalContent
()
ElMessage
.
success
(
'状态已同步'
)
}
return
}
if
(
msg
.
action
===
'turn_the_page'
&&
msg
.
currentPage
)
{
currentPage
.
value
=
msg
.
currentPage
.
data
.
currentPage
scrollRatio
.
value
=
0
applyScroll
(
0
)
if
(
desensitizationEnabled
.
value
)
applyDesensitization
()
else
restoreOriginalContent
()
return
}
if
(
msg
.
action
===
'scroll'
&&
msg
.
currentPage
)
{
scrollRatio
.
value
=
msg
.
currentPage
.
data
.
scrollRatio
applyScroll
(
scrollRatio
.
value
)
return
}
if
(
msg
.
action
===
'scaling'
&&
msg
.
currentPage
)
{
zoomLevel
.
value
=
msg
.
currentPage
.
data
.
zoomLevel
return
}
if
(
msg
.
action
===
'desensitization_switch'
)
{
desensitizationEnabled
.
value
=
msg
.
enabled
if
(
desensitizationEnabled
.
value
)
await
applyDesensitization
()
else
restoreOriginalContent
()
ElMessage
.
info
(
`脱敏
${
msg
.
enabled
?
'开启'
:
'关闭'
}
`
)
return
}
if
(
msg
.
action
===
'end_sharing'
)
{
console
.
log
(
'【end_sharing】收到结束共享消息,当前录制状态:'
,
recordingStatus
.
value
)
ElMessage
.
warning
(
'共享已结束'
)
if
(
recordingStatus
.
value
===
'recording'
)
{
console
.
log
(
'【end_sharing】正在录制,开始停止并上传...'
)
await
stopRecordingAndUpload
()
}
shouldReconnect
=
false
if
(
wsReconnectTimer
)
clearTimeout
(
wsReconnectTimer
)
if
(
ws
)
{
ws
.
close
();
ws
=
null
}
wsConnected
.
value
=
false
sharingStatus
.
value
=
'已结束'
closeWebRTC
()
return
}
// WebRTC 信令
if
(
msg
.
action
===
'webrtc_offer'
)
{
if
(
role
.
value
===
'consultant'
)
{
await
handleWebRTCOffer
(
msg
.
roomId
,
msg
.
sdp
)
}
return
}
if
(
msg
.
action
===
'webrtc_answer'
)
{
if
(
role
.
value
===
'customer'
)
{
await
handleWebRTCAnswer
(
msg
.
sdp
)
}
return
}
if
(
msg
.
action
===
'webrtc_ice_candidate'
)
{
await
handleWebRTCIceCandidate
(
msg
.
candidate
)
return
}
}
// ========== 应用滚动 ==========
function
applyScroll
(
ratio
)
{
const
el
=
articleContentRef
.
value
if
(
el
)
{
const
wrapper
=
el
.
closest
(
'.article-wrapper'
)
if
(
wrapper
)
{
const
maxScroll
=
wrapper
.
scrollHeight
-
wrapper
.
clientHeight
if
(
maxScroll
>
0
)
wrapper
.
scrollTop
=
ratio
*
maxScroll
}
}
}
// ========== 发送同步操作 ==========
function
sendSyncAction
(
action
,
value
)
{
if
(
!
wsConnected
.
value
||
!
isController
.
value
)
{
ElMessage
.
warning
(
'无控制权或未连接'
)
return
}
const
pageData
=
{
url
:
window
.
location
.
href
,
title
:
currentArticle
.
value
.
title
,
page_type
:
'article'
,
data
:
{
currentPage
:
currentPage
.
value
,
totalPages
:
articles
.
length
,
scrollRatio
:
scrollRatio
.
value
,
zoomLevel
:
zoomLevel
.
value
,
desensitizationEnabled
:
desensitizationEnabled
.
value
,
pageTitle
:
currentArticle
.
value
.
title
,
chartIndices
:
[]
}
}
const
sendMsg
=
{
action
,
currentPage
:
pageData
}
if
(
action
===
'desensitization_switch'
)
{
sendMsg
.
enabled
=
value
pageData
.
data
.
desensitizationEnabled
=
value
}
else
if
(
action
===
'turn_the_page'
)
{
pageData
.
data
.
currentPage
=
currentPage
.
value
}
else
if
(
action
===
'scroll'
)
{
scrollRatio
.
value
=
value
pageData
.
data
.
scrollRatio
=
value
}
else
if
(
action
===
'scaling'
)
{
zoomLevel
.
value
=
value
pageData
.
data
.
zoomLevel
=
value
}
ws
.
send
(
JSON
.
stringify
(
sendMsg
))
}
// ========== 客户结束共享 ==========
async
function
handleEndSharing
()
{
if
(
role
.
value
!==
'customer'
)
{
ElMessage
.
warning
(
'只有客户可以结束共享'
)
return
}
try
{
await
proxy
.
$modal
.
confirm
(
'确认结束共享?'
)
if
(
ws
&&
ws
.
readyState
===
WebSocket
.
OPEN
)
{
ws
.
send
(
JSON
.
stringify
({
action
:
'end_sharing'
}))
console
.
log
(
'【客户】已发送 end_sharing 消息'
)
}
await
endCoSession
({
roomId
:
shareInfo
.
roomId
})
ElMessage
.
success
(
'共享已结束'
)
shouldReconnect
=
false
if
(
wsReconnectTimer
)
clearTimeout
(
wsReconnectTimer
)
if
(
ws
)
{
ws
.
close
();
ws
=
null
}
wsConnected
.
value
=
false
sharingStatus
.
value
=
'已结束'
closeWebRTC
()
if
(
sharedStream
)
{
sharedStream
.
getTracks
().
forEach
(
track
=>
track
.
stop
())
sharedStream
=
null
}
}
catch
(
error
)
{
if
(
error
!==
'cancel'
)
{
console
.
error
(
'结束共享失败:'
,
error
)
ElMessage
.
error
(
'结束共享失败,请重试'
)
}
}
}
// ========== 工具 ==========
function
copyCode
()
{
if
(
shareInfo
.
roomPwd
)
{
navigator
.
clipboard
?.
writeText
(
shareInfo
.
roomPwd
).
then
(()
=>
ElMessage
.
success
(
'已复制'
))
.
catch
(()
=>
{
const
input
=
document
.
createElement
(
'input'
)
input
.
value
=
shareInfo
.
roomPwd
document
.
body
.
appendChild
(
input
)
input
.
select
()
document
.
execCommand
(
'copy'
)
document
.
body
.
removeChild
(
input
)
ElMessage
.
success
(
'已复制'
)
})
}
}
// ========== 录制 UI 切换 ==========
function
toggleRecording
()
{
if
(
recordingStatus
.
value
===
'recording'
)
{
stopRecordingAndUpload
()
}
else
if
(
recordingStatus
.
value
===
'idle'
)
{
ElMessage
.
info
(
'录制将在共享屏幕时自动开始'
)
}
else
if
(
recordingStatus
.
value
===
'stopped'
)
{
if
(
recordedFileUrl
.
value
)
window
.
open
(
recordedFileUrl
.
value
,
'_blank'
)
}
}
function
formatDuration
(
seconds
)
{
if
(
!
seconds
||
seconds
<
0
)
return
'00:00'
const
mins
=
Math
.
floor
(
seconds
/
60
)
const
secs
=
seconds
%
60
return
`
${
String
(
mins
).
padStart
(
2
,
'0'
)}
:
${
String
(
secs
).
padStart
(
2
,
'0'
)}
`
}
// ==================== 脱敏核心逻辑 ====================
async
function
applyDesensitization
()
{
if
(
!
desensitizationEnabled
.
value
)
{
restoreOriginalContent
()
return
}
const
originalHtml
=
originalContentMap
.
get
(
currentPage
.
value
)
if
(
!
originalHtml
)
return
try
{
const
resourceType
=
'report'
const
resourceId
=
route
.
query
.
reportId
||
null
const
res
=
await
getDesensitizationRules
(
resourceType
,
resourceId
)
if
(
res
.
code
!==
200
||
!
res
.
data
||
res
.
data
.
length
===
0
)
{
ElMessage
.
warning
(
'未获取到脱敏规则'
)
return
}
let
processedHtml
=
originalHtml
res
.
data
.
forEach
(
rule
=>
{
const
fieldPath
=
rule
.
fieldPath
||
''
if
(
fieldPath
.
includes
(
'name'
))
{
const
tempDiv
=
document
.
createElement
(
'div'
)
tempDiv
.
innerHTML
=
processedHtml
const
nameEls
=
tempDiv
.
querySelectorAll
(
'.desensitize-name'
)
nameEls
.
forEach
(
el
=>
{
const
originalText
=
el
.
textContent
const
maskedText
=
applyMask
(
originalText
,
rule
.
maskType
,
rule
.
maskConfig
)
el
.
textContent
=
maskedText
})
processedHtml
=
tempDiv
.
innerHTML
}
else
if
(
fieldPath
.
includes
(
'phone'
))
{
const
tempDiv
=
document
.
createElement
(
'div'
)
tempDiv
.
innerHTML
=
processedHtml
const
phoneEls
=
tempDiv
.
querySelectorAll
(
'.desensitize-phone'
)
phoneEls
.
forEach
(
el
=>
{
const
originalText
=
el
.
textContent
const
maskedText
=
applyMask
(
originalText
,
rule
.
maskType
,
rule
.
maskConfig
)
el
.
textContent
=
maskedText
})
processedHtml
=
tempDiv
.
innerHTML
}
})
const
currentArticle
=
articles
[
currentPage
.
value
]
if
(
currentArticle
)
currentArticle
.
content
=
processedHtml
}
catch
(
error
)
{
console
.
error
(
'应用脱敏失败:'
,
error
)
ElMessage
.
error
(
'脱敏处理失败'
)
}
}
function
restoreOriginalContent
()
{
const
originalHtml
=
originalContentMap
.
get
(
currentPage
.
value
)
if
(
originalHtml
)
{
const
currentArticle
=
articles
[
currentPage
.
value
]
if
(
currentArticle
)
currentArticle
.
content
=
originalHtml
}
}
function
applyMask
(
text
,
maskType
,
maskConfigStr
)
{
if
(
!
text
)
return
text
let
config
=
{}
try
{
config
=
maskConfigStr
?
JSON
.
parse
(
maskConfigStr
)
:
{}
}
catch
(
e
)
{}
const
replaceChar
=
config
.
replace_char
||
'*'
switch
(
maskType
)
{
case
'mask'
:
{
const
suffix
=
config
.
suffix
||
1
if
(
text
.
length
<=
suffix
)
return
text
const
prefixLen
=
text
.
length
-
suffix
return
replaceChar
.
repeat
(
prefixLen
)
+
text
.
slice
(
-
suffix
)
}
case
'hide'
:
return
replaceChar
.
repeat
(
text
.
length
)
case
'replace'
:
return
replaceChar
.
repeat
(
text
.
length
)
case
'partial'
:
{
const
prefix
=
config
.
prefix
||
0
const
suffix
=
config
.
suffix
||
0
if
(
text
.
length
<=
prefix
+
suffix
)
return
text
const
middleLen
=
text
.
length
-
prefix
-
suffix
return
text
.
slice
(
0
,
prefix
)
+
replaceChar
.
repeat
(
middleLen
)
+
text
.
slice
(
-
suffix
)
}
default
:
return
text
}
}
async
function
toggleDesensitization
()
{
desensitizationEnabled
.
value
=
!
desensitizationEnabled
.
value
await
applyDesensitization
()
if
(
ws
&&
ws
.
readyState
===
WebSocket
.
OPEN
)
{
ws
.
send
(
JSON
.
stringify
({
action
:
'desensitization_switch'
,
enabled
:
desensitizationEnabled
.
value
}))
}
}
// ========== 生命周期 ==========
onMounted
(()
=>
{
articles
.
forEach
((
article
,
index
)
=>
{
originalContentMap
.
set
(
index
,
article
.
content
)
})
const
wrapper
=
document
.
querySelector
(
'.article-wrapper'
)
if
(
wrapper
)
{
wrapper
.
addEventListener
(
'scroll'
,
()
=>
{
if
(
wsConnected
.
value
&&
isController
.
value
)
{
const
maxScroll
=
wrapper
.
scrollHeight
-
wrapper
.
clientHeight
if
(
maxScroll
>
0
)
{
const
ratio
=
wrapper
.
scrollTop
/
maxScroll
if
(
Math
.
abs
(
ratio
-
scrollRatio
.
value
)
>
0.01
)
{
scrollRatio
.
value
=
ratio
sendSyncAction
(
'scroll'
,
ratio
)
}
}
}
})
}
})
onBeforeUnmount
(()
=>
{
shouldReconnect
=
false
if
(
wsReconnectTimer
)
clearTimeout
(
wsReconnectTimer
)
if
(
ws
)
{
try
{
ws
.
close
()
}
catch
(
e
)
{};
ws
=
null
}
closeWebRTC
()
if
(
durationTimer
)
clearInterval
(
durationTimer
)
if
(
mediaRecorder
.
value
&&
recordingStatus
.
value
===
'recording'
)
{
try
{
mediaRecorder
.
value
.
stop
()
}
catch
(
e
)
{}
}
if
(
sharedStream
)
{
sharedStream
.
getTracks
().
forEach
(
track
=>
track
.
stop
())
sharedStream
=
null
}
if
(
recordingStream
.
value
)
{
recordingStream
.
value
.
getTracks
().
forEach
(
track
=>
track
.
stop
())
recordingStream
.
value
=
null
}
originalContentMap
.
clear
()
})
</
script
>
<
style
scoped
>
.reader-container
{
position
:
relative
;
height
:
100vh
;
background
:
#f7f9fc
;
display
:
flex
;
flex-direction
:
column
;
overflow
:
hidden
;
font-family
:
-apple-system
,
BlinkMacSystemFont
,
'Segoe UI'
,
Roboto
,
sans-serif
;
}
.reader-header
{
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
padding
:
12px
20px
;
background
:
#fff
;
border-bottom
:
1px
solid
#e8ecf1
;
flex-shrink
:
0
;
z-index
:
10
;
}
.header-left
{
display
:
flex
;
align-items
:
center
;
gap
:
12px
;
}
.role-badge
{
font-size
:
13px
;
padding
:
2px
10px
;
border-radius
:
12px
;
font-weight
:
500
;
}
.role-badge.customer
{
background
:
#e6f7ff
;
color
:
#1890ff
;
}
.role-badge.consultant
{
background
:
#f6ffed
;
color
:
#52c41a
;
}
.header-center
{
display
:
flex
;
flex-direction
:
column
;
align-items
:
center
;
flex
:
1
;
}
.header-center
.title
{
font-size
:
16px
;
font-weight
:
600
;
color
:
#1a1a2e
;
max-width
:
300px
;
overflow
:
hidden
;
text-overflow
:
ellipsis
;
white-space
:
nowrap
;
}
.header-center
.page-info
{
font-size
:
12px
;
color
:
#999
;
}
.header-right
{
display
:
flex
;
align-items
:
center
;
gap
:
10px
;
}
.reader-body
{
flex
:
1
;
overflow
:
hidden
;
padding
:
16px
20px
;
position
:
relative
;
}
.article-wrapper
{
height
:
100%
;
overflow-y
:
auto
;
background
:
#fff
;
border-radius
:
12px
;
box-shadow
:
0
2px
12px
rgba
(
0
,
0
,
0
,
0.06
);
padding
:
20px
24px
;
transition
:
transform
0.2s
ease
;
}
.article-content
{
line-height
:
1.8
;
font-size
:
15px
;
color
:
#333
;
}
.article-content
h2
{
font-size
:
20px
;
margin-bottom
:
12px
;
color
:
#1a1a2e
;
}
.article-content
p
{
margin-bottom
:
12px
;
}
.article-content
img
{
max-width
:
100%
;
border-radius
:
8px
;
margin
:
12px
0
;
}
.reader-footer
{
display
:
flex
;
align-items
:
center
;
padding
:
12px
24px
20px
;
background
:
#fff
;
border-top
:
1px
solid
#e8ecf1
;
flex-shrink
:
0
;
}
.zoom-controls
{
position
:
fixed
;
right
:
20px
;
top
:
50%
;
transform
:
translateY
(
-50%
);
display
:
flex
;
flex-direction
:
column
;
align-items
:
center
;
gap
:
6px
;
background
:
rgba
(
255
,
255
,
255
,
0.85
);
padding
:
8px
;
border-radius
:
24px
;
box-shadow
:
0
2px
8px
rgba
(
0
,
0
,
0
,
0.08
);
backdrop-filter
:
blur
(
4px
);
z-index
:
20
;
}
.zoom-level
{
font-size
:
12px
;
font-weight
:
500
;
color
:
#333
;
}
/* 悬浮按钮组 */
.fab-group
{
position
:
fixed
;
bottom
:
30px
;
right
:
30px
;
z-index
:
30
;
display
:
flex
;
flex-direction
:
column-reverse
;
gap
:
12px
;
align-items
:
center
;
}
.fab-btn
{
width
:
60px
;
height
:
60px
;
border
:
none
;
box-shadow
:
0
4px
16px
rgba
(
0
,
0
,
0
,
0.3
);
transition
:
all
0.3s
;
color
:
#fff
;
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
border-radius
:
50%
;
padding
:
0
;
}
.fab-btn
:hover
{
transform
:
scale
(
1.05
);
}
.fab-btn-text
{
font-size
:
12px
;
font-weight
:
600
;
line-height
:
1.2
;
text-align
:
center
;
white-space
:
pre-line
;
color
:
#fff
;
}
.fab-share
{
background
:
#1890ff
;
}
.fab-share
:hover
{
background
:
#40a9ff
;
}
.fab-end
{
background
:
#ff4d4f
;
}
.fab-end
:hover
{
background
:
#ff7875
;
}
.fab-desensitize
{
background
:
#52c41a
;
}
.fab-desensitize
:hover
{
background
:
#73d13d
;
}
.fab-join
{
background
:
#1890ff
;
}
.fab-join
:hover
{
background
:
#40a9ff
;
}
.fab-authorize
{
background
:
#faad14
;
}
.fab-authorize
:hover
{
background
:
#ffd666
;
}
.fab-revoke
{
background
:
#ff4d4f
;
}
.fab-revoke
:hover
{
background
:
#ff7875
;
}
/* 视频容器 */
.video-container
{
position
:
relative
;
width
:
100%
;
height
:
100%
;
background
:
#000
;
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
}
.video-container
video
{
width
:
100%
;
height
:
100%
;
object-fit
:
contain
;
}
/* 视频覆盖层 */
.controls-overlay
{
position
:
absolute
;
bottom
:
30px
;
left
:
50%
;
transform
:
translateX
(
-50%
);
display
:
flex
;
align-items
:
center
;
gap
:
20px
;
background
:
rgba
(
0
,
0
,
0
,
0.6
);
backdrop-filter
:
blur
(
8px
);
padding
:
10px
24px
;
border-radius
:
40px
;
border
:
1px
solid
rgba
(
255
,
255
,
255
,
0.1
);
z-index
:
10
;
color
:
white
;
}
.nav-controls
{
display
:
flex
;
align-items
:
center
;
gap
:
12px
;
}
.nav-btn
,
.zoom-btn
{
background
:
rgba
(
255
,
255
,
255
,
0.1
);
border
:
1px
solid
rgba
(
255
,
255
,
255
,
0.2
);
color
:
white
;
width
:
36px
;
height
:
36px
;
border-radius
:
50%
;
cursor
:
pointer
;
transition
:
all
0.2s
;
font-size
:
18px
;
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
}
.nav-btn
:not
(
.disabled
)
:hover
,
.zoom-btn
:not
(
:disabled
)
:hover
{
background
:
rgba
(
255
,
255
,
255
,
0.25
);
}
.nav-btn.disabled
,
.zoom-btn
:disabled
{
opacity
:
0.3
;
cursor
:
not-allowed
;
}
.page-text
{
color
:
rgba
(
255
,
255
,
255
,
0.8
);
font-size
:
14px
;
min-width
:
60px
;
text-align
:
center
;
}
.zoom-controls-overlay
{
display
:
flex
;
align-items
:
center
;
gap
:
6px
;
}
.zoom-controls-overlay
span
{
font-size
:
13px
;
min-width
:
36px
;
text-align
:
center
;
}
.status-tip
{
font-size
:
13px
;
color
:
rgba
(
255
,
255
,
255
,
0.6
);
padding-left
:
16px
;
border-left
:
1px
solid
rgba
(
255
,
255
,
255
,
0.1
);
}
.customer-info
{
background
:
#f6ffed
;
border
:
1px
solid
#b7eb8f
;
border-radius
:
8px
;
padding
:
12px
16px
;
margin
:
12px
0
;
}
.customer-info
p
{
margin
:
4px
0
;
}
.article-wrapper
::-webkit-scrollbar
{
width
:
4px
;
}
.article-wrapper
::-webkit-scrollbar-track
{
background
:
transparent
;
}
.article-wrapper
::-webkit-scrollbar-thumb
{
background
:
#d0d5dd
;
border-radius
:
4px
;
}
</
style
>
\ No newline at end of file
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment