Compare commits

..

15 Commits

Author SHA1 Message Date
sunli_tydic
caa9b23766 ai视频一期功能提交 2025-11-21 17:26:34 +08:00
sunli_tydic
4b4c0943d8 添加改动标识 ai播放器相关 2025-11-21 14:30:09 +08:00
sunli_tydic
b31f02bf23 ai视频一期功能提交 2025-11-20 17:30:31 +08:00
sunli_tydic
003fece291 ai功能静态 2025-11-19 16:27:07 +08:00
sunli_tydic
986a47f22a 视频ai文稿 ai摘要静态 2025-11-18 15:13:57 +08:00
Caojr
782bcc31e5 szx-1282 超时清空展示文件 2025-11-04 15:54:23 +08:00
Caojr
1a95852912 szx-1282 超时清空展示文件 2025-11-04 14:55:16 +08:00
Caojr
f5d865ccc3 szx-1282 增加超时配置 2025-11-04 13:47:02 +08:00
Caojr
65673561d8 szx-1280 编辑时长无效问题修复 2025-10-31 16:16:29 +08:00
Caojr
2cbb379fa6 样式修改 2025-10-29 18:10:07 +08:00
670788339
6d4af3aa2d 还原视频状态 调试 2025-10-24 13:29:09 +08:00
joshen
5ebee96ce4 Merge remote-tracking branch 'yx/202599-da' 2025-09-29 17:42:32 +08:00
408d6a1612 修复视频 status 状态 2025-09-29 17:26:53 +08:00
b1508ad226 更新精品课图片 2025-09-26 09:18:51 +08:00
a9764bf2f8 更新精品课图片 2025-09-26 09:15:55 +08:00
36 changed files with 20681 additions and 10026 deletions

22046
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,6 @@
"lint": "vue-cli-service lint"
},
"dependencies": {
"@mermaid-js/parser": "^0.6.3",
"axios": "^0.21.4",
"core-js": "^3.6.5",
"driver.js": "^0.9.8",
@@ -24,15 +23,9 @@
"element-ui": "^2.15.7",
"file-saver": "^2.0.5",
"fuse.js": "^6.4.6",
"highlight.js": "^11.11.1",
"image-conversion": "^2.1.1",
"jsencrypt": "^3.2.1",
"json-bigint": "^1.0.0",
"katex": "^0.16.25",
"markdown-it": "^14.1.0",
"markdown-it-highlightjs": "^4.2.0",
"markdown-it-mermaid": "^0.2.5",
"mermaid": "^8.13.10",
"mockjs": "^1.1.0",
"moment": "^2.29.1",
"nprogress": "^0.2.0",
@@ -50,7 +43,6 @@
"vue": "^2.6.11",
"vue-awesome-swiper": "^3.1.3",
"vue-cookies": "^1.7.4",
"vue-katex": "^0.5.0",
"vue-pdf": "^4.2.0",
"vue-quill-editor": "^3.0.6",
"vue-router": "^3.5.2",
@@ -68,7 +60,6 @@
"html-webpack-plugin": "^5.5.0",
"less": "^4.1.1",
"less-loader": "^6.2.0",
"null-loader": "^4.0.1",
"sass": "^1.32.13",
"sass-loader": "^10.1.0",
"vue-template-compiler": "^2.6.11"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 438 KiB

View File

@@ -1,74 +1,25 @@
<template>
<div id="app" style="width: 100vw">
<div id="app">
<keep-alive :include="['case']">
<router-view />
12312
</keep-alive>
<!-- 添加AI Call组件 -->
<AICall
:dialogVisible="showAICall"
@close="onCloseAICall"
@restore="onRestoreAICall"
/>
</div>
</template>
<script>
import { mapGetters, mapState } from 'vuex';
import AICall from '@/views/portal/case/AICall.vue';
import { mapGetters } from 'vuex';
export default{
name: 'App',
components: {
AICall
},
computed: {
...mapGetters(['userInfo']),
...mapState('app', ['showAICall', 'showAICallMinimized'])
...mapGetters(['userInfo'])
},
methods: {
onCloseAICall() {
// 通过Vuex关闭AI Call组件
this.$store.dispatch('app/setShowAICall', false);
},
onRestoreAICall() {
// 通过Vuex显示AI Call组件
this.$store.dispatch('app/setShowAICall', true);
},
// 检查当前路由是否应该显示AI弹窗
checkRouteForAICall() {
const currentRoute = this.$route.name;
// 只在case或caseDetail路由显示弹窗
if (currentRoute === 'case' || currentRoute === 'caseDetail') {
// 设置最小化窗口显示状态为true
this.$store.dispatch('app/setShowAICallMinimized', true);
// 注意这里不再强制设置showAICall为true保留用户之前的操作状态
} else {
// 其他路由关闭弹窗
this.$store.dispatch('app/setShowAICall', false);
// 设置最小化窗口显示状态为false
this.$store.dispatch('app/setShowAICallMinimized', false);
}
}
},
mounted() {
mounted() {
//从状态值中取,因为登录处理所以移动watch中
// console.log(this.userInfo);
// if(this.userInfo && this.userInfo.name!=''){
// this.$watermark.set(this.userInfo.name+this.userInfo.loginName);
// }
// 初始化检查路由
this.checkRouteForAICall();
},
watch: {
// 监听路由变化
$route(to, from) {
this.checkRouteForAICall();
}
}
// watch:{
// userInfo(newVal,oldVal){
// if(newVal && newVal.name!=''){
@@ -88,3 +39,4 @@
box-shadow: 0px 1px 5px 1px rgba(92,98,111,.3);
}
</style>

View File

@@ -1,30 +0,0 @@
import ajax from '@/utils/xajax.js'
/**
* AI聊天对话接口
* @param {Object} data - 请求参数
* @param {string} data.conversationId - 会话ID如果为空则创建新会话
* @param {string} data.query - 用户提问内容
* @returns {Promise} - 返回SSE流
*/
export function aiChat(data) {
return ajax.postJson('http://192.168.3.178/xboe/m/boe/case/ai/chat', data)
}
/**
* 查询会话消息记录接口
* @param {string} conversationId - 会话ID
* @returns {Promise} - 返回会话历史记录
*/
export function getChatMessages(conversationId) {
return ajax.get('/xboe/m/boe/case/ai/messages?conversationId=' + conversationId)
}
/**
* 案例专家功能入口显示权限判断接口
* 判断当前登录用户是否显示"案例专家"功能入口
* @returns {Promise} - 返回是否显示功能入口的布尔值
*/
export function showCaseAiEntrance() {
return ajax.get('/xboe/m/boe/case/ai/show-entrance')
}

View File

@@ -22,7 +22,7 @@ const pageList = function(data) {
/**
* 选择课件的查询,这里也是分页查询,只是返回的内容,字段会很少,用于课件制作那选择已有课件内容。
*
*
* @param {Object} data
* 查询参数如上面pageList方法
*/
@@ -47,7 +47,9 @@ const findList = function(data) {
}
*/
const saveUpload = function(data) {
return ajax.post('/xboe/m/course/file/upload/save', data);
return ajax.post('/xboe/m/course/file/upload/save', data, {
timeout: 60000
});
}
/**
@@ -88,4 +90,4 @@ export default {
batchUpdate,
detail,
delFile
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 B

View File

@@ -0,0 +1,403 @@
<template>
<div class="ai-script">
<!-- 搜索和语言选择区域 -->
<div class="search-container">
<el-input
v-model="searchKeyword"
placeholder="请输入关键词查找文稿内容"
class="search-input"
prefix-icon="el-icon-search"
@keyup.enter.native="searchContent"
@input="handleInputChange"
clearable
native-type="text"
/>
<div class="language-selector">
<span class="language-label">语言</span>
<el-select v-model="selectedLanguage" class="language-select" @change="changeLanguage" placeholder="请选择语言">
<el-option v-for="lang in selectableLang" :key="lang.srclang" :label="getSelectLabel(lang)" :value="lang.srclang"></el-option>
</el-select>
</div>
</div>
<!-- 内容展示区域 -->
<div class="content-container">
<!-- 动态渲染内容块 -->
<div v-for="(item, index) in contentList" :key="index" class="content-item" :class="{'active': currentTime >= item.start && currentTime <= item.end}">
<div class="timestamp">
<div class="timestamp-text">
<i class="el-icon-time"></i>
{{ formatTime(item.start) }}
</div>
</div>
<el-card class="content-text" @click.native="scrollToTime(item.start)">
<div v-html="item.highlightedContent || item.text"></div>
</el-card>
</div>
</div>
</div>
</template>
<script>
import { mapGetters, mapMutations } from 'vuex'
export default {
name: 'ai-script',
data() {
return {
searchKeyword: '',
selectedLanguage: 'zh-CN',
originalContentList: [],
contentList: [], // 用于显示的内容列表
isUserScrolling: false, // 用户是否正在滚动
userScrollTimeout: null // 滚动超时计时器
}
},
computed: {
...mapGetters([
'currentTime',
'selectableLang'
]),
},
mounted: function() {
// 添加滚动事件监听,检测用户手动滚动
const container = document.querySelector('.content-container');
if (container) {
container.addEventListener('scroll', this.handleUserScroll);
}
},
beforeDestroy: function() {
// 清理事件监听和计时器
const container = document.querySelector('.content-container');
if (container) {
container.removeEventListener('scroll', this.handleUserScroll);
}
if (this.userScrollTimeout) {
clearTimeout(this.userScrollTimeout);
}
},
watch: {
// 监听currentTime变化自动滚动到当前激活项
currentTime: function(newTime) {
// 只有当用户没有手动滚动时才执行自动滚动
if (!this.isUserScrolling) {
this.$nextTick(function() {
const activeElement = document.querySelector('.content-item.active');
if (activeElement) {
// 获取内容容器
const container = document.querySelector('.content-container');
// 计算元素是否在可视区域内
const containerRect = container.getBoundingClientRect();
const elementRect = activeElement.getBoundingClientRect();
// 如果元素不在可视区域内,则滚动到可视区域
if (elementRect.top < containerRect.top || elementRect.bottom > containerRect.bottom) {
// 计算元素相对于容器的偏移量而不是使用scrollIntoView
// 这样只会滚动content-container内部不会影响页面滚动
// 计算元素相对于容器的位置
const elementOffsetTop = activeElement.offsetTop;
const containerScrollTop = container.scrollTop;
const containerHeight = container.clientHeight;
const elementHeight = activeElement.clientHeight;
// 计算目标滚动位置,使元素居中显示
// 考虑容器的内边距和元素本身的高度
let targetScrollTop = elementOffsetTop - (containerHeight / 2) + (elementHeight / 2);
// 确保目标滚动位置不会小于0
targetScrollTop = Math.max(0, targetScrollTop);
// 确保目标滚动位置不会导致元素超出容器底部
const maxScrollTop = container.scrollHeight - containerHeight;
targetScrollTop = Math.min(targetScrollTop, maxScrollTop);
// 使用requestAnimationFrame实现平滑滚动
const startScrollTop = containerScrollTop;
const distance = targetScrollTop - startScrollTop;
const duration = 300; // 滚动持续时间,毫秒
let startTime = null;
function animateScroll(currentTime) {
if (!startTime) startTime = currentTime;
const timeElapsed = currentTime - startTime;
container.scrollTo({
top: startScrollTop + distance - elementHeight - 120,
behavior: 'smooth'
});
if (timeElapsed < duration) {
requestAnimationFrame(animateScroll);
}
}
requestAnimationFrame(animateScroll);
}
}
});
}
}
},
created() {
// 初始化时根据语言选择显示内容
this.changeLanguage(this.selectedLanguage)
},
methods: {
// 动态获取选择框的标签
getSelectLabel(lang) {
if (lang.srclang == 'zh-CN') {
return lang.label;
}
return `${lang.name} (${lang.label})`;
},
formatTime (time) {
// 格式化时间为HH:MM:SS如01:00:00
const hours = Math.floor(time / 3600);
const minutes = Math.floor((time % 3600) / 60);
const seconds = Math.floor(time % 60);
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
},
// 跳转到指定时间点
scrollToTime(time) {
console.log('跳转到时间点:', time);
this.$emit('changeCurrentTime', time);
// 设置用户滚动状态,避免自动滚动干扰
this.isUserScrolling = true;
if (this.userScrollTimeout) {
clearTimeout(this.userScrollTimeout);
}
this.userScrollTimeout = setTimeout(() => {
this.isUserScrolling = false;
}, 3000);
},
// 处理用户滚动事件
handleUserScroll: function() {
this.isUserScrolling = true;
// 清除之前的计时器
if (this.userScrollTimeout) {
clearTimeout(this.userScrollTimeout);
}
// 设置新的计时器3秒后恢复自动滚动
this.userScrollTimeout = setTimeout(() => {
this.isUserScrolling = false;
}, 3000);
},
searchContent () {
// 搜索功能实现
if (!this.searchKeyword.trim()) {
// 如果搜索关键词为空,显示所有内容
this.contentList = this.originalContentList.map(item => ({ ...item }));
return;
}
const keyword = this.searchKeyword.trim();
// 过滤包含关键词的内容
const filteredList = this.originalContentList.filter(item =>
item.text.includes(keyword)
);
if (filteredList.length === 0) {
// 如果没有搜索到内容,显示提示
this.$message({
message: '未找到相关内容',
type: 'info'
});
this.contentList = this.originalContentList.map(item => ({ ...item }));
} else {
// 对搜索到的内容进行关键词高亮处理
this.contentList = filteredList.map(item => ({
...item,
highlightedContent: this.highlightKeyword(item.text, keyword)
}));
console.log(this.contentList)
}
},
highlightKeyword(content, keyword) {
// 对关键词进行转义,防止正则表达式特殊字符的影响
const escapedKeyword = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// 使用正则表达式全局匹配关键词并添加高亮标记
const regex = new RegExp(`(${escapedKeyword})`, 'gi');
return content.replace(regex, '<span style="color: rgba(6, 125, 255, 1); background: rgba(6, 125, 255, 0.1);">$1</span>');
},
changeLanguage (event) {
// this.selectedLanguage = event
this.selectableLang.forEach(item => {
if (item.srclang === event) {
console.log('当前语言:', item)
if (!item.originalContentList) {
try {
item.originalContentList = JSON.parse(item.subtitleData)
} catch (error) {
console.error('ai文稿格式有问题')
}
}
this.originalContentList = item.originalContentList || []
// 初始化时显示所有内容
this.contentList = this.originalContentList.map(item => ({ ...item }));
console.log('ai文稿数据', this.originalContentList)
}
})
console.log('切换语言:', event)
},
handleInputChange() {
// 当输入框内容变化时,如果为空则重置显示所有内容
if (!this.searchKeyword.trim()) {
this.contentList = this.originalContentList.map(item => ({ ...item }));
}
}
}
}
</script>
<style lang="scss" scoped>
.ai-script {
padding: 15px 0;
background-color: #fff;
border-radius: 8px;
}
.search-container {
display: flex;
align-items: center;
gap: 20px;
margin: 0 20px 15px 20px;
}
.search-box {
position: relative;
flex: 1;
max-width: 400px;
}
.search-input {
flex: 1;
}
:deep(.el-input__inner) {
border-radius: 20px;
border-color: #2688FF;
}
:deep(.el-input__inner:focus) {
border-color: #1a6fe0;
box-shadow: 0 0 0 2px rgba(38, 136, 255, 0.2);
}
:deep(.el-input__prefix) {
left: 15px;
}
:deep(.el-input__icon) {
color: #2688FF;
}
.language-selector {
display: flex;
align-items: center;
gap: 10px;
}
.language-label {
font-size: 14px;
color: #333;
}
.language-select {
width: 90px;
}
:deep(.el-select__inner) {
border-radius: 4px;
}
.content-container {
display: flex;
flex-direction: column;
gap: 20px;
max-height: 410px;
overflow-y: auto;
padding: 0 20px;
}
.content-item {
display: flex;
flex-direction: column;
gap: 7px;
}
.timestamp {
display: flex;
align-items: center;
font-size: 14px;
color: #666;
padding: 5px 0;
.timestamp-text{
display: flex;
align-items: center;
gap: 5px;
border-radius: 12px;
padding: 2px 12px;
font-size: 14px;
font-weight: 500;
line-height: 20px;
letter-spacing: 0.3px;
}
}
:deep(.el-icon-time) {
color: #2688FF;
}
.content-text {
cursor: pointer;
line-height: 1.6;
font-size: 14px;
color: rgba(102, 102, 102, 1);
border-radius: 6px;
overflow: hidden;
background: rgba(250, 250, 250, 1);
line-height: 22px;
letter-spacing: 0.28px;
}
.active {
.timestamp-text{
color: rgba(6, 125, 255, 1);
background: rgba(6, 125, 255, 0.1);
}
.content-text{
border: 1px solid rgba(116, 182, 255, 1);
box-shadow: 0px 0px 7px 0px rgba(6, 125, 255, 0.24);
background: rgba(250, 250, 250, 1);
}
}
:deep(.el-card__body) {
padding: 15px;
}
:deep(.el-card.is-hover-shadow:focus, .el-card.is-hover-shadow:hover) {
box-shadow: 0 2px 12px 0 rgba(38, 136, 255, 0.2);
}
/* 响应式设计 */
@media (max-width: 768px) {
.search-container {
flex-direction: column;
align-items: stretch;
}
.search-box {
max-width: none;
}
.language-selector {
justify-content: flex-end;
}
}
</style>

View File

@@ -50,7 +50,7 @@
<el-input-number v-model="duration" size="mini" :min="1" :max="100"></el-input-number>
</span>
</div>
<el-upload class="upload-demo" :headers="headers" :data="data" drag :action="uploadFileUrl" :on-success="handleUploadSuccess" :before-upload="handleBeforeUpload">
<el-upload ref="uploadRef" class="upload-demo" :headers="headers" :data="data" drag :action="uploadFileUrl" :on-success="handleUploadSuccess" :before-upload="handleBeforeUpload">
<i class="el-icon-upload"></i>
<div class="el-upload__text">将文件拖到此处<em>点击上传</em></div>
<div class="el-upload__tip" slot="tip">文件大小限制{{curComType.maxSizeName}},支持的文件类型{{curComType.fileTypes.join(',')}}</div>
@@ -195,6 +195,7 @@
// this.cware.content.content=result.filePath;
}else{
this.$message.error(rs.message);
this.$refs.uploadRef.clearFiles();
}
});
}else{

View File

@@ -158,6 +158,43 @@
placeholder="请尽量填写课程简介,用于列表中显示,可以让用户更容易了解课程信息">
</el-input>
</el-form-item>
<el-form-item label="AI设置">
<div style="margin-top: 7px;">
<div style="display: flex; align-items: center;gap: 5px;">
<el-switch v-model="courseInfo.aiSet" :active-value="1" :inactive-value="0"></el-switch>
<el-tooltip class="item" effect="dark" :content="aiSetTip" placement="top">
<i class="el-icon-question" style="margin-left: 5px; color: #909399; cursor: pointer;"></i>
</el-tooltip>
</div>
<div v-show="courseInfo.aiSet==1" style="margin-left: -20px;">
<div style="display: flex; justify-content: space-between;;align-items: center;gap: 5px;margin: 10px 0;">
<div style="display: flex; align-items: center;gap: 5px;">
<span>AI摘要</span>
<el-switch v-model="courseInfo.aiAbstract" :active-value="1" :inactive-value="0"></el-switch>
<el-tooltip class="item" effect="dark" :content="aiAbstractTip" placement="top">
<i class="el-icon-question" style="margin-left: 5px; color: #909399; cursor: pointer;"></i>
</el-tooltip>
</div>
<div style="display: flex; align-items: center;gap: 5px;">
<span>AI文稿</span>
<el-switch v-model="courseInfo.aiDraft" :active-value="1" :inactive-value="0"></el-switch>
<el-tooltip class="item" effect="dark" :content="aiDraftTip" placement="top">
<i class="el-icon-question" style="margin-left: 5px; color: #909399; cursor: pointer;"></i>
</el-tooltip>
</div>
</div>
<div style="display: flex; align-items: center;gap: 5px;margin: 10px 0;margin-left: -30px;">
<span>AI翻译语种</span>
<el-select v-model="courseInfo.languageCode" placeholder="请选择" multiple filterable style="width: 240px;">
<el-option v-for="item in selectAllLang" :key="item.key" :label="item.label" :value="item.srclang"> </el-option>
</el-select>
<el-tooltip class="item" effect="dark" :content="aiTranslateTip" placement="top">
<i class="el-icon-question" style="margin-left: 5px; color: #909399; cursor: pointer;"></i>
</el-tooltip>
</div>
</div>
</div>
</el-form-item>
</el-col>
<el-col :span="14">
<div @click="checkCourse"><weikeContent ref="weikeContent" :reset="weikeReset" :contents="contentInfo.list" :course="courseInfo" min-height="644px"></weikeContent></div>
@@ -329,6 +366,44 @@
placeholder="请尽量填写课程简介,用于列表中显示,可以让用户更容易了解课程信息">
</el-input>
</el-form-item>
<!-- ai播放器相关 -->
<el-form-item label="AI设置">
<div style="margin-top: 7px;">
<div style="display: flex; align-items: center;gap: 5px;">
<el-switch v-model="courseInfo.aiSet" :active-value="1" :inactive-value="0"></el-switch>
<el-tooltip class="item" effect="dark" :content="aiSetTip" placement="top">
<i class="el-icon-question" style="margin-left: 5px; color: #909399; cursor: pointer;"></i>
</el-tooltip>
</div>
<div v-show="courseInfo.aiSet==1" style="margin-left: -20px;">
<div style="display: flex;align-items: center;gap: 80px;margin: 20px 0;">
<div style="display: flex; align-items: center;gap: 5px;">
<span>AI摘要</span>
<el-switch v-model="courseInfo.aiAbstract" :active-value="1" :inactive-value="0"></el-switch>
<el-tooltip class="item" effect="dark" :content="aiAbstractTip" placement="top">
<i class="el-icon-question" style="margin-left: 5px; color: #909399; cursor: pointer;"></i>
</el-tooltip>
</div>
<div style="display: flex; align-items: center;gap: 5px;">
<span>AI文稿</span>
<el-switch v-model="courseInfo.aiDraft" :active-value="1" :inactive-value="0"></el-switch>
<el-tooltip class="item" effect="dark" :content="aiDraftTip" placement="top">
<i class="el-icon-question" style="margin-left: 5px; color: #909399; cursor: pointer;"></i>
</el-tooltip>
</div>
</div>
<div style="display: flex; align-items: center;gap: 5px;margin: 20px 0;margin-left: -30px;">
<span>AI翻译语种</span>
<el-select v-model="courseInfo.languageCode" placeholder="请选择" multiple filterable style="flex:1">
<el-option v-for="item in selectAllLang" :key="item.key" :label="item.label" :value="item.srclang"> </el-option>
</el-select>
<el-tooltip class="item" effect="dark" :content="aiTranslateTip" placement="top">
<i class="el-icon-question" style="margin-left: 5px; color: #909399; cursor: pointer;"></i>
</el-tooltip>
</div>
</div>
</div>
</el-form-item>
<!-- v-if="!weike.onlyRequired" -->
<!-- <el-form-item label="课程描述">
<WxEditor v-model="courseInfo.overview" :minHeight="50"></WxEditor>
@@ -535,14 +610,19 @@ export default {
selectedOrg: {
orgId: null,
name: ''
}
},
aiSetTip: '是否将课程进行AI处理', //提示信息
aiAbstractTip: '一键提炼课程视频核心要点,助力学员课前高效掌握重点,快速筛选学习资源', // 提示信息
aiDraftTip: '分段展示视频内容并精准同步时间轴,实现视频进度与文稿双向定位,学习内容触手可及', //提示信息
aiTranslateTip: '智能转换视频字幕与语音为多语种,支持全球学员按需切换语言,打破学习边界', // 提示信息
};
},
created() {
this.getSceneData();
},
computed: {
...mapGetters(['resOwnerMap', 'sysTypeMap','userInfo','identity']),
// ai播放器相关
...mapGetters(['resOwnerMap', 'sysTypeMap','userInfo','identity', 'selectAllLang']),
catalogTree() {
let treeList = [];
let $this = this;
@@ -951,6 +1031,19 @@ export default {
this.dicts = result.dicts; //课程的老师信息
console.log("--- 编辑查看 this.isPermission = ",this.isPermission)
console.log("--- 编辑查看 this.dicts = ",this.dicts)
// ai播放器相关
// 如果ai设置为空则给默认值 - 会看成新增状态
if(this.courseInfo.aiSet === null || this.courseInfo.aiSet === '' || this.courseInfo.aiSet === undefined){
this.courseInfo.isAddAI = 1; //暂时是否是新增
this.courseInfo.aiSet = 1;
this.courseInfo.aiAbstract = 1;
this.courseInfo.aiDraft = 1;
this.courseInfo.aiTranslate = 1;
this.courseInfo.languageCode = ['zh-CN', 'en-US'];
} else {
// 获取ai设置信息
this.courseInfo.isAddAI = 0;
}
if(!this.courseInfo.orgId){
//根据课程创建者获取机构id
apiUser.getOrgSimpleByUserId(result.course.sysCreateAid).then(ors=>{

View File

@@ -119,7 +119,35 @@
</div>
<div class="player-time">{{ currentTimeFormat }} / {{ fullTimeFormat }}</div>
</div>
<!-- ai播放器相关 -->
<div class="player-controls-bottom-right">
<div class="player-controls-btn box-aiTranslate">
<div v-show="isSubtitle" class="player-controls-btn cursor-pointer btn-speed">
<span>{{!currentLang ? 'AI翻译' : currentLangLabel}}</span>
<div class="speed-control">
<ul class="speed-control-list">
<li
v-for="item in selectableLang"
:key="item.srclang"
@click="changeLang(item)"
:data-value="item.srclang"
class="one-line-ellipsis"
:title="item.label"
:class="{'current': currentLang === item.srclang}"
>{{ item.label }}</li>
</ul>
</div>
</div>
<div v-show="isSubtitle" style="margin-top: -3px;">|</div>
<div class="player-controls-btn" style="display: flex;gap: 0.3rem;align-items: center;">
<span>字幕</span>
<el-switch
@change="toggleSubtitle"
v-model="isSubtitle">
</el-switch>
</div>
<div style="margin-top: -3px;">|</div>
</div>
<div class="player-controls-btn cursor-pointer btn-speed">
<span>{{currentSpeed === 1 ? '倍速' : `${currentSpeed}x`}}</span>
<div class="speed-control">
@@ -224,6 +252,7 @@
import volumeBar from "@/components/VideoPlayer/volume-bar.vue";
import progressBar from "@/components/VideoPlayer/progress-bar.vue";
import playerBarrageScreen from "@/components/VideoPlayer/player-barrage-screen.vue";
import { mapGetters, mapMutations } from 'vuex';
export default {
name: "barrage-videoplayer",
@@ -301,10 +330,18 @@ export default {
fullTimeFormat: "00:00:00", // 视频总长度的文字
barrageTimelineStart: 0, // 弹幕时间轴的起始时间点(手动调整进度条触发更新)
isInit:false, // 是否初始化过
// ai播放器相关
isSubtitle: true, // 是否开启字幕
currentLangLabel:'', // 当前字幕语言
};
},
// ai播放器相关
computed: {
...mapGetters(['selectableLang','currentLang'])
},
created() {
// ai播放器相关
this.SET_currentLang('');
},
mounted() {
this.videoDom = this.$refs.video;
@@ -411,6 +448,11 @@ export default {
// });
},
methods: {
// ai播放器相关
...mapMutations({
SET_currentLang: 'video/SET_currentLang',
SET_currentTime: 'video/SET_currentTime',
}),
//当视频由于需要缓冲下一帧而停止,解决一直计时的问题
onWaiting(){
console.log('触发了onWairing');
@@ -624,6 +666,8 @@ export default {
},
onAudioTimeUpdate() {
const currentTime = this.$refs.video.currentTime;
// ai播放器相关
this.SET_currentTime(currentTime)
this.$emit('onTimeUpdate', currentTime);
},
/**
@@ -641,9 +685,77 @@ export default {
this.$emit('onFullscreen',false);//全屏
}
}
},
/** ai播放器相关
* 切换字幕
*/
toggleSubtitle(value) {
if (this.videoDom && this.videoDom.textTracks && this.videoDom.textTracks.length >0) {
if (!value) {
// 关闭字幕
this.videoDom.textTracks[this.videoDom.textTracks.length - 1].mode = 'hidden';
} else {
// 打开字幕
this.videoDom.textTracks[this.videoDom.textTracks.length - 1].mode = 'showing';
}
}
},
/** ai播放器相关
* 切换字幕语言
*/
changeLang(item) {
this.SET_currentLang(item.srclang);
this.currentLangLabel = item.label;
console.log("changeLang",item);
// 先移除所有字幕轨道
Array.from(this.videoDom.querySelectorAll('track')).forEach(t => t.remove());
if(!item.vttContent){
console.log("字幕内容为空!")
return;
}
if(!item.srcUrl){
try{
const blob = new Blob([item.vttContent], { type: 'text/vtt' });
item.srcUrl = URL.createObjectURL(blob);
}catch(e){
console.log("字幕格式错误",e)
}
}
const trackEl = document.createElement('track');
trackEl.kind = 'subtitles';
trackEl.srclang = item.srclang;
trackEl.label = item.label;
trackEl.src = item.srcUrl;
trackEl.default = true; // 确保字幕默认启用
// 使用箭头函数保持this上下文
trackEl.addEventListener('load', () => {
console.log('字幕加载成功!');
// console.log('#########Track cues:', trackEl.track.cues);
});
trackEl.addEventListener('error', () => {
console.error('字幕加载失败!');
});
// 确保视频已加载到可添加轨道的状态
if (this.videoDom.readyState >= 1) {
this.videoDom.appendChild(trackEl);
this.videoDom.textTracks[this.videoDom.textTracks.length - 1].mode = 'showing';
} else {
this.videoDom.addEventListener('loadedmetadata', () => {
this.videoDom.appendChild(trackEl);
this.videoDom.textTracks[this.videoDom.textTracks.length - 1].mode = 'showing';
}, { once: true });
}
},
seekToTime(time) {
if (!this.videoDom) return;
this.videoDom.currentTime = time + 0.01;
this.isPlaying = true;
this.videoDom.play();
},
}
},
watch: {
currentVolume: function () {
@@ -907,6 +1019,12 @@ export default {
color: #fff;
margin-bottom: 0.5rem;
}
.box-aiTranslate{
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
}
@media (device-width: 100vw) {
.player-controls-btn .player-controls-icon {
/* height: 26px; */

View File

@@ -3,22 +3,6 @@ import App from './App.vue'
import router from './router'
import store from './store'
import vueKatexEs from "vue-katex";
import "katex/dist/katex.min.css"
Vue.use(vueKatexEs,{
globalOptions:{
delimiters:[
{left:"$$",right:"$$",display:true},
{left:"$",right:"$",display:false},
{left:"\\[",right:"\\]",display:true},
{left:"\\(",right:"\\)",display:false}
],
throwOnError:true
}
})
//import './mock/index'
import xpage from '@/utils/xpage'

View File

@@ -28,5 +28,10 @@ const getters = {
studyTaskCount:state => state.user.studyTaskCount,
praisesUnicom:state =>state.pdf.praisesUnicom,
favoritesUnicom:state =>state.pdf.favoritesUnicom,
// ai播放器相关
selectAllLang:state => state.video.selectAllLang,
selectableLang:state => state.video.selectableLang,
currentLang:state => state.video.currentLang,
currentTime:state => state.video.currentTime,
}
export default getters

View File

@@ -12,6 +12,7 @@ import resOwner from './modules/resOwner'
import majorType from './modules/majorType'
import orgDomain from './modules/orgDomain'
import pdf from './modules/pdf'
import video from './modules/video' // ai播放器相关
Vue.use(Vuex)
@@ -27,7 +28,8 @@ const store = new Vuex.Store({
resOwner,
majorType,
orgDomain,
pdf
pdf,
video
},
getters
})

View File

@@ -7,11 +7,7 @@ const state = {
withoutAnimation: false
},
device: 'desktop',//默认是桌面以后会有android,ios,minapp
size: Cookies.get('size') || 'medium', //字段大小
// 添加AI Call组件显示控制状态
showAICall: false,
// 控制AI Call最小化窗口在特定路由下显示的状态
showAICallMinimized: false
size: Cookies.get('size') || 'medium' //字段大小
}
const mutations = {
@@ -38,14 +34,6 @@ const mutations = {
SET_SIZE: (state, size) => {
state.size = size
Cookies.set('size', size)
},
// 添加控制AI Call组件显示的mutation
SET_SHOW_AI_CALL: (state, show) => {
state.showAICall = show
},
// 控制AI Call最小化窗口显示的mutation
SET_SHOW_AI_CALL_MINIMIZED: (state, show) => {
state.showAICallMinimized = show
}
}
@@ -61,14 +49,6 @@ const actions = {
},
setSize({ commit }, size) {
commit('SET_SIZE', size)
},
// 添加控制AI Call组件显示的action
setShowAICall({ commit }, show) {
commit('SET_SHOW_AI_CALL', show)
},
// 控制AI Call最小化窗口显示的action
setShowAICallMinimized({ commit }, show) {
commit('SET_SHOW_AI_CALL_MINIMIZED', show)
}
}
@@ -77,4 +57,4 @@ export default {
state,
mutations,
actions
}
}

137
src/store/modules/video.js Normal file
View File

@@ -0,0 +1,137 @@
// ai播放器相关
const state = {
selectAllLang: [
{
key: 'ZH_CN',
srclang: 'zh-CN',
label: '中文',
name: '中文',
},
{
key: 'EN_US',
srclang: 'en-US',
label: '英语',
name: 'English',
},
{
key: 'JA_JP',
srclang: 'ja-JP',
label: '日语',
name: '日本語',
},
{
key: 'KO_KR',
srclang: 'ko-KR',
label: '韩语',
name: '한국어',
},
{
key: 'FR_FR',
srclang: 'fr-FR',
label: '法语',
name: 'français',
},
{
key: 'DE_DE',
srclang: 'de-DE',
label: '德语',
name: 'Deutsch',
},
{
key: 'ES_ES',
srclang: 'es-ES',
label: '西班牙语',
name: 'español',
},
{
key: 'RU_RU',
srclang: 'ru-RU',
label: '俄语',
name: 'русский',
},
{
key: 'PT_BR',
srclang: 'pt-BR',
label: '葡萄牙语',
name: 'português',
},
{
key: 'IT_IT',
srclang: 'it-IT',
label: '意大利语',
name: 'italiano',
},
{
key: 'AR_SA',
srclang: 'ar-SA',
label: '阿拉伯语',
name: 'العربية',
},
{
key: 'TH_TH',
srclang: 'th-TH',
label: '泰语',
name: 'ไทย',
},
{
key: 'VI_VN',
srclang: 'vi-VN',
label: '越南语',
name: 'tiếng Việt',
},
{
key: 'ID_ID',
srclang: 'id-ID',
label: '印度尼西亚语',
name: 'Bahasa Indonesia',
},
{
key: 'HI_IN',
srclang: 'hi-IN',
label: '印地语',
name: 'हिन्दी',
}
], // 全部语言列表
selectableLang: [], // 可选语言列表+字幕信息
currentLang: '', // 当前选中语言
currentTime: -1, // 当前视频时间
}
const mutations = {
SET_currentLang: (state, lang) => {
state.currentLang = lang
},
SET_selectableLang: (state, list = []) => {
let selectableLang = []
list.forEach(item => {
let selectItem = state.selectAllLang.find(selectItem => selectItem.srclang === item.language)
if (selectItem) {
selectableLang.push({
...item,
...selectItem,
})
}
})
state.selectableLang = selectableLang
},
SET_currentTime: (state, time) => {
state.currentTime = time
},
}
const actions = {
unicomPraises({ commit }, data) {
commit('SET_praisesUnicom', data)
},
unicomFavorites({ commit }, data) {
commit('SET_favoritesUnicom', data)
}
}
export default {
namespaced: true,
state,
mutations,
actions
}

View File

@@ -1,83 +0,0 @@
/**
* SSE流式数据处理工具
*/
/**
* 处理SSE响应数据
* @param {String} data - SSE响应数据
* @param {Function} onMessage - 处理消息的回调函数
* @param {Function} onComplete - 完成时的回调函数
* @param {Function} onError - 错误处理回调函数
*/
export function processSSEData(data, onMessage, onComplete, onError) {
try {
const lines = data.split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
const jsonData = JSON.parse(line.substring(6))
onMessage(jsonData)
// 如果状态为4表示对话结束
if (jsonData.data && jsonData.data.status === 4) {
onComplete()
}
}
}
} catch (error) {
console.error('处理SSE数据时出错:', error)
if (onError) {
onError(error)
}
}
}
/**
* 创建SSE连接
* @param {String} url - 请求地址
* @param {Object} data - 请求数据
* @param {Function} onMessage - 消息处理回调
* @param {Function} onComplete - 完成回调
* @param {Function} onError - 错误回调
* @returns {Promise} - 返回fetch Promise
*/
export function createSSEConnection(url, data, onMessage, onComplete, onError) {
return fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
}).then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8')
let buffer = ''
function read() {
reader.read().then(({ done, value }) => {
if (done) {
if (onComplete) onComplete()
return
}
buffer += decoder.decode(value, { stream: true })
processSSEData(buffer, onMessage, onComplete, onError)
// 继续读取
read()
}).catch(error => {
console.error('SSE读取错误:', error)
if (onError) onError(error)
})
}
// 开始读取数据
read()
}).catch(error => {
console.error('SSE连接错误:', error)
if (onError) onError(error)
})
}

View File

@@ -483,8 +483,11 @@ export default {
} else if (this.form.device2 === true) {
this.form.device = 2;
}
//时长,秒与分钟的转化
//if(this.form.)
// 时长,秒与分钟的转化
if (this.form.minute) {
this.form.duration = this.form.minute * 60;
}
try {
const { status,message} = await coueseFile.batchUpdate([this.form]);
if (status === 200) {

View File

@@ -60,101 +60,108 @@
</div>
<div style="width:390px">
<el-button type="primary" @click="searchData(true)" icon="el-icon-search" >搜索</el-button>
<el-button icon="el-icon-refresh-right" type="primary" style="margin-left:5px" @click="reset">重置</el-button>
<el-button icon="el-icon-refresh-right" type="primary" style="margin-left:5px" @click="reset">重置</el-button>
</div>
</div>
<el-row :gutter="20" style="margin-top:10px">
<el-col :span="4">
<!-- <el-button icon="el-icon-folder" type="primary" size="small">导出</el-button> -->
<el-button class="Create-coures" type="primary" @click="addNewCourse()" icon="el-icon-plus">新建课程</el-button>
</el-col >
<!-- ai播放器相关 -->
<el-col :span="24">
<!-- <el-button icon="el-icon-folder" type="primary" size="small">导出</el-button> -->
<el-button class="Create-coures" type="primary" @click="addNewCourse()" icon="el-icon-plus">新建课程</el-button>
<el-button type="primary" @click="setLanguage()" icon="el-icon-connection" :disabled="selectedCourses.length === 0">设置语种</el-button>
<el-button type="primary" @click="enableAI()" icon="el-icon-switch-button" :disabled="selectedCourses.length === 0">开启AI处理</el-button>
</el-col >
</el-row>
</div>
<div style="margin-right:30px;">
<el-table style="margin:10px 32px 10px 22px;" :data="pageData" border stripe>
<el-table-column label="序号" type="index" width="50"></el-table-column>
<el-table-column v-if="forChoose" label="选择" width="80">
<template slot-scope="scope" v-if="scope.row.published">
<el-button type="default" size="mini" @click="handleChoose(scope.row)">选择</el-button>
</template>
</el-table-column>
<el-table-column label="名称" prop="name" width="200" show-overflow-tooltip>
<template slot-scope="scope">
<span class="previewStyle" @click="viewTopic(scope.row)">{{ scope.row.name }}</span>
</template>
</el-table-column>
<el-table-column label="内容分类" prop="sysType" sortable width="240px">
<template slot-scope="scope">
<span>{{sysTypeName(scope.row.sysType1)}}</span>
<span v-if="scope.row.sysType2 !=''">/{{sysTypeName(scope.row.sysType2)}}</span>
<span v-if="scope.row.sysType3 !=''">/{{sysTypeName(scope.row.sysType3)}}</span>
</template>
</el-table-column>
<el-table-column label="关键字" :show-overflow-tooltip="true" prop="name" width="200px">
<template slot-scope="scope">
{{ scope.row.keywords }}
</template>
</el-table-column>
<!-- <el-table-column label="资源归属" sortable prop="author" width="240px">
<template slot-scope="scope">
<span>{{resOwnerName(scope.row.resOwner1)}}</span>
<span v-if="scope.row.resOwner2 != ''">/{{resOwnerName(scope.row.resOwner2)}}</span>
<span v-if="scope.row.resOwner3 != ''">/{{resOwnerName(scope.row.resOwner3)}}</span>
</template>
</el-table-column> -->
<!-- <el-table-column label="授课方式" prop="type" width="120px">
<template slot-scope="scope">
{{ courseType(scope.row.type)}}
</template>
</el-table-column> -->
<el-table-column label="状态" prop="status" width="120px">
<template slot-scope="scope">
<!-- 1未提交 2.已提交 = 未审核 5 已审核 -->
<span v-if="scope.row.status == 1">未提交</span>
<span v-if="scope.row.status == 2">待审核</span>
<span v-if="scope.row.status == 5">已审核</span>
<span v-if="scope.row.status == 3">审核未通过</span>
</template>
</el-table-column>
<el-table-column label="是否发布" width="130px">
<template slot-scope="scope">
{{ scope.row.published == true ? '已发布' : '未发布' }}
</template>
</el-table-column>
<el-table-column label="创建人" prop="sysCreateBy"></el-table-column>
<el-table-column label="创建时间" prop="sysCreateTime" width="230px" show-overflow-tooltip></el-table-column>
<el-table-column label="是否停用" width="130px">
<template slot-scope="scope">
{{ scope.row.enabled == true ? '启用' : '停用' }}
</template>
</el-table-column>
<el-table-column label="是否置顶" width="130px">
<template slot-scope="scope">
{{ scope.row.isTop == true ? '置顶' : '未置顶' }}
</template>
</el-table-column>
<el-table-column label="操作" width="180px" fixed="right">
<template slot-scope="scope" class="btn-gl">
<!-- 20240621 修改scope.row.isPermission = fasle 时不展示操作按钮-->
<el-button type="text" size="mini" v-if="scope.row.isPermission && scope.row.status == 5 && !scope.row.published" @click="releaseData(scope.row)">发布</el-button>
<el-button v-if="scope.row.isPermission && pageManage && scope.row.published" @click="showStudent(scope.row)" type="text" size="mini">学员</el-button>
<el-button v-if="scope.row.isPermission && !forChoose && scope.row.published" @click="showManageStudy(scope.row)" type="text" size="mini">管理</el-button>
<el-button v-if="scope.row.isPermission && !forChoose && scope.row.status == 2" @click="withdraw(scope.row)" type="text" size="mini">撤回</el-button>
<el-button v-if="scope.row.isPermission && scope.row.status != 2" type="text" size="mini" @click="editCurriculum(scope.row)">编辑</el-button>
<el-button v-if="scope.row.isPermission && (scope.row.status != 2 && !scope.row.published) || scope.row.isPermission &&!scope.row.enabled" type="text" size="mini" @click="delItem(scope.row)">删除</el-button>
<el-dropdown v-if="scope.row.isPermission && scope.row.published" type="text" size="mini" style="margin-left:10px">
<el-button type="text" size="mini">更多<i class="el-icon-arrow-down el-icon--right"></i></el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item @click.native="copyCourse(scope.row)">复制</el-dropdown-item>
<el-dropdown-item v-if="scope.row.published" @click.native="isDisable(scope.row)">{{scope.row.enabled? '停用':'启用'}}</el-dropdown-item>
<el-dropdown-item v-if="scope.row.published" @click.native="showQrimage(scope.row)">二维码</el-dropdown-item><!--发布之后才可以查看二维码-->
<el-dropdown-item v-if="scope.row.published" @click.native="setTop(scope.row)">{{scope.row.isTop? '取消置顶':'置顶'}}</el-dropdown-item>
<!-- ai播放器相关 -->
<el-table style="margin:10px 32px 10px 22px;" :data="pageData" border stripe @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55"></el-table-column>
<el-table-column label="序号" type="index" width="50"></el-table-column>
<el-table-column v-if="forChoose" label="选择" width="80">
<template slot-scope="scope" v-if="scope.row.published">
<el-button type="default" size="mini" @click="handleChoose(scope.row)">选择</el-button>
</template>
</el-table-column>
<el-table-column label="名称" prop="name" width="200" show-overflow-tooltip>
<template slot-scope="scope">
<span class="previewStyle" @click="viewTopic(scope.row)">{{ scope.row.name }}</span>
</template>
</el-table-column>
<el-table-column label="内容分类" prop="sysType" sortable width="240px">
<template slot-scope="scope">
<span>{{sysTypeName(scope.row.sysType1)}}</span>
<span v-if="scope.row.sysType2 !=''">/{{sysTypeName(scope.row.sysType2)}}</span>
<span v-if="scope.row.sysType3 !=''">/{{sysTypeName(scope.row.sysType3)}}</span>
</template>
</el-table-column>
<el-table-column label="关键字" :show-overflow-tooltip="true" prop="name" width="200px">
<template slot-scope="scope">
{{ scope.row.keywords }}
</template>
</el-table-column>
<!-- <el-table-column label="资源归属" sortable prop="author" width="240px">
<template slot-scope="scope">
<span>{{resOwnerName(scope.row.resOwner1)}}</span>
<span v-if="scope.row.resOwner2 != ''">/{{resOwnerName(scope.row.resOwner2)}}</span>
<span v-if="scope.row.resOwner3 != ''">/{{resOwnerName(scope.row.resOwner3)}}</span>
</template>
</el-table-column> -->
<!-- <el-table-column label="授课方式" prop="type" width="120px">
<template slot-scope="scope">
{{ courseType(scope.row.type)}}
</template>
</el-table-column> -->
<el-table-column label="状态" prop="status" width="120px">
<template slot-scope="scope">
<!-- 1未提交 2.已提交 = 未审核 5 已审核 -->
<span v-if="scope.row.status == 1">未提交</span>
<span v-if="scope.row.status == 2">审核</span>
<span v-if="scope.row.status == 5">已审核</span>
<span v-if="scope.row.status == 3">审核未通过</span>
</template>
</el-table-column>
<el-table-column label="是否发布" width="130px">
<template slot-scope="scope">
{{ scope.row.published == true ? '已发布' : '未发布' }}
</template>
</el-table-column>
<el-table-column label="创建人" prop="sysCreateBy"></el-table-column>
<el-table-column label="创建时间" prop="sysCreateTime" width="230px" show-overflow-tooltip></el-table-column>
<el-table-column label="是否停用" width="130px">
<template slot-scope="scope">
{{ scope.row.enabled == true ? '启用' : '停用' }}
</template>
</el-table-column>
<el-table-column label="是否置顶" width="130px">
<template slot-scope="scope">
{{ scope.row.isTop == true ? '置顶' : '未置顶' }}
</template>
</el-table-column>
<el-table-column label="操作" width="180px" fixed="right">
<template slot-scope="scope" class="btn-gl">
<!-- 20240621 修改scope.row.isPermission = fasle 时不展示操作按钮-->
<!-- ai播放器相关 -->
<el-button v-if="scope.row.isPermission && scope.row.status != 2" type="text" size="mini" @click="setAI(scope.row)">AI设置</el-button>
<el-button type="text" size="mini" v-if="scope.row.isPermission && scope.row.status == 5 && !scope.row.published" @click="releaseData(scope.row)">发布</el-button>
<el-button v-if="scope.row.isPermission && pageManage && scope.row.published" @click="showStudent(scope.row)" type="text" size="mini">学员</el-button>
<el-button v-if="scope.row.isPermission && !forChoose && scope.row.published" @click="showManageStudy(scope.row)" type="text" size="mini">管理</el-button>
<el-button v-if="scope.row.isPermission && !forChoose && scope.row.status == 2" @click="withdraw(scope.row)" type="text" size="mini">撤回</el-button>
<el-button v-if="scope.row.isPermission && scope.row.status != 2" type="text" size="mini" @click="editCurriculum(scope.row)">编辑</el-button>
<el-button v-if="scope.row.isPermission && (scope.row.status != 2 && !scope.row.published) || scope.row.isPermission &&!scope.row.enabled" type="text" size="mini" @click="delItem(scope.row)">删除</el-button>
<el-dropdown v-if="scope.row.isPermission && scope.row.published" type="text" size="mini" style="margin-left:10px">
<el-button type="text" size="mini">更多<i class="el-icon-arrow-down el-icon--right"></i></el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item @click.native="copyCourse(scope.row)">复制</el-dropdown-item>
<el-dropdown-item v-if="scope.row.published" @click.native="isDisable(scope.row)">{{scope.row.enabled? '停用':'启用'}}</el-dropdown-item>
<el-dropdown-item v-if="scope.row.published" @click.native="showQrimage(scope.row)">二维码</el-dropdown-item><!--发布之后才可以查看二维码-->
<el-dropdown-item v-if="scope.row.published" @click.native="setTop(scope.row)">{{scope.row.isTop? '取消置顶':'置顶'}}</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</template>
</el-table-column>
</el-table>
</el-dropdown-menu>
</el-dropdown>
</template>
</el-table-column>
</el-table>
</div>
@@ -287,6 +294,231 @@
<div>
<course-form ref="courseForm" @submitSuccess="searchData" @close="searchData"></course-form>
</div>
<!-- ai播放器相关 -->
<!-- 设置语种弹框 -->
<el-dialog
title="AI翻译"
:visible.sync="languageSetting.dlgShow"
width="500px"
:close-on-click-modal="false"
>
<div style="margin-bottom: 20px;">
<div style="margin-bottom: 15px;">请选择课程所支持语种</div>
<el-select
v-model="languageSetting.selectedLanguages"
multiple
placeholder="请选择语种"
style="width: 100%;"
>
<el-option
v-for="lang in selectAllLang"
:key="lang.srclang"
:label="lang.label"
:value="lang.srclang"
></el-option>
</el-select>
</div>
<div style="color: #ff4d4f; font-size: 12px;">
仅支持对已开启AI处理的课程进行批量语种设置所选的课程中有{{languageSetting.aiSetNoNum}}个未开启AI处理的课程以上配置仅对{{languageSetting.aiSetNum}}个已开启AI处理的课程生效
</div>
<template #footer>
<el-button @click="languageSetting.dlgShow = false">取消</el-button>
<el-button type="primary" @click="confirmLanguageSetting">确认</el-button>
</template>
</el-dialog>
<!-- 开启AI处理弹框 -->
<el-dialog
title="开启AI处理"
:visible.sync="aiProcessSetting.dlgShow"
width="400px"
>
<div class="ai-process-dialog">
<!-- AI处理状态 -->
<div class="form-item">
<span class="form-label">
<el-tooltip class="item" effect="dark" :content="aiSetTip" placement="top">
<i class="el-icon-question"></i>
</el-tooltip>
AI处理
</span>
<span class="status-text">
{{ aiProcessSetting.aiSet === 1 ? '开启' : '关闭' }}
</span>
<el-switch
v-model="aiProcessSetting.aiSet"
:active-value="1"
:inactive-value="0"
></el-switch>
</div>
<div v-show="aiProcessSetting.aiSet === 1">
<!-- 是否生成AI摘要 -->
<div class="form-item">
<span class="form-label">
<el-tooltip class="item" effect="dark" :content="aiAbstractTip" placement="top">
<i class="el-icon-question"></i>
</el-tooltip>
AI摘要
</span>
<span class="status-text">
{{ aiProcessSetting.aiAbstract === 1 ? '开启' : '关闭' }}
</span>
<el-switch
v-model="aiProcessSetting.aiAbstract"
:active-value="1"
:inactive-value="0"
>
</el-switch>
</div>
<!-- 是否生成AI文稿 -->
<div class="form-item">
<span class="form-label">
<el-tooltip class="item" effect="dark" :content="aiDraftTip" placement="top">
<i class="el-icon-question"></i>
</el-tooltip>
AI文稿
</span>
<span class="status-text">
{{ aiProcessSetting.aiDraft === 1 ? '开启' : '关闭' }}
</span>
<el-switch
v-model="aiProcessSetting.aiDraft"
:active-value="1"
:inactive-value="0"
>
</el-switch>
</div>
<!-- 课程支持语种选择 -->
<div class="form-item" style="flex-flow: column;align-items: baseline;">
<span class="form-label">
<el-tooltip class="item" effect="dark" :content="aiTranslateTip" placement="top">
<i class="el-icon-question"></i>
</el-tooltip>
AI翻译语种
</span>
<el-select
v-model="aiProcessSetting.languageCode"
multiple
style="width: 100%;"
placeholder="请选择语种"
>
<el-option
v-for="lang in selectAllLang"
:key="lang.srclang"
:label="lang.label"
:value="lang.srclang"
></el-option>
</el-select>
</div>
</div>
<!-- 提示信息 -->
<div class="tips">
<span>已跳过{{aiProcessSetting.aiSetNum}}个已开启AI处理的课程仅更新剩余{{aiProcessSetting.aiSetNoNum}}</span>
</div>
</div>
<span slot="footer" class="dialog-footer">
<el-button @click="aiProcessSetting.dlgShow = false">取消</el-button>
<el-button type="primary" @click="confirmAiProcess">确认</el-button>
</span>
</el-dialog>
<!-- AI设置弹框 -->
<el-dialog
title="AI设置"
:visible.sync="aiSetting.dlgShow"
width="500px"
>
<div class="ai-setting-dialog">
<!-- AI功能状态 -->
<div class="form-item">
<span class="form-label">
<el-tooltip class="item" effect="dark" content="是否将课程进行AI处理" placement="top">
<i class="el-icon-question"></i>
</el-tooltip>
AI功能状态
</span>
<span class="status-text">
{{ aiSetting.aiSet === 1 ? '已开放' : '未开放' }}
</span>
<el-switch
v-model="aiSetting.aiSet"
:active-value="1"
:inactive-value="0"
></el-switch>
</div>
<div v-show="aiSetting.aiSet === 1">
<!-- AI摘要状态 -->
<div class="form-item">
<span class="form-label">AI摘要状态</span>
<span class="status-badge" :class="{'status-badge--active': aiSetting.aiAbstract === 1}">
{{ aiSetting.aiAbstract === 1 ? '已上架' : '已下架' }}
</span>
<div class="action-buttons">
<el-button type="text" @click="aiSetting.aiAbstract = 0">
{{ aiSetting.aiAbstract === 0 ? '上架' : '下架' }}
</el-button>
<el-button type="text" >编辑</el-button>
</div>
</div>
<!-- AI文稿状态 -->
<div class="form-item">
<span class="form-label">AI文稿状态</span>
<span class="status-badge" :class="{'status-badge--active': aiSetting.aiDraft === 1}">
{{ aiSetting.aiDraft === 1 ? '已上架' : '已下架' }}
</span>
<div class="action-buttons">
<el-button type="text" @click="aiSetting.aiDraft = 0">
{{ aiSetting.aiDraft === 0 ? '上架' : '下架' }}
</el-button>
</div>
</div>
<!-- AI翻译状态 -->
<div class="form-item">
<span class="form-label">AI翻译状态</span>
<span class="status-badge" :class="{'status-badge--active': aiSetting.aiTranslate === 1}">
{{ aiSetting.aiTranslate === 1 ? '已上架' : '已下架' }}
</span>
<div class="action-buttons">
<el-button type="text" @click="aiSetting.aiTranslate = 0">
{{ aiSetting.aiTranslate === 0 ? '上架' : '下架' }}
</el-button>
<el-button type="text" >编辑</el-button>
</div>
</div>
<!-- 支持语种 -->
<div class="form-item">
<span class="form-label">支持语种</span>
<div class="languages-list">
<div class="language-tag">
中文
<span class="status-badge">已下架</span>
</div>
<div class="language-tag">
英语
<span class="status-badge">生成中</span>
</div>
<div class="language-tag">
越南语
<span class="status-badge status-badge--active">已上架</span>
</div>
</div>
</div>
</div>
</div>
<span slot="footer" class="dialog-footer">
<el-button @click="aiSetting.dlgShow = false">取消</el-button>
<el-button type="primary" @click="confirmAISetting">确认</el-button>
</span>
</el-dialog>
</div>
</template>
@@ -307,8 +539,9 @@ import apiUserbasic from "@/api/boe/userbasic.js"
export default {
name: 'manageCourse',
components: {courseForm, manager, auditCourse1, auditCourse2,adminPage},
// ai播放器相关
computed: {
...mapGetters(['resOwnerMap','sysTypeMap','userInfo']),
...mapGetters(['resOwnerMap','sysTypeMap','userInfo', 'selectAllLang']),
},
data() {
return {
@@ -392,6 +625,32 @@ export default {
},
extendRefId:'',
extendRefType:'',
// ai播放器相关
selectedCourses: [], //已选课程
languageSetting: { // 设置语种弹框
dlgShow: false,
languageCode: ['zh-CN', 'en-US'] // 默认选中的语种
},
aiProcessSetting: { // 开启AI处理弹框
dlgShow: true,
aiSet: 1,
aiAbstract: 1,
aiDraft: 1,
languageCode: ['zh-CN', 'en-US'] // 默认选中的语种
},
aiSetting: { // AI设置弹框
dlgShow: false,
courseId: '',
aiSet: 1,
aiAbstract: 1, // 1:上架 0:下架
aiDraft: 1, // 1:上架 0:下架
aiTranslate: 1, // 1:上架 0:下架
languageCode: ['zh-CN', 'en-US', 'vi-VN'] // 支持的语种
},
aiSetTip: '是否将课程进行AI处理', //提示信息
aiAbstractTip: '一键提炼课程视频核心要点,助力学员课前高效掌握重点,快速筛选学习资源', // 提示信息
aiDraftTip: '分段展示视频内容并精准同步时间轴,实现视频进度与文稿双向定位,学习内容触手可及', //提示信息
aiTranslateTip: '智能转换视频字幕与语音为多语种,支持全球学员按需切换语言,打破学习边界', // 提示信息
};
},
mounted() {
@@ -449,6 +708,7 @@ export default {
inputOn() {
this.$forceUpdate();
},
// 置顶
setTop(row) {
let params = {
@@ -885,6 +1145,143 @@ export default {
saveNewCatalogZhang() {
this.catalogs.addNewZhang = false;
},
// ai播放器相关
handleSelectionChange(val){
this.selectedCourses = val;
console.log(val);
},
// 获取选中课程的AI信息
getAIInfoByList(list = []) {
let selectNum = 0; // 选中的课程数量
let aiSetNum = 0; // 已设置AI的课程数量
let aiSetNoNum = 0; // 未设置AI的课程数量
list.forEach(item => {
if(item.aiSet === 1){
aiSetNum++;
}else{
aiSetNoNum++;
}
});
return {
selectNum,
aiSetNum,
aiSetNoNum
}
},
// AI设置
setAI(row) {
this.aiSetting.courseId = row.id;
// 这里可以添加获取当前课程AI设置的逻辑
this.aiSetting.dlgShow = true;
},
// 确认AI设置
confirmAISetting() {
const { courseId, aiSet, aiAbstract, aiDraft, aiTranslate } = this.aiSetting;
// 这里可以添加保存AI设置的API调用
console.log('保存AI设置', {
courseId,
aiSet,
aiAbstract,
aiDraft,
aiTranslate
});
// 模拟成功保存
this.$message.success('AI设置保存成功');
this.aiSetting.dlgShow = false;
// 可以选择是否刷新列表数据
},
setLanguage() {
if (this.selectedCourses.length > 0) {
this.languageSetting = {...this.languageSetting, ...this.getAIInfoByList(this.selectedCourses)}
this.languageSetting.dlgShow = true;
}
},
enableAI() {
// 开启AI处理按钮点击事件
if (this.selectedCourses.length > 0) {
this.aiProcessSetting = {...this.aiProcessSetting, ...this.getAIInfoByList(this.selectedCourses)}
this.aiProcessSetting.dlgShow = true;
}
},
async confirmLanguageSetting() {
try {
// 收集选中的课程ID
const courseIds = this.selectedCourses.map(course => course.id).join(',');
// 获取选择的语种
const languages = this.languageSetting.selectedLanguages.join(',');
// 这里可以根据实际需求调用API进行语种设置
console.log('批量设置课程语种', { courseIds, languages });
// 显示成功提示
this.$message({
type: 'success',
message: `已成功为${this.selectedCourses.length}个课程设置语种`,
offset: 50
});
// 关闭弹框
this.languageSetting.dlgShow = false;
// 刷新数据(如果需要)
// this.searchData();
} catch (error) {
console.error('设置语种失败', error);
this.$message({
type: 'error',
message: '设置语种失败,请稍后重试',
offset: 50
});
}
},
async confirmAiProcess() {
try {
// 收集选中的课程ID
const courseIds = this.selectedCourses.map(course => course.id).join(',');
// 获取AI处理配置
const { aiSet, aiAbstract, aiDraft, selectedLanguages } = this.aiProcessSetting;
// 这里可以根据实际需求调用API进行AI处理设置
console.log('批量开启AI处理', {
courseIds,
aiSet,
aiAbstract,
aiDraft,
languages: selectedLanguages.join(',')
});
// 模拟计算跳过和更新的课程数量
const totalCourses = this.selectedCourses.length;
const skippedCount = Math.floor(totalCourses * 0.2); // 假设20%的课程已开启AI处理
const updatedCount = totalCourses - skippedCount;
// 显示成功提示
this.$message({
type: 'success',
message: `已跳过${skippedCount}个已开启AI处理的课程成功为${updatedCount}个课程更新AI处理设置`,
offset: 50
});
// 关闭弹框
this.aiProcessSetting.dlgShow = false;
// 刷新数据
this.searchData();
} catch (error) {
console.error('开启AI处理失败', error);
this.$message({
type: 'error',
message: '开启AI处理失败请稍后重试',
offset: 50
});
}
},
}
};
</script>
@@ -953,4 +1350,38 @@ export default {
.el-dialog__body {
overflow: hidden;
}
.form-item{
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
}
.tips {
color: #f56c6c;
font-size: 12px;
margin: 10px 0;
line-height: 1.5;
}
.status-badge{
display: inline-block;
padding: 3px 13px;
border-radius: 20px;
font-size: 12px;
background: rgba(254, 249, 195, 1);
color: rgba(133, 77, 14, 1);
font-size: 12px;
font-weight: 500;
line-height: 17px;
letter-spacing: 0px;
}
.status-badge--active{
background: rgba(220, 252, 231, 1);
color: rgba(22, 101, 52, 1);
}
.languages-list{
display: flex;
flex-wrap: wrap;
gap: 20px;
}
</style>

View File

@@ -1,873 +0,0 @@
<template>
<div>
<!-- 最大化状态的弹窗 -->
<el-dialog
v-show=" windowState === 'maximized'"
v-if="dialogVisible"
:visible="true"
:close-on-click-modal="false"
:show-close="true"
@close="onClose"
class="case-expert-dialog"
:modal="false"
:append-to-body="true"
:fullscreen="false"
top="10vh"
v-resizeable
v-draggable
>
<!-- 标题 -->
<div slot="title" class="dialog-title">
<span>案例专家</span>
<el-button
style="color:#96999f"
type="text"
class="window-control-btn"
@click="minimizeWindow"
>
<i class="el-icon-minus"></i>
</el-button>
</div>
<!-- 内容区域 -->
<div class="content-wrapper">
<div
class="welcome-message"
ref="messageContainer"
@scroll="handleScroll"
>
<div class="message-text" v-for="(item, index) in messageList" :key="index">
<messages :messageData="item" :suggestions="suggestions" @getMinWindow="minimizeWindow"></messages>
</div>
<div class="message-suggestions" v-if="messageList.length > 0 && messageList[messageList.length-1].textCompleted">
<div class="suggestion-item" v-for="(item, index) in suggestions" :key="index">
<a @click="sendSuggestions(item)"> {{ item }} </a>
</div>
</div>
<div v-if="isLoading" class="loading-message">
<div class="loading-dots">
<span></span>
<span></span>
<span></span>
</div>
</div>
</div>
<!-- 输入框区域 -->
<send-message
v-model="AIContent"
:message-list="messageList"
:suggestions="suggestions"
@loading="handleLoading"
@update-message="updateMessage"
@update-suggestions="updateSuggestions"
@new-conversation="startNewConversation"
:disabled="isLoading"
class="input-area-wrapper"
ref="sendMessage"
/>
</div>
</el-dialog>
<!-- 最小化状态的弹窗 -->
<div
class="minimized-window"
v-show="windowState === 'minimized' && showMinimizedWindow"
@click="onMinimizedWindowClick"
>
<div class="minimized-content">
<span class="window-title">案例专家</span>
<div style="display: flex;align-items: center">
<el-button
type="text"
class="window-control-btn"
@click.stop="onMinimizedWindowClick"
>
<img :src="openImg" alt="" style="width: 17px">
</el-button>
<el-button
style="margin-left: 1px;color:#96999f"
type="text"
class="window-control-btn"
@click.stop="closeMinimizedWindow"
>
<i class="el-icon-close"></i>
</el-button>
</div>
</div>
<div class="minimized-message">
<div v-if="messageList.length <= 1 && messageList[0].isBot">
当前暂无对话内容去创建对话吧
</div>
<div v-else>
{{ getLastUserMessage() }}
</div>
</div>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex'
import messages from './components/messages.vue'
import sendMessage from './components/sendMessage.vue'
import openImg from './components/open.png'
export default {
name: 'CaseExpertDialog',
props: {
dialogVisible: {
type: Boolean,
default: false
}
},
components: {
messages,
sendMessage
},
directives: {
draggable: {
bind(el, binding, vnode) {
vnode.context.$nextTick(() => {
const dialogEl = el.querySelector('.el-dialog');
if (!dialogEl) return;
const headerEl = dialogEl.querySelector('.dialog-title');
if (!headerEl) return;
// 检查是否有保存的位置状态
const savedPosition = sessionStorage.getItem('aiCallDialogPosition');
if (savedPosition) {
const { left, top } = JSON.parse(savedPosition);
dialogEl.style.left = left + 'px';
dialogEl.style.top = top + 'px';
} else {
// 设置初始样式
dialogEl.style.position = 'fixed';
dialogEl.style.top = '100px';
dialogEl.style.left = (window.innerWidth - dialogEl.offsetWidth) / 2 + 'px';
}
dialogEl.style.margin = '0';
let isDragging = false;
let startX = 0;
let startY = 0;
let startLeft = 0;
let startTop = 0;
const startDrag = (event) => {
// 只有在标题栏上按下鼠标才开始拖动
if (event.target.closest('.resize-handle')) {
return; // 如果点击的是resize-handle则不触发拖动
}
isDragging = true;
startX = event.clientX;
startY = event.clientY;
startLeft = parseInt(dialogEl.style.left) || dialogEl.offsetLeft;
startTop = parseInt(dialogEl.style.top) || dialogEl.offsetTop;
event.preventDefault();
event.stopPropagation();
// 添加全局事件监听
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', stopDrag);
};
const handleMouseMove = (event) => {
if (!isDragging) return;
const deltaX = event.clientX - startX;
const deltaY = event.clientY - startY;
dialogEl.style.left = (startLeft + deltaX) + 'px';
dialogEl.style.top = (startTop + deltaY) + 'px';
};
const stopDrag = () => {
isDragging = false;
// 保存当前位置到 sessionStorage
const currentPosition = {
left: parseInt(dialogEl.style.left),
top: parseInt(dialogEl.style.top)
};
sessionStorage.setItem('aiCallDialogPosition', JSON.stringify(currentPosition));
// 移除全局事件监听
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', stopDrag);
};
// 为标题栏绑定拖动事件
headerEl.addEventListener('mousedown', startDrag);
});
}
},
resizeable: {
bind(el, binding, vnode) {
// 确保元素已插入DOM
vnode.context.$nextTick(() => {
const dialogEl = el.querySelector('.el-dialog');
if (!dialogEl) return;
// 检查是否有保存的尺寸状态
const savedSize = sessionStorage.getItem('aiCallDialogSize');
if (savedSize) {
const { width, height, left, top } = JSON.parse(savedSize);
dialogEl.style.width = width + 'px';
dialogEl.style.height = height + 'px';
dialogEl.style.left = left + 'px';
dialogEl.style.top = top + 'px';
} else {
// 设置初始样式
dialogEl.style.position = 'fixed';
dialogEl.style.top = '100px';
dialogEl.style.left = (window.innerWidth - dialogEl.offsetWidth) / 2 + 'px';
}
// 创建拖拽手柄
const createHandle = (direction) => {
const handle = document.createElement('div');
handle.className = `resize-handle ${direction}`;
handle.style.position = 'absolute';
handle.style.zIndex = '10';
switch (direction) {
case 'left':
case 'right':
handle.style.width = '6px';
handle.style.height = '100%';
handle.style.top = '0';
handle.style.cursor = 'ew-resize';
break;
case 'top':
case 'bottom':
handle.style.width = '100%';
handle.style.height = '6px';
handle.style.left = '0';
handle.style.cursor = 'ns-resize';
break;
case 'top-left':
case 'top-right':
case 'bottom-left':
case 'bottom-right':
handle.style.width = '10px';
handle.style.height = '10px';
handle.style.zIndex = '20';
break;
}
switch (direction) {
case 'left':
handle.style.left = '0';
break;
case 'right':
handle.style.right = '0';
break;
case 'top':
handle.style.top = '0';
break;
case 'bottom':
handle.style.bottom = '0';
break;
case 'top-left':
handle.style.top = '0';
handle.style.left = '0';
handle.style.cursor = 'nw-resize';
break;
case 'top-right':
handle.style.top = '0';
handle.style.right = '0';
handle.style.cursor = 'ne-resize';
break;
case 'bottom-left':
handle.style.bottom = '0';
handle.style.left = '0';
handle.style.cursor = 'sw-resize';
break;
case 'bottom-right':
handle.style.bottom = '0';
handle.style.right = '0';
handle.style.cursor = 'se-resize';
break;
}
// 防止拖拽手柄的事件冒泡到标题栏
handle.addEventListener('mousedown', (e) => {
e.stopPropagation();
});
dialogEl.appendChild(handle);
return handle;
};
// 创建8个拖拽手柄
const handles = {
left: createHandle('left'),
right: createHandle('right'),
top: createHandle('top'),
bottom: createHandle('bottom'),
topLeft: createHandle('top-left'),
topRight: createHandle('top-right'),
bottomLeft: createHandle('bottom-left'),
bottomRight: createHandle('bottom-right')
};
// 添加拖拽事件处理
let isResizing = false;
let resizeDirection = '';
let startX = 0;
let startY = 0;
let startWidth = 0;
let startHeight = 0;
let startLeft = 0;
let startTop = 0;
const startResize = (direction, event) => {
event.preventDefault();
event.stopPropagation();
isResizing = true;
resizeDirection = direction;
startX = event.clientX;
startY = event.clientY;
startWidth = dialogEl.offsetWidth;
startHeight = dialogEl.offsetHeight;
// 统一使用计算后的样式值
startLeft = parseInt(dialogEl.style.left) || 0;
startTop = parseInt(dialogEl.style.top) || 0;
// 添加全局事件监听
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', stopResize);
};
const handleMouseMove = (event) => {
if (!isResizing) return;
const deltaX = event.clientX - startX;
const deltaY = event.clientY - startY;
let newWidth, newHeight, newLeft, newTop;
switch (resizeDirection) {
case 'right':
newWidth = Math.max(400, startWidth + deltaX);
dialogEl.style.width = newWidth + 'px';
break;
case 'left':
newWidth = Math.max(400, startWidth - deltaX);
newLeft = startLeft + startWidth - newWidth;
dialogEl.style.width = newWidth + 'px';
dialogEl.style.left = newLeft + 'px';
break;
case 'bottom':
newHeight = Math.max(600, startHeight + deltaY);
dialogEl.style.height = newHeight + 'px';
break;
case 'top':
// 当窗口高度达到最小值时,不再调整高度和位置
if (startHeight - deltaY >= 600) {
newHeight = startHeight - deltaY;
newTop = startTop + deltaY;
dialogEl.style.height = newHeight + 'px';
dialogEl.style.top = newTop + 'px';
}
break;
case 'bottom-right':
newWidth = Math.max(400, startWidth + deltaX);
newHeight = Math.max(600, startHeight + deltaY);
dialogEl.style.width = newWidth + 'px';
dialogEl.style.height = newHeight + 'px';
break;
case 'bottom-left':
newWidth = Math.max(400, startWidth - deltaX);
newHeight = Math.max(600, startHeight + deltaY);
newLeft = startLeft + startWidth - newWidth;
dialogEl.style.width = newWidth + 'px';
dialogEl.style.left = newLeft + 'px';
dialogEl.style.height = newHeight + 'px';
break;
case 'top-right':
// 当窗口高度达到最小值时,不再调整高度和位置
if (startHeight - deltaY >= 600) {
newHeight = startHeight - deltaY;
newTop = startTop + deltaY;
newWidth = Math.max(400, startWidth + deltaX);
dialogEl.style.height = newHeight + 'px';
dialogEl.style.top = newTop + 'px';
dialogEl.style.width = newWidth + 'px';
}
break;
case 'top-left':
// 当窗口高度达到最小值时,不再调整高度和位置
if (startHeight - deltaY >= 600) {
newHeight = startHeight - deltaY;
newTop = startTop + deltaY;
newWidth = Math.max(400, startWidth - deltaX);
newLeft = startLeft + startWidth - newWidth;
dialogEl.style.height = newHeight + 'px';
dialogEl.style.top = newTop + 'px';
dialogEl.style.width = newWidth + 'px';
dialogEl.style.left = newLeft + 'px';
}
break;
}
let doc = document.querySelector('.welcome-message')
let sendBox = document.querySelector('.input-area-wrapper');
// sendBox 的高度
if (doc && sendBox) {
doc.style.height = `calc(${dialogEl.style.height} - ${sendBox.offsetHeight}px - 120px)`;
}
};
const stopResize = () => {
isResizing = false;
resizeDirection = '';
// 保存当前尺寸和位置到 sessionStorage
const currentSize = {
width: parseInt(dialogEl.style.width),
height: parseInt(dialogEl.style.height),
left: parseInt(dialogEl.style.left),
top: parseInt(dialogEl.style.top)
};
sessionStorage.setItem('aiCallDialogSize', JSON.stringify(currentSize));
// 移除全局事件监听
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', stopResize);
};
// 为每个手柄绑定事件
handles.left.addEventListener('mousedown', (e) => startResize('left', e));
handles.right.addEventListener('mousedown', (e) => startResize('right', e));
handles.top.addEventListener('mousedown', (e) => startResize('top', e));
handles.bottom.addEventListener('mousedown', (e) => startResize('bottom', e));
handles.topLeft.addEventListener('mousedown', (e) => startResize('top-left', e));
handles.topRight.addEventListener('mousedown', (e) => startResize('top-right', e));
handles.bottomLeft.addEventListener('mousedown', (e) => startResize('bottom-left', e));
handles.bottomRight.addEventListener('mousedown', (e) => startResize('bottom-right', e));
});
}
}
},
computed: {
...mapState('app', ['showAICallMinimized']),
showMinimizedWindow() {
// 只有在Vuex状态为true时才显示最小化窗口
return this.showAICallMinimized;
}
},
data() {
return {
openImg,
AIContent: '',
isLoading: false,
windowState: 'maximized', // 'maximized' 或 'minimized'
messageList: [
{
typing:true,
isBot: true, // 是否为机器人
text: `<p><b>您好!我是京东方案例智能问答助手,随时为您服务。</b></p>
<p>我可以帮您快速查找和解读平台内的各类案例内容。只需输入您想了解的问题或关键词,我会从案例库中精准匹配相关信息,并提供清晰的解答。每条回答都会附上来源链接,方便您随时查阅原始案例全文。</p>
<p>我还会根据您的提问,智能推荐相关延伸问题,助您更高效地探索知识、解决问题。</p>
<p>现在,欢迎随时向我提问,开启高效的知识查询体验吧!</p>`
}
],
suggestions:[],
isAutoScroll: true, // 是否自动滚动
// 添加一个标志位,用于标识组件是否已经初始化完成
isComponentReady: false
}
},
mounted() {
// 组件挂载完成后,标记为已准备就绪
this.$nextTick(() => {
this.isComponentReady = true;
});
},
watch: {
dialogVisible: {
handler(newVal) {
if (newVal) {
this.$nextTick(() => {
// 获取对话框元素
const dialogEl = document.querySelector('.case-expert-dialog .el-dialog');
if (dialogEl) {
// 检查是否有保存的尺寸状态
const savedSize = sessionStorage.getItem('aiCallDialogSize');
if (savedSize) {
const { width, height, left, top } = JSON.parse(savedSize);
dialogEl.style.width = width + 'px';
dialogEl.style.height = height + 'px';
dialogEl.style.left = left + 'px';
dialogEl.style.top = top + 'px';
}
// 检查是否有保存的位置状态
const savedPosition = sessionStorage.getItem('aiCallDialogPosition');
if (savedPosition) {
const { left, top } = JSON.parse(savedPosition);
dialogEl.style.left = left + 'px';
dialogEl.style.top = top + 'px';
}
}
let doc = document.querySelector('.welcome-message')
let sendBox = document.querySelector('.input-area-wrapper');
// 只有在没有保存的尺寸状态时才使用默认值
if (doc && sendBox) {
const savedSize = sessionStorage.getItem('aiCallDialogSize');
if (!savedSize) {
doc.style.height = `calc(600px - ${sendBox.offsetHeight}px - 120px)`;
} else {
const { height } = JSON.parse(savedSize);
doc.style.height = `calc(${height}px - ${sendBox.offsetHeight}px - 120px)`;
}
}
});
}
},
immediate: true
},
messageList: {
handler() {
// 只有在组件准备就绪后才执行滚动操作
if (this.isComponentReady) {
this.$nextTick(() => {
this.scrollToBottom();
});
}
},
deep: true
}
},
methods: {
// / 关闭最小化窗口
closeMinimizedWindow() {
this.$store.commit('app/SET_SHOW_AI_CALL_MINIMIZED', false);
this.$store.commit('app/SET_SHOW_AI_CALL', false);
this.windowState = 'maximized';
},
getMinWidow(vis){
// this.showAICallMinimized = vis
this.windowState = 'minimized';
},
onClose() {
console.log('关闭弹窗')
// 清除保存的状态
sessionStorage.removeItem('aiCallDialogSize');
sessionStorage.removeItem('aiCallDialogPosition');
this.$emit('close')
// 可以在这里执行其他逻辑
},
minimizeWindow() {
this.windowState = 'minimized';
this.$store.commit('app/SET_SHOW_AI_CALL_MINIMIZED', true);
},
maximizeWindow() {
this.windowState = 'maximized';
},
getLastUserMessage() {
// 从后往前找用户消息
for (let i = this.messageList.length - 1; i >= 0; i--) {
if (!this.messageList[i].isBot) {
// 移除HTML标签只返回纯文本
const tempDiv = document.createElement('div');
tempDiv.innerHTML = this.messageList[i].text;
return tempDiv.textContent || tempDiv.innerText || '';
}
}
return '';
},
// 处理加载状态
handleLoading(status) {
this.isLoading = status;
},
// 更新消息
updateMessage(message) {
// 由于Vue的响应式系统message对象的更改会自动更新视图
// 这里不需要额外的操作
},
updateSuggestions(arr){
this.suggestions = arr
},
// 处理建议
sendSuggestions(item){
// this.suggestions = []
this.AIContent = item
setTimeout(()=>{
this.$refs.sendMessage.handleSend()
this.AIContent = ''
},500)
},
startNewConversation() {
// 重置对话时,先标记组件为未准备就绪状态
this.isComponentReady = false;
this.messageList = [
{
isBot: true,
text: `<p><b>您好!我是京东方案例智能问答助手,随时为您服务。</b></p>
<p>我可以帮您快速查找和解读平台内的各类案例内容。只需输入您想了解的问题或关键词,我会从案例库中精准匹配相关信息,并提供清晰的解答。每条回答都会附上来源链接,方便您随时查阅原始案例全文。</p>
<p>我还会根据您的提问,智能推荐相关延伸问题,助您更高效地探索知识、解决问题。</p>
<p>现在,欢迎随时向我提问,开启高效的知识查询体验吧!</p>`
}
];
this.AIContent = '';
this.isLoading = false;
// 在下一个 tick 中重新标记为准备就绪
this.$nextTick(() => {
this.isComponentReady = true;
});
},
// 处理滚动事件
handleScroll(event) {
const element = event.target;
// 判断是否滚动到底部
const isAtBottom = element.scrollHeight - element.scrollTop <= element.clientHeight + 1;
// 如果滚动到底部,则开启自动滚动
// 如果离开底部,则关闭自动滚动
this.isAutoScroll = isAtBottom;
},
// 滚动到底部
scrollToBottom() {
if (this.isAutoScroll && this.$refs.messageContainer) {
this.$refs.messageContainer.scrollTop = this.$refs.messageContainer.scrollHeight;
}
},
// 最小化窗口的点击事件处理方法
onMinimizedWindowClick() {
// 当点击最小化窗口时如果dialogVisible为false则通过事件通知父组件显示对话框
if (!this.dialogVisible) {
this.$emit('restore');
}
// 然后将窗口状态设置为最大化
this.windowState = 'maximized';
}
}
}
</script>
<style scoped lang="scss">
.case-expert-dialog {
::v-deep .el-dialog{
background: url("./components/u762.svg") no-repeat ;
background-size: cover;
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
//background-color: rgba(255, 255, 255, 0.8);
}
::v-deep .el-dialog__body{
padding: 10px;
flex:1;
//font-size: 12px;
*{
font-size:unset ;
}
}
.dialog-title {
background: transparent;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
font-size: 16px;
font-weight: 600;
color: #333;
padding-right: 20px;
cursor: move; /* 添加拖动样式 */
.icon {
width: 24px;
height: 24px;
}
.window-control-btn {
font-size: 18px;
padding: 5px 10px;
color: #333; /* 黑色图标 */
}
}
.message-suggestions{
display: flex;
flex-direction: column;
.suggestion-item{
cursor: pointer;
float: right;
padding: 5px 15px;
box-sizing: border-box;
background-color: rgba(228, 231, 237, 1);
border-radius: 5px;
margin-bottom: 5px;
}
}
.content-wrapper {
padding: 20px;
background-color: transparent;
border-radius: 8px;
min-height: 500px;
height:100%;
position: relative;
//margin-bottom: 20px;
display: flex;
flex-direction: column;
.welcome-message {
display: flex;
flex-direction: column;
align-items: flex-start;
margin-bottom: 10px;
height: 400px;
//flex:1;
overflow-y: auto;
.avatar {
margin-right: 12px;
img {
width: 32px;
height: 32px;
border-radius: 50%;
background-color: #007aff;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 14px;
}
}
.message-text {
width: 100%;
//margin-bottom: 15px;
p {
color: #333;
//font-size: 14px;
line-height: 1.6;
margin: 8px 0;
}
}
.loading-message {
display: flex;
align-items: center;
width: 100%;
margin-bottom: 15px;
.avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background-color: #007aff;
margin-right: 12px;
}
.loading-dots {
display: flex;
align-items: center;
span {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #999;
margin-right: 5px;
animation: loading 1.4s infinite ease-in-out both;
&:nth-child(1) {
animation-delay: -0.32s;
}
&:nth-child(2) {
animation-delay: -0.16s;
}
}
}
}
}
.input-area-wrapper {
//position: absolute;
//bottom: 10px;
//width: calc(100% - 40px);
}
@keyframes loading {
0%, 80%, 100% {
transform: scale(0);
}
40% {
transform: scale(1);
}
}
}
}
.minimized-window {
position: fixed;
right: 20px;
bottom: 20px;
width: 300px;
background: url("./components/u762.svg") no-repeat;
background-size: cover;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 2000;
cursor: pointer;
.minimized-content {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 15px;
border-bottom: 1px solid #eee;
.window-title {
font-weight: 600;
color: #333;
}
.window-control-btn {
font-size: 16px;
padding: 3px 8px;
color: #000000; /* 黑色图标 */
}
}
.minimized-message {
padding: 15px;
font-size: 14px;
color: #666;
min-height: 60px;
display: flex;
align-items: center;
}
}
</style>

View File

@@ -1,9 +1,7 @@
<template>
<div id="case-list-content">
<div style="margin-bottom:30px" class="case-banner">
<portal-header current="case" textColor="#fff" :goSearch="2">
</portal-header>
<portal-header current="case" textColor="#fff" :goSearch="2"></portal-header>
</div>
<div class="">
<div class="xcontent2">
@@ -111,7 +109,6 @@
</div>
</div>
<div class="xcontent2-minor" :style="{ display: zoomShow ? '' : 'none' }">
<AICaseConsult />
<div id="fixd-box">
<router-link class="the_charts" to="/case/charts">
<div class="text">排行榜</div>
@@ -240,10 +237,9 @@ import { formatDate } from "@/utils/datetime.js"
import { cutFullName } from "@/utils/tools.js";
import apiPlace from "@/api/phase2/place.js"
import portalFloatTools from "@/components/PortalFloatTools.vue";
import AICaseConsult from "@/views/portal/case/components/AICaseConsult.vue";
export default {
name: 'atticle',
components: {AICaseConsult, portalHeader, portalFloatTools, portalFooter, interactBar, author, comments, pdfPreview },
components: { portalHeader, portalFloatTools, portalFooter, interactBar, author, comments, pdfPreview },
computed: {
...mapGetters(['userInfo'])
},

File diff suppressed because it is too large Load Diff

View File

@@ -1,63 +0,0 @@
<template>
<div>
<div class="AI-case" style="position: relative; margin-bottom: 10px;" v-if="showAiCase" @click.stop="getAICase()">
<img src="../../../../../public/images/case-logo.png" alt="">
<span @click="getAICase()" style="position: absolute; bottom: 65px;left: 15px;z-index: 1;width: 40%;height: 30px;"></span>
</div>
<!-- 移除直接使用的AICall组件 -->
</div>
</template>
<script>
import { showCaseAiEntrance } from '@/api/boe/aiChat.js'
import { mapState } from 'vuex'
export default {
name: 'AICaseConsult',
data() {
return {
showAiCase: false
}
},
computed: {
// 从Vuex中获取showAICall状态虽然当前组件不使用但保持连接
...mapState('app', ['showAICall'])
},
mounted() {
this.getShowAiCase()
},
methods: {
// 是否展示入口
getShowAiCase() {
showCaseAiEntrance().then(res => {
this.showAiCase = res.result
})
},
// 案例立即咨询
getAICase() {
// 通过Vuex控制AICall组件显示
this.$store.dispatch('app/setShowAICall', true)
}
}
}
</script>
<style scoped lang="scss">
.AI-case {
margin-bottom: 10px;
position: relative;
img {
width: 100%;
}
span {
width: 160px;
height: 40px;
position: absolute;
left: 20px;
top: 105px;
cursor: pointer;
}
}
</style>

View File

@@ -1 +0,0 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1759024984858" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4686" xmlns:xlink="http://www.w3.org/1999/xlink" width="25" height="25"><path d="M451.673935 994.395699C478.883834 1025.019147 524.254807 1024.808979 551.400292 993.928851 553.755808 991.387908 558.821323 985.796762 565.872444 977.84835 577.572838 964.659017 590.597131 949.62432 604.615947 932.998315 644.662065 885.504506 684.708678 834.717818 722.129538 782.646447 759.658524 730.424619 792.492213 679.709274 819.314991 631.458462 868.685946 542.646317 896 465.543426 896 402.285715 896 180.109449 719.301715 0 501.333333 0 283.364952 0 106.666667 180.109449 106.666667 402.285715 106.666667 465.598716 134.05152 542.80573 183.54613 631.762622 210.371803 679.976529 243.193308 730.651876 280.699364 782.833154 318.155192 834.94455 358.239268 885.77421 398.322835 933.311031 412.354743 949.952073 425.391185 965.00073 437.102468 978.202579 444.160087 986.158466 449.230214 991.754921 451.982775 994.736706L451.673935 994.395699ZM486.822684 961.321348C484.281231 958.568254 479.425084 953.207989 472.585916 945.498359 461.135889 932.591017 448.364015 917.847761 434.602351 901.527215 395.275714 854.888073 355.949587 805.019548 319.289224 754.014863 282.808749 703.260452 250.983685 654.123578 225.158316 607.707522 179.388826 525.445805 154.50505 455.290161 154.50505 402.285715 154.50505 207.039905 309.785362 48.761905 501.333333 48.761905 692.881306 48.761905 848.161617 207.039905 848.161617 402.285715 848.161617 455.246022 823.345286 525.298263 777.693969 607.419251 751.873483 653.867066 720.038415 703.039925 683.537446 753.831262 646.912604 804.794967 607.624538 854.619674 568.335977 901.215038 554.587654 917.520243 541.828177 932.24925 530.389289 945.143797 523.556841 952.845711 518.705521 958.200435 516.166694 960.950526 507.543772 970.748911 495.255793 970.80583 487.131524 961.662353L486.822684 961.321348Z" fill="#979797" p-id="4687"></path><path d="M714.955981 467.028806C723.919106 442.627955 728.565658 416.668998 728.565658 390.095238 728.565658 268.908183 632.184774 170.666667 513.29293 170.666667 394.401086 170.666667 298.020202 268.908183 298.020202 390.095238 298.020202 511.282291 394.401086 609.52381 513.29293 609.52381 549.003859 609.52381 583.510052 600.631947 614.373097 583.874409 626.032316 577.543868 630.449257 562.77782 624.238611 550.893519 618.027966 539.009218 603.541579 534.507006 591.882359 540.837549 567.900883 553.858639 541.111735 560.761905 513.29293 560.761905 420.821495 560.761905 345.858586 484.351836 345.858586 390.095238 345.858586 295.838641 420.821495 219.428572 513.29293 219.428572 605.764365 219.428572 680.727273 295.838641 680.727273 390.095238 680.727273 410.807981 677.117041 430.977316 670.154965 449.930592 665.522846 462.540883 671.796821 476.591108 684.168282 481.312651 696.53974 486.034191 710.323861 479.639095 714.955981 467.028806L714.955981 467.028806Z" fill="#979797" p-id="4688"></path></svg>

Before

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -1,423 +0,0 @@
<template>
<div class="messages">
<!-- 机器人消息 -->
<div v-if="messageData.isBot" class="bot-message">
<!-- 思考中提示 -->
<div v-if="messageData.thinkText" class="bot-think" v-katex:auto v-html="md.render(messageData.thinkText)"></div>
<!-- 主要回复内容 -->
<div
ref="contentContainer"
class="message-content"
v-katex:auto
v-html="md.render(displayText)"
></div>
<!-- 引用案例 -->
<div v-if="messageData.caseRefers && messageData.caseRefers.length > 0 && messageData.textCompleted" class="case-refers">
<div class="case-refers-title">
<span><i class="iconfont icon-think"></i> 引用案例</span>
<span v-if="shouldShowMoreButton" class="more" @click="toggleShowAllCaseRefers">
{{ showAllCaseRefers ? '收起' : '查看更多' }}
</span>
</div>
<div class="case-refers-list">
<div
v-for="item in displayedCaseRefers"
:key="item.caseId"
class="case-refers-item"
>
<div class="case-refers-item-title">
<a @click="toUrl(item)" class="title">{{ item.title }}</a>
<span class="case-refers-item-timer">{{ item.uploadTime }}</span>
</div>
<div class="case-refers-item-author">
<span class="user"></span>
<span>{{ item.authorName }}</span>
<span class="case-inter-orginInfo">{{ item.orgInfo }}</span>
</div>
<div class="case-refers-item-keywords">
<span v-for="keyword in item.keywords" :key="keyword">{{ keyword }}</span>
</div>
<div class="message-content case-content" v-html="md.render(item.content)"></div>
</div>
</div>
</div>
</div>
<!-- 用户消息 -->
<div v-else class="user-message">
<div class="message-text" v-html="messageData.text"></div>
</div>
<!-- 推荐问题 -->
<!-- <div v-if="suggestions && suggestions.length > 0" class="suggestions">-->
<!-- <div class="suggestions-title">💡 推荐问题</div>-->
<!-- <div class="suggestions-list">-->
<!-- <button-->
<!-- v-for="(item, index) in suggestions"-->
<!-- :key="index"-->
<!-- class="suggestions-item"-->
<!-- @click="$emit('suggestion-click', item)"-->
<!-- >-->
<!-- {{ item }}-->
<!-- </button>-->
<!-- </div>-->
<!-- </div>-->
</div>
</template>
<script>
import MarkdownIt from 'markdown-it';
import highlight from 'markdown-it-highlightjs';
import 'highlight.js/styles/a11y-dark.css';
import markdownItMermaid from 'markdown-it-mermaid';
import mermaid from 'mermaid';
// 初始化 Mermaid
mermaid.initialize({ startOnLoad: false });
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true,
});
md.use(highlight).use(markdownItMermaid);
export default {
name: 'Message',
props: {
messageData: {
type: Object,
required: true,
default: () => ({}),
},
suggestions: {
type: Array,
default: () => [],
},
},
data() {
return {
md,
displayText: '',
typingTimer: null,
typingSpeed: 30, // 毫秒/字符
showAllCaseRefers: false,
};
},
computed: {
displayedCaseRefers() {
if (this.showAllCaseRefers || !this.messageData.caseRefers) {
return this.messageData.caseRefers || [];
}
return this.messageData.caseRefers.slice(0, 3);
},
shouldShowMoreButton() {
return this.messageData.caseRefers && this.messageData.caseRefers.length > 3;
},
},
watch: {
'messageData.text': {
handler(newVal) {
if (!newVal) {
this.displayText = '';
return;
}
if (this.messageData.isBot && !this.messageData.typing) {
// this.startTyping(newVal); // 启动打字机效果/**/
this.displayText = newVal || ''
} else {
this.displayText = this.md.render(newVal);
this.$nextTick(this.renderMermaid); // 直接渲染 Mermaid
}
},
immediate: true,
},
},
methods: {
toUrl(item) {
this.$router.push({
path: '/case/detail',
query: { id: item.caseId },
});
this.$emit('getMinWindow')
},
// 正确的打字机效果:先整体渲染 Markdown再逐字显示 HTML
startTyping(fullText) {
const renderedText = this.md.render(fullText);
this.displayText = '';
let index = 0;
if (this.typingTimer) {
clearInterval(this.typingTimer);
}
this.typingTimer = setInterval(() => {
if (index < renderedText.length) {
this.displayText += renderedText[index];
index++;
} else {
clearInterval(this.typingTimer);
this.typingTimer = null;
this.$nextTick(this.renderMermaid); // 渲染 Mermaid 图表
}
}, this.typingSpeed);
},
// 触发 Mermaid 渲染
renderMermaid() {
this.$nextTick(() => {
const mermaidEls = this.$el.querySelectorAll('.mermaid');
if (mermaidEls.length > 0) {
try {
// mermaid 8.x 版本使用 init 方法而不是 run
if (typeof mermaid.init === 'function') {
mermaid.init(undefined, '.mermaid');
} else if (mermaid.default && typeof mermaid.default.init === 'function') {
mermaid.default.init(undefined, '.mermaid');
}
} catch (e) {
console.warn('Mermaid 渲染失败:', e);
}
}
});
},
// 切换案例引用显示数量
toggleShowAllCaseRefers() {
this.showAllCaseRefers = !this.showAllCaseRefers;
// 切换后重新渲染 Mermaid如果内容中有图表
this.$nextTick(this.renderMermaid);
},
},
beforeDestroy() {
if (this.typingTimer) {
clearInterval(this.typingTimer);
this.typingTimer = null;
}
},
};
</script>
<style scoped lang="scss">
::v-deep .mermaid{
width: 100%;
height: auto;
}
::v-deep svg[id^="mermaid-"]{
width: 100%;
height: 100%;
}
.messages {
width: 100%;
word-wrap: break-word;
.bot-message {
background-color: #fff;
border-radius: 5px;
padding: 10px;
margin-bottom: 10px;
.bot-think {
color: #909399;
padding-bottom: 5px;
position: relative;
padding-left: 10px;
&:before {
content: ' ';
border-left: 0.5px solid #909399;
position: absolute;
height: 100%;
left: 0;
top: -3px;
transform: scaleX(0.5);
}
}
.message-content {
font-size: 14px;
line-height: 1.6;
}
.case-content {
font-size: 12px !important;
margin-top: 8px;
padding: 6px 10px;
//background-color: #f9f9f9;
border-radius: 4px;
//border: 1px solid #eee;
}
}
.user-message {
float: right;
padding: 8px 15px;
max-width: 80%;
background-color: #e4e7ed;
border-radius: 5px;
margin-bottom: 10px;
font-size: 14px;
word-break: break-word;
}
/* ========== 案例引用样式 ========== */
.case-refers {
margin-top: 12px;
.case-refers-title {
font-weight: bold;
margin-bottom: 8px;
display: flex;
align-items: center;
justify-content: space-between;
color: #333;
.icon-think {
background-image: url('./map.svg');
width: 15px;
height: 13px;
display: inline-block;
background-repeat: no-repeat;
background-size: 100% 100%;
margin-right: 6px;
}
.more {
font-size: 12px;
padding: 2px 8px;
background-color: #f4f7fd;
border-radius: 5px;
color: #577ee1;
font-weight: normal;
cursor: pointer;
}
}
.case-refers-list {
display: flex;
flex-direction: column;
gap: 8px;
.case-refers-item {
border: 1px solid rgba(144, 147, 153, 0.44);
padding: 8px;
border-radius: 6px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
.case-refers-item-title {
font-size: 14px;
margin-bottom: 6px;
font-weight: 600;
color: #000;
display: flex;
align-items: center;
justify-content: space-between;
.title {
max-width: 70%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #000;
cursor: pointer;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.case-refers-item-timer {
font-size: 11px;
color: #aaa;
font-weight: normal;
}
}
.case-refers-item-author {
display: flex;
align-items: center;
font-size: 12px;
color: #555;
margin-bottom: 5px;
.user {
background-image: url('./user.svg');
width: 15px;
height: 15px;
display: inline-block;
background-repeat: no-repeat;
background-size: 100% 100%;
margin-right: 6px;
}
.case-inter-orginInfo {
font-size: 11px;
color: #999;
margin-left: 6px;
}
}
.case-refers-item-keywords {
margin-top: 4px;
font-size: 12px;
span {
padding: 2px 6px;
background-color: #f4f7fd;
border-radius: 5px;
font-size: 11px !important;
color: #577ee1;
}
span + span {
margin-left: 8px;
}
}
}
}
}
/* ========== 推荐问题 ========== */
.suggestions {
margin-top: 10px;
font-size: 14px;
.suggestions-title {
font-weight: bold;
margin-bottom: 6px;
color: #333;
}
.suggestions-list {
display: flex;
flex-direction: column;
gap: 6px;
.suggestions-item {
padding: 6px 10px;
background-color: #f0f4fc;
border: none;
border-radius: 6px;
text-align: left;
color: #1a73e8;
cursor: pointer;
font-size: 13px;
transition: background-color 0.2s;
&:hover {
background-color: #e1e8f5;
}
}
}
}
}
</style>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 283 B

View File

@@ -1,391 +0,0 @@
<template>
<div class="input-area">
<el-input
v-model="inputContent"
type="textarea"
class="input-placeholder"
placeholder="有问题,尽管问"
@keyup.enter.native="handleSend"
:disabled="disabled"
:autosize="{ minRows: 2, maxRows: 4}"
resize="none"
></el-input>
<div class="action-buttons">
<el-button type="primary" size="small" class="start-btn" @click="handleNewConversation">
+ 开启新对话
</el-button>
<el-button type="text" class="send-btn" @click="handleSend" :disabled="disabled">
<i class="el-icon-s-promotion"></i>
</el-button>
</div>
</div>
</template>
<script>
import { aiChat } from '@/api/boe/aiChat.js'
export default {
name: 'SendMessage',
props: {
value: {
type: String,
default: ''
},
disabled: {
type: Boolean,
default: false
},
messageList: {
type: Array,
default: () => []
},
suggestions: {
type: Array,
default: () => []
}
},
data() {
return {
inputContent: this.value,
conversationId: '' // 会话ID
}
},
watch: {
value(newVal) {
this.inputContent = newVal
}
},
methods: {
handleSend() {
if (!this.inputContent.trim() || this.disabled) return
// 添加用户消息到列表
const userMessage = {
isBot: false,
text: this.inputContent
};
this.messageList.push(userMessage);
// 显示加载状态
this.$emit('loading', true);
// 调用AI聊天接口 (暂时注释掉SSE使用模拟数据)
this.callAIChat(this.inputContent);
// 清空输入框
this.inputContent = ''
},
// 真实的SSE实现暂时注释掉
callAIChat(question) {
// 创建新的AI消息对象
const aiMessage = {
isBot: true,
text: '',
status:null,
thinkText: '',
caseRefers: [], // 添加caseRefers字段
textCompleted: false // 添加文字处理完成状态默认为false
};
this.messageList.push(aiMessage);
// 构造请求参数
const requestData = {
conversationId: this.conversationId,
query: question
};
// 创建POST请求
fetch('/systemapi/xboe/m/boe/case/ai/chat',{
method: 'POST',
headers: {
'Content-Type': 'application/json',
"accept": "text/event-stream",
},
body: JSON.stringify(requestData)
}).then(r=>{
return r
}).then(response => {
// 处理流式响应
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let accumulatedContent = ''; // 累积的内容用于打字机效果
let accumulatedThinkContent = ''; // 累积的思考内容
let inThinkSection = false; // 是否在思考部分
let typingTimer = null; // 打字机定时器
let thinkTypingTimer = null; // 思考内容打字机定时器
// 逐字显示文本的函数
const typeText = (message, fullContent) => {
// 如果已有定时器在运行,先清除它
if (typingTimer) {
clearInterval(typingTimer);
}
// 获取当前已显示的文本长度
const currentLength = message.text.length;
// 获取完整文本
const targetLength = fullContent.length;
// 如果已经显示完整文本,不需要继续
if (currentLength >= targetLength) {
return;
}
const typingSpeed = 30; // 每个字符的间隔时间(毫秒)
typingTimer = setInterval(() => {
// 计算下一个要显示的字符索引
const nextIndex = message.text.length + 1;
if (nextIndex <= targetLength) {
message.text = fullContent.substring(0, nextIndex);
this.$emit('update-message', message);
} else {
clearInterval(typingTimer);
typingTimer = null;
// 当打字机效果完成时检查是否应该设置textCompleted为true
// 这应该在status 4交互完成时才设置
if (message.status === 4) {
if (nextIndex >= targetLength) {
message.textCompleted = true;
}
}
}
}, typingSpeed);
};
// 逐字显示思考内容的函数
const typeThinkText = (message, fullThinkContent) => {
// 如果已有定时器在运行,先清除它
if (thinkTypingTimer) {
clearInterval(thinkTypingTimer);
}
// 获取当前已显示的文本长度
const currentLength = message.thinkText.length;
// 获取完整文本
const targetLength = fullThinkContent.length;
// 如果已经显示完整文本,不需要继续
if (currentLength >= targetLength) {
return;
}
// 从当前显示位置开始继续显示(避免清空重显)
const startIndex = currentLength;
const typingSpeed = 20; // 每个字符的间隔时间(毫秒)
thinkTypingTimer = setInterval(() => {
// 计算下一个要显示的字符索引
const nextIndex = message.thinkText.length + 1;
if (nextIndex <= targetLength) {
message.thinkText = fullThinkContent.substring(0, nextIndex);
this.$emit('update-message', message);
} else {
clearInterval(thinkTypingTimer);
thinkTypingTimer = null;
}
}, typingSpeed);
};
// 添加一个检查是否所有文本都已完成显示的函数
const isTextDisplayCompleted = (message, fullContent) => {
return message.text.length >= fullContent.length;
};
// 读取流数据
const read = () => {
reader.read().then(({ done, value }) => {
if (done) {
// 当流结束时,等待打字机效果完成
const waitForTyping = () => {
if (!typingTimer) {
this.$emit('loading', false);
} else {
setTimeout(waitForTyping, 100);
}
};
waitForTyping();
return;
}
// 解码数据
buffer += decoder.decode(value, { stream: true });
// 按行分割数据
const lines = buffer.split('\n');
buffer = lines.pop(); // 保留不完整的行
for (const line of lines) {
if (line.startsWith('data:')) {
try {
const jsonData = JSON.parse(line.substring(5));
// 根据status处理不同类型的数据
switch (jsonData.status) {
case 0: // 引用文件
// 处理引用文件信息
if (jsonData.fileRefer && jsonData.fileRefer.caseRefers) {
aiMessage.caseRefers = jsonData.fileRefer.caseRefers;
// 更新父组件的messageList
this.$emit('update-message', aiMessage);
}
// 从响应中获取并保存conversationId
if (jsonData.conversationId) {
this.conversationId = jsonData.conversationId;
sessionStorage.setItem('conversationId', jsonData.conversationId);
}
break;
case 1: // 流式对话内容
// 处理
const content = jsonData.content;
aiMessage.hasThink = false;
if (content.startsWith('<think>')) {
aiMessage.hasThink = true
inThinkSection = true;
accumulatedThinkContent = content.replace('<think>', '');
// 使用打字机效果显示think内容
typeThinkText(aiMessage, accumulatedThinkContent);
} else if (content.startsWith('</think>')) {
inThinkSection = false;
accumulatedThinkContent += content.replace('</think>', '');
// 使用打字机效果显示think内容
typeThinkText(aiMessage, accumulatedThinkContent);
} else if (inThinkSection) {
accumulatedThinkContent += content;
// 使用打字机效果显示think内容
typeThinkText(aiMessage, accumulatedThinkContent);
} else {
// 累积内容并使用打字机效果更新显示
accumulatedContent += content;
// 如果thinkText已经显示完整则继续使用打字机效果显示内容
if( aiMessage.hasThink){
if(aiMessage.thinkText.length >=accumulatedThinkContent.length){
typeText(aiMessage, accumulatedContent);
}
} else {
typeText(aiMessage, accumulatedContent);
}
}
// 不在这里直接更新,让打字机效果处理更新
break;
case 2: // 回答完成
// 不再在这里设置textCompleted状态
// 更新父组件的messageList
this.$emit('update-message', aiMessage);
// 从响应中获取并保存conversationId
break;
case 3: // 返回建议
// 这里可以处理建议问题
this.$emit('update-suggestions', jsonData.suggestions);
break;
case 4: // 交互完成
aiMessage.status = 4
// 从响应中获取并保存conversationId
this.$emit('loading', false);
// 检查文本是否已经完全显示如果是则设置textCompleted为true
if (isTextDisplayCompleted(aiMessage, accumulatedContent)) {
// aiMessage.textCompleted = true;
this.$emit('update-message', aiMessage);
}
break;
}
} catch (error) {
console.error('解析SSE数据错误:', error);
}
}
}
// 继续读取
read();
}).catch(error => {
console.error('SSE连接错误:', error);
// 出错时也设置文字处理完成状态
if (typingTimer) {
clearInterval(typingTimer);
typingTimer = null;
}
aiMessage.textCompleted = true;
this.$emit('loading', false);
aiMessage.text = '当前无法获取回答,请稍后重试';
// 更新父组件的messageList
this.$emit('update-message', aiMessage);
});
};
// 开始读取数据
read();
}).catch(error => {
console.error('请求失败:', error);
// 出错时也设置文字处理完成状态
aiMessage.textCompleted = true;
this.$emit('loading', false);
aiMessage.text = '当前无法获取回答,请稍后重试';
// 更新父组件的messageList
this.$emit('update-message', aiMessage);
});
},
handleNewConversation() {
this.conversationId = ''
this.$emit('new-conversation')
}
}
}
</script>
<style scoped lang="scss">
.input-area {
background-color: white;
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 5px 16px 10px 16px;
display: flex;
flex-direction: column;
.input-placeholder {
color: #999;
font-size: 14px;
margin: 0;
border: none;
::v-deep .el-input__inner {
border: none;
padding: 0;
height: 30px;
}
}
.action-buttons {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 5px;
.start-btn {
padding: 6px 10px;
font-size: 12px;
border-radius: 4px;
color: #409eff;
background-color: #f5f7fa;
border: 1px solid #dcdfe6;
}
.send-btn {
font-size: 18px;
color: #409eff;
padding: 6px;
&[disabled] {
color: #c0c4cc;
}
}
}
}
</style>

View File

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="750px" height="850px" xmlns="http://www.w3.org/2000/svg">
<defs>
<radialGradient cx="362.789473684209" cy="413.96491228069874" r="1153.015055438179" gradientTransform="matrix(0.023310357358899587 0.999728276703125 -0.9997282767031253 0.02331035735889959 768.1851497765263 41.62434690904212 )" gradientUnits="userSpaceOnUse" id="RadialGradient4">
<stop id="Stop5" stop-color="#ffffff" offset="0" />
<stop id="Stop6" stop-color="#d4def7" offset="1" />
</radialGradient>
</defs>
<g>
<path d="M 0 5.000000000000001 A 5 5 0 0 1 4.999999999999999 0 L 745 0 A 5 5 0 0 1 750 5 L 750 845 A 5 5 0 0 1 745 850 L 5 850 A 5 5 0 0 1 0 845 L 0 5 Z " fill-rule="nonzero" fill="url(#RadialGradient4)" stroke="none" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 858 B

View File

@@ -1 +0,0 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1759026139840" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5676" xmlns:xlink="http://www.w3.org/1999/xlink" width="25" height="25"><path d="M512 74.666667C270.933333 74.666667 74.666667 270.933333 74.666667 512S270.933333 949.333333 512 949.333333 949.333333 753.066667 949.333333 512 753.066667 74.666667 512 74.666667zM288 810.666667c0-123.733333 100.266667-224 224-224S736 686.933333 736 810.666667c-61.866667 46.933333-140.8 74.666667-224 74.666666s-162.133333-27.733333-224-74.666666z m128-384c0-53.333333 42.666667-96 96-96s96 42.666667 96 96-42.666667 96-96 96-96-42.666667-96-96z m377.6 328.533333c-19.2-96-85.333333-174.933333-174.933333-211.2 32-29.866667 51.2-70.4 51.2-117.333333 0-87.466667-72.533333-160-160-160s-160 72.533333-160 160c0 46.933333 19.2 87.466667 51.2 117.333333-89.6 36.266667-155.733333 115.2-174.933334 211.2-55.466667-66.133333-91.733333-149.333333-91.733333-243.2 0-204.8 168.533333-373.333333 373.333333-373.333333S885.333333 307.2 885.333333 512c0 93.866667-34.133333 177.066667-91.733333 243.2z" fill="#666666" p-id="5677"></path></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -376,8 +376,25 @@
<div class="couresstartTime">
<span v-if="cinfo.type == 30 && cinfo.startTime">开课时间{{ cinfo.startTime }}</span>
</div>
<div class="course-info">
<!-- ai播放器相关 -->
<div class="course-info" style="align-items: center;">
<el-popover
placement="top-start"
:width="cinfo.summaryContent && cinfo.summaryContent.length > 200 ? '402' : '253'"
trigger="hover"
popper-class="course-popover"
>
<div class="course-popover-content">
<h4>课程摘要</h4>
<div v-if="cinfo.summaryContent" class="course-popover-content-text" >{{ cinfo.summaryContent }}</div>
<div v-else class="course-popover-noContent" >
<img src="../../../assets/images/course/noData.png" alt="">
<span>暂无数据</span>
</div>
</div>
<!-- <img slot="reference" src="../../../assets/images/course/courseAbstract.png" alt="摘要" style="width: 94px;height: 44px;margin-left: -10px;"> -->
<img v-show="cinfo.aiAbstract == '1'" slot="reference" src="../../../assets/images/course/courseAbstract.png" alt="摘要" style="width: 94px;height: 44px;margin-left: -10px;">
</el-popover>
<div class="course-info-user" style="max-width: 100px;" v-if="cinfo.teacher">
<el-tooltip :content="cinfo.teacher" placement="bottom" effect="light">
<span class="course-info-author">{{ cinfo.teacher }}</span>
@@ -387,13 +404,13 @@
<span class="course-info-studys">{{ formatNum(cinfo.studies) }}人学习</span>
</div>
<div class="course-info-score">
<div style="display: flex;">
<div style="display: flex; align-items: center;">
<interactBar :type="1" nodeWidth="20px" :data="cinfo" :courseExclusive="true" :comments="false"
:praises="false" :shares="false" :views="false"></interactBar>
<div v-if="cinfo.score">
<span class="course-score-value">{{ toScore(cinfo.score) }}</span>
<span class="course-score-value" style="white-space: nowrap;">{{ toScore(cinfo.score) }}</span>
</div>
<div v-else class="course-score-no">未评分</div>
<div v-else class="course-score-no" style="white-space: nowrap;">未评分</div>
</div>
</div>
</div>
@@ -2422,3 +2439,43 @@ console.log(res.result.list,'data')
.option-active {
color: #387DF7;
}</style>
<!-- ai播放器相关 -->
<style lang="scss">
.course-popover{
border-radius: 12px;
box-shadow: 0px 0px 16px 0px rgba(0, 0, 0, 0.12);
.course-popover-content{
h4{
margin: 0 0 7px 0;
font-size: 14px;
font-weight: 500;
line-height: 17px;
letter-spacing: 0.3px;
color: rgba(0, 0, 0, 1);
}
.course-popover-content-text{
font-size: 12px;
font-weight: 400;
line-height: 20px;
letter-spacing: 0.26px;
color: rgba(102, 102, 102, 1);
}
.course-popover-noContent{
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
color: rgba(0, 0, 0, 0.34);
font-size: 12px;
font-weight: 400;
line-height: 14px;
letter-spacing: 0.26px;
padding-bottom: 20px;
img{
width: 96px;
height: 50px;
}
}
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -57,13 +57,6 @@ module.exports = {
// set svg-sprite-loader
config.plugins.delete('preload')
config.plugins.delete('prefetch')
// 添加对 mathxyjax3 的处理规则
config.module
.rule('mathxyjax3')
.test(/node_modules[\/\\]mathxyjax3[\/\\].*\.js$/)
.use('null-loader')
.loader('null-loader')
.end()
config.module
.rule('svg')
.exclude.add(resolve('src/icons'))