Commit d23ea9b2 by zhangxingmin

push

parent 7a6969cd
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
......@@ -10,6 +10,7 @@ const useUserStore = defineStore('user', {
state: () => ({
token: getToken(),
id: '',
userBizId: '',
name: '',
nickName: '',
avatar: '',
......@@ -31,6 +32,7 @@ const useUserStore = defineStore('user', {
.then(res => {
setToken(res.data.token)
this.token = res.data.token
this.userBizId = res.data.userBizId
resolve()
})
.catch(error => {
......@@ -70,6 +72,7 @@ const useUserStore = defineStore('user', {
}
this.id = user.userId
this.userBizId = user.userBizId
this.name = user.userName
this.nickName = user.nickName
this.avatar = avatar
......
<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
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment