|
|
@@ -1,205 +1,15 @@
|
|
|
<template>
|
|
|
- <div class="dialog-content-wrapper">
|
|
|
- <!-- 遮罩层 -->
|
|
|
- <div v-if="showMask" class="mask-layer" ref="maskLayer">
|
|
|
- <div class="play-button-container">
|
|
|
- <button class="play-button" @click="startPlayback">
|
|
|
- <el-icon class="play-icon"><VideoPlay /></el-icon>
|
|
|
- </button>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- <!-- 标题 -->
|
|
|
- <div class="title-box">
|
|
|
- <!-- 返回 -->
|
|
|
- <div class="title-left">
|
|
|
- <div class="box-icon" @click="goBackToMain">
|
|
|
- <el-icon class="left-icon"><ArrowLeftBold /></el-icon>
|
|
|
- {{ backText }}
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- <!-- 标题 -->
|
|
|
- <div class="title-center">
|
|
|
- <div class="title-text" :title="currentSection?.name">
|
|
|
- {{ currentSection?.name }}
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- <!-- 自动按钮 -->
|
|
|
- <div class="title-right">
|
|
|
- <div class="box-icon" @click="togglePlay">
|
|
|
- <span class="play-text">{{ isPlaying ? '暂停' : '自动' }}</span>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <!-- 内容区域 -->
|
|
|
- <div class="content-box">
|
|
|
- <!-- 人物形象 -->
|
|
|
- <div
|
|
|
- v-if="currentDialogue && (currentDialogue.type === 'digital' || currentDialogue.type === 'quest')"
|
|
|
- :key="`character-${currentDialogueIndex}`"
|
|
|
- class="character"
|
|
|
- :class="{
|
|
|
- 'left': getCharacterSide(currentDialogue.roleName) === 'left',
|
|
|
- 'right': getCharacterSide(currentDialogue.roleName) === 'right'
|
|
|
- }"
|
|
|
- :style="{ backgroundImage: `url(${getCharacterImage(currentDialogue.roleName)})` }"
|
|
|
- ></div>
|
|
|
-
|
|
|
- <!-- 对话卡片 -->
|
|
|
- <div
|
|
|
- v-if="currentDialogue && (currentDialogue.type === 'digital' || currentDialogue.type === 'quest')"
|
|
|
- :key="`dialogue-${currentDialogueIndex}`"
|
|
|
- class="dialogue-card"
|
|
|
- :class="{
|
|
|
- 'left': getCharacterSide(currentDialogue.roleName) === 'left',
|
|
|
- 'right': getCharacterSide(currentDialogue.roleName) === 'right'
|
|
|
- }"
|
|
|
- >
|
|
|
- <div class="dialogue-header">
|
|
|
- <span class="role-name">{{ currentDialogue?.roleName }}</span>
|
|
|
- </div>
|
|
|
- <div class="dialogue-content" v-html="parseMarkdown(currentDialogue.content)"></div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <!-- 诗词显示区域 -->
|
|
|
- <div v-if="showPoem" class="poem-display">
|
|
|
- <div class="poem-content">
|
|
|
- <div class="poem-text" v-html="formatPoemContent(currentPoemContent)" ></div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <!-- 视频显示区域 -->
|
|
|
- <div v-if="currentDialogue && currentDialogue.type === 'video'" class="video-display">
|
|
|
- <div class="video-frame">
|
|
|
- <video
|
|
|
- :src="currentDialogue.videoUrl"
|
|
|
- class="dialogue-video"
|
|
|
- ref="dialogueVideoRef"
|
|
|
- controls
|
|
|
- autoplay
|
|
|
- @ended="handleVideoEnded"
|
|
|
- @contextmenu.prevent
|
|
|
- controlslist="nodownload"
|
|
|
- >
|
|
|
- 您的浏览器不支持视频播放
|
|
|
- </video>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <!-- 用户输入卡片 -->
|
|
|
- <div
|
|
|
- v-if="currentDialogue.type === 'user' && !isUserSingleChoice"
|
|
|
- class="dialogue-card user-input-card"
|
|
|
- >
|
|
|
- <div class="dialogue-header">
|
|
|
- <span class="role-name">我</span>
|
|
|
- </div>
|
|
|
- <div class="dialogue-content">
|
|
|
- <textarea
|
|
|
- :value="userInput"
|
|
|
- @input="e => userInput = e.target.value"
|
|
|
- class="user-input-textarea"
|
|
|
- placeholder="请输入内容..."
|
|
|
- @keyup.enter.exact="submitUserInput"
|
|
|
- ></textarea>
|
|
|
- <div class="input-actions">
|
|
|
- <button class="cancel-btn" @click="cancelUserInput">清空</button>
|
|
|
- <button class="submit-btn" @click="submitUserInput">发送</button>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <!-- 单选问题卡片(在user类型时显示) -->
|
|
|
- <div
|
|
|
- v-if="isUserSingleChoice"
|
|
|
- class="dialogue-card single-choice-card"
|
|
|
- >
|
|
|
- <div class="dialogue-header">
|
|
|
- <span class="role-name">我</span>
|
|
|
- </div>
|
|
|
- <div class="single-choice-content">
|
|
|
- <!-- 问题描述 -->
|
|
|
- <div class="question-text" v-html="parseMarkdown(previousQuestDialogue?.content || '')"></div>
|
|
|
- <!-- 选项列表 -->
|
|
|
- <div class="options-list">
|
|
|
- <div
|
|
|
- v-for="(option, index) in previousQuestDialogue?.options"
|
|
|
- :key="index"
|
|
|
- class="option-item"
|
|
|
- :class="{ 'selected': selectedOption === optionLabels[index] }"
|
|
|
- @click="selectOption(optionLabels[index])"
|
|
|
- >
|
|
|
- <span class="option-label">{{ optionLabels[index] }}</span>
|
|
|
- <span class="option-content">{{ option.content }}</span>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- <!-- 操作按钮 -->
|
|
|
- <div class="input-actions">
|
|
|
- <button class="cancel-btn" @click="cancelSingleChoice">清空选择</button>
|
|
|
- <button class="submit-btn" :disabled="!selectedOption" @click="submitSingleChoice">提交答案</button>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <!-- 输入按钮区域 -->
|
|
|
- <div class="input-buttons-container" >
|
|
|
- <!-- 上一个对话按钮 -->
|
|
|
- <div class="arrow-icon-circle" @click="playPrevious" :class="{ 'disabled': currentSectionIndex === 0 && currentDialogueIndex === 0 }">
|
|
|
- <el-icon class="arrow-icon"><CaretLeft /></el-icon>
|
|
|
- </div>
|
|
|
- <!-- 语音输入按钮 -->
|
|
|
- <div class="voice-input-outer" v-if="currentDialogue.type === 'user' && !isUserSingleChoice" :class="{ 'recording': isVoiceRecording }">
|
|
|
- <VoiceInput
|
|
|
- inputSelector=".user-input-textarea"
|
|
|
- lang="zh-CN"
|
|
|
- maxDuration="10"
|
|
|
- @voiceRecognized="handleVoiceRecognized"
|
|
|
- @recordingStatusChanged="handleRecordingStatusChanged"
|
|
|
- />
|
|
|
- </div>
|
|
|
- <!-- 语音输入按钮占位符 -->
|
|
|
- <div class="voice-input-outer placeholder" v-else></div>
|
|
|
- <!-- 下一个对话按钮 -->
|
|
|
- <div class="arrow-icon-circle" @click="playNext" :class="{ 'disabled': isAtLastDialogue() }">
|
|
|
- <el-icon class="arrow-icon"><CaretRight /></el-icon>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <!-- 背景图 -->
|
|
|
- <img
|
|
|
- v-if="currentBackgroundType === 'imageAudio'"
|
|
|
- :src="currentBackgroundImage"
|
|
|
- alt="背景图"
|
|
|
- class="background-image"
|
|
|
- >
|
|
|
- <!-- 背景视频 -->
|
|
|
- <video
|
|
|
- v-else-if="currentBackgroundType === 'video'"
|
|
|
- ref="backgroundVideoRef"
|
|
|
- :src="currentBackgroundVideo"
|
|
|
- class="background-video"
|
|
|
- loop
|
|
|
- muted
|
|
|
- playsinline>
|
|
|
- 您的浏览器不支持视频播放
|
|
|
- </video>
|
|
|
- </div>
|
|
|
+ <DialogEngine
|
|
|
+ :script-data="scriptData"
|
|
|
+ :script-roles="scriptRoles"
|
|
|
+ :back-text="backText"
|
|
|
+ :is-last-course="isLastCourse"
|
|
|
+ @dialogue-ended="handleDialogueEnded"
|
|
|
+ />
|
|
|
</template>
|
|
|
|
|
|
<script setup>
|
|
|
-import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
|
|
-import { useRouter } from 'vue-router'
|
|
|
-import { ArrowLeftBold, CaretLeft, CaretRight, Grid, VideoPlay } from '@element-plus/icons-vue'
|
|
|
-import VoiceInput from '@/components/ai/voice/VoiceInput_Api.vue'
|
|
|
-import { marked } from 'marked'
|
|
|
-import {CreateDialogue, sendChatMessageStream} from "@/api/questions.js";
|
|
|
-import {useAudioPlayer} from "@/api/tts/useAudioPlayer.js";
|
|
|
-
|
|
|
-const { playAudioChunk , stopPlayback, setOnPlaybackComplete, getIsPlaying } = useAudioPlayer();
|
|
|
-
|
|
|
-// 路由实例
|
|
|
-const router = useRouter()
|
|
|
+import DialogEngine from '../../../components/aiCourse/DialogEngine.vue';
|
|
|
|
|
|
const props = defineProps({
|
|
|
scriptData: {
|
|
|
@@ -217,2066 +27,19 @@ const props = defineProps({
|
|
|
type: String,
|
|
|
default: '返回课程'
|
|
|
},
|
|
|
- // 是否是最后一节课
|
|
|
isLastCourse: {
|
|
|
type: Boolean,
|
|
|
default: false
|
|
|
}
|
|
|
-})
|
|
|
-
|
|
|
-const emit = defineEmits(['dialogueEnded'])
|
|
|
-
|
|
|
-// 对话相关状态
|
|
|
-// 当前章节索引
|
|
|
-const currentSectionIndex = ref(0)
|
|
|
-// 当前对话索引
|
|
|
-const currentDialogueIndex = ref(0)
|
|
|
-// 是否正在播放
|
|
|
-const isPlaying = ref(false)
|
|
|
-// 用户输入内容
|
|
|
-const userInput = ref('')
|
|
|
-// 语音录音状态
|
|
|
-const isVoiceRecording = ref(false)
|
|
|
-// 实时语音识别结果
|
|
|
-const voiceRecognizedText = ref("")
|
|
|
-// 诗词显示状态
|
|
|
-const showPoem = ref(false)
|
|
|
-// 当前诗词内容
|
|
|
-const currentPoemContent = ref('')
|
|
|
-// 单选问题选中答案
|
|
|
-const selectedOption = ref('')
|
|
|
-// 选项标签映射
|
|
|
-const optionLabels = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']
|
|
|
-
|
|
|
-// 音频对象
|
|
|
-// 背景音频
|
|
|
-const backgroundAudio = ref(null)
|
|
|
-// 对话音频
|
|
|
-const dialogueAudio = ref(null)
|
|
|
-// 背景视频
|
|
|
-const backgroundVideoRef = ref(null)
|
|
|
-// 对话视频
|
|
|
-const dialogueVideoRef = ref(null)
|
|
|
-// 遮罩层显示状态
|
|
|
-const showMask = ref(true)
|
|
|
-// 是否开始播放
|
|
|
-const isPlaybackStarted = ref(false)
|
|
|
-
|
|
|
-// 计算属性
|
|
|
-// 当前章节信息
|
|
|
-const currentSection = computed(() => {
|
|
|
- return props.scriptData.sections[currentSectionIndex.value]
|
|
|
-})
|
|
|
-// 当前对话信息
|
|
|
-const currentDialogue = computed(() => {
|
|
|
- if (!currentSection.value) return null
|
|
|
- return currentSection.value.dialogues[currentDialogueIndex.value]
|
|
|
-})
|
|
|
-
|
|
|
-const currentBackgroundImage = computed(() => {
|
|
|
- if (!currentSection.value || !currentSection.value.backgroundImage || !currentSection.value.backgroundImage.url) {
|
|
|
- return ''
|
|
|
- }
|
|
|
- return currentSection.value.backgroundImage.url
|
|
|
-})
|
|
|
-
|
|
|
-
|
|
|
-// 当前背景类型
|
|
|
-const currentBackgroundType = computed(() => {
|
|
|
- return currentSection.value?.backgroundType || 'imageAudio'
|
|
|
-})
|
|
|
-
|
|
|
-// 当前背景视频
|
|
|
-const currentBackgroundVideo = computed(() => {
|
|
|
- if (!currentSection.value || !currentSection.value.backgroundVideo || !currentSection.value.backgroundVideo.url) {
|
|
|
- return ''
|
|
|
- }
|
|
|
- return currentSection.value.backgroundVideo.url
|
|
|
-})
|
|
|
-
|
|
|
-// 当前对话缓存
|
|
|
-const currentDialogueCache = ref(null)
|
|
|
-
|
|
|
-// 方法
|
|
|
-const handleVoiceRecognized = (data) => {
|
|
|
- console.log('语音识别结果:', data.originalText)
|
|
|
- if (isVoiceRecording.value) {
|
|
|
- // 在同一次录音过程中,实时更新文本框内容
|
|
|
- voiceRecognizedText.value = data.originalText
|
|
|
- userInput.value = data.processedText
|
|
|
- } else {
|
|
|
- // 在录音结束时,将最终的语音内容追加到userInput.value
|
|
|
- const textarea = document.querySelector('.user-input-textarea')
|
|
|
- if (textarea) {
|
|
|
- userInput.value = data.processedText
|
|
|
- // 重新设置光标位置到插入文本的末尾
|
|
|
- setTimeout(() => {
|
|
|
- textarea.selectionStart = textarea.selectionEnd = data.cursorPos
|
|
|
- }, 0)
|
|
|
- } else {
|
|
|
- // 如果没有找到输入框,直接替换整个内容
|
|
|
- userInput.value = data.originalText
|
|
|
- }
|
|
|
- // 清空临时变量
|
|
|
- voiceRecognizedText.value = ""
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-// 处理录音状态变化
|
|
|
-const handleRecordingStatusChanged = (isRecording) => {
|
|
|
- console.log('录音状态:', isRecording)
|
|
|
- const wasRecording = isVoiceRecording.value
|
|
|
- isVoiceRecording.value = isRecording
|
|
|
-
|
|
|
- // 如果是从录音状态切换到非录音状态,只需要清空临时变量
|
|
|
- if (wasRecording && !isRecording) {
|
|
|
- // 清空临时变量
|
|
|
- voiceRecognizedText.value = ""
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-// 获取上一条quest对话
|
|
|
-const previousQuestDialogue = computed(() => {
|
|
|
- const prevDialogue = getPreviousDialogue()
|
|
|
- if (prevDialogue?.type === 'quest') {
|
|
|
- return prevDialogue
|
|
|
- }
|
|
|
- return null
|
|
|
-})
|
|
|
-
|
|
|
-// 判断当前user对话是否应该显示单选框
|
|
|
-const isUserSingleChoice = computed(() => {
|
|
|
- if (currentDialogue.value?.type !== 'user') return false
|
|
|
- const prevDialogue = previousQuestDialogue.value
|
|
|
- return prevDialogue?.questionType === 'singleChoice' &&
|
|
|
- prevDialogue?.options &&
|
|
|
- prevDialogue.options.length > 0
|
|
|
-})
|
|
|
-
|
|
|
-// 判断是否是单选问题(quest类型时)
|
|
|
-const isSingleChoiceQuestion = computed(() => {
|
|
|
- return currentDialogue.value?.type === 'quest' &&
|
|
|
- currentDialogue.value?.questionType === 'singleChoice' &&
|
|
|
- currentDialogue.value?.options &&
|
|
|
- currentDialogue.value.options.length > 0
|
|
|
-})
|
|
|
-
|
|
|
-// 提交用户输入
|
|
|
-const submitUserInput = async () => {
|
|
|
- if (userInput.value.trim()) {
|
|
|
- console.log('用户输入:', userInput.value)
|
|
|
-
|
|
|
- await createAiChart();
|
|
|
- await doSendMessage();
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-// 取消用户输入
|
|
|
-const cancelUserInput = () => {
|
|
|
- userInput.value = ''
|
|
|
-}
|
|
|
-
|
|
|
-// 选择单选选项
|
|
|
-const selectOption = (label) => {
|
|
|
- selectedOption.value = label
|
|
|
-}
|
|
|
-
|
|
|
-// 取消单选选择
|
|
|
-const cancelSingleChoice = () => {
|
|
|
- selectedOption.value = ''
|
|
|
-}
|
|
|
-
|
|
|
-// 提交单选答案
|
|
|
-const submitSingleChoice = async () => {
|
|
|
- if (!selectedOption.value) return
|
|
|
-
|
|
|
- console.log('用户选择:', selectedOption.value)
|
|
|
-
|
|
|
- await createAiChart();
|
|
|
- await doSendSingleChoiceMessage();
|
|
|
-}
|
|
|
-
|
|
|
-// 解析 Markdown 内容
|
|
|
-const parseMarkdown = (content) => {
|
|
|
- if (!content) return ''
|
|
|
- return marked(content)
|
|
|
-}
|
|
|
-
|
|
|
-// 格式化诗词内容,在逗号和句号后添加换行
|
|
|
-const formatPoemContent = (content) => {
|
|
|
- if (!content) return ''
|
|
|
- const plainText = content.replace(/[\*#`\[\]\(\)]/g, '')
|
|
|
- return plainText.replace(/([。!?;、])/g, '$1<br/>')
|
|
|
-}
|
|
|
-
|
|
|
-const getCharacterSide = () => {
|
|
|
- return currentDialogueIndex.value % 2 === 0 ? 'left' : 'right'
|
|
|
-
|
|
|
-}
|
|
|
-
|
|
|
-// 根据角色ID获取角色名称
|
|
|
-const getRole = (roleName) => {
|
|
|
- return props.scriptRoles.find(r => r.name === roleName)
|
|
|
-}
|
|
|
-
|
|
|
-const getCharacterImage = (roleName) => {
|
|
|
- const role = getRole(roleName)
|
|
|
- return role ? role.avatar : ''
|
|
|
-}
|
|
|
-
|
|
|
-const stopAllAudio = () => {
|
|
|
- if (backgroundAudio.value) {
|
|
|
- backgroundAudio.value.pause()
|
|
|
- backgroundAudio.value.currentTime = 0
|
|
|
- }
|
|
|
- if (dialogueAudio.value) {
|
|
|
- dialogueAudio.value.pause()
|
|
|
- dialogueAudio.value.currentTime = 0
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-const playBackgroundAudio = () => {
|
|
|
- // 停止之前的背景音
|
|
|
- if (backgroundAudio.value) {
|
|
|
- backgroundAudio.value.pause()
|
|
|
- backgroundAudio.value.currentTime = 0
|
|
|
- }
|
|
|
-
|
|
|
- // 只有当背景类型为 imageAudio 时才播放背景音频
|
|
|
- if (currentBackgroundType.value === 'imageAudio' && currentSection.value?.backgroundAudio?.url && isPlaying.value) {
|
|
|
- backgroundAudio.value = new Audio(currentSection.value.backgroundAudio.url)
|
|
|
- backgroundAudio.value.loop = true
|
|
|
- backgroundAudio.value.volume = 1
|
|
|
- backgroundAudio.value.play().catch(e => console.error('背景音播放失败:', e))
|
|
|
- }
|
|
|
-
|
|
|
- // 处理背景视频
|
|
|
- if (currentBackgroundType.value === 'video' && isPlaybackStarted.value) {
|
|
|
- // 视频已经在模板中渲染,这里只需要确保它在播放状态
|
|
|
- if (backgroundVideoRef.value) {
|
|
|
- backgroundVideoRef.value.play().catch(e => console.error('背景视频播放失败:', e))
|
|
|
- }
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-const playDialogueAudio = (isAutoPlay = false) => {
|
|
|
- // 停止之前的对话语音
|
|
|
- if (dialogueAudio.value) {
|
|
|
- dialogueAudio.value.pause()
|
|
|
- dialogueAudio.value.currentTime = 0
|
|
|
- }
|
|
|
-
|
|
|
- // 播放当前对话的语音
|
|
|
- if (currentDialogue.value?.voiceoverUrl && currentDialogue.value?.type !== 'video') {
|
|
|
- const audio = new Audio(currentDialogue.value.voiceoverUrl)
|
|
|
- dialogueAudio.value = audio
|
|
|
-
|
|
|
- // 音频结束事件
|
|
|
- audio.onended = () => {
|
|
|
- // 检查是否是最后一个对话
|
|
|
- if (isAtLastDialogue()) {
|
|
|
- // 如果是用户输入类型,不立即触发dialogueEnded,等待AI回答完成
|
|
|
- if (currentDialogue.value?.type === 'user') {
|
|
|
- console.log('用户输入类型,等待AI回答完成后再提示');
|
|
|
- return;
|
|
|
- }
|
|
|
- // 语音播报完成且是最后一个对话,触发事件通知父组件
|
|
|
- console.log('普通对话语音播放完成,已到达最后一个对话,触发 dialogueEnded 事件');
|
|
|
- emit('dialogueEnded', props.isLastCourse);
|
|
|
- isPlaying.value = false;
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- // 如果是自动播放状态,继续播放下一条
|
|
|
- if (isAutoPlay && isPlaying.value) {
|
|
|
- setTimeout(() => {
|
|
|
- if (!playNext(true)) {
|
|
|
- // 播放完毕
|
|
|
- isPlaying.value = false
|
|
|
- stopAllAudio()
|
|
|
- }
|
|
|
- }, 100)
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // 播放音频
|
|
|
- audio.play().catch(e => {
|
|
|
- console.error('对话语音播放失败:', e)
|
|
|
- // 播放失败时,检查是否是最后一个对话
|
|
|
- if (isAtLastDialogue()) {
|
|
|
- // 如果是用户输入类型,不立即触发dialogueEnded,等待AI回答完成
|
|
|
- if (currentDialogue.value?.type === 'user') {
|
|
|
- console.log('用户输入类型,等待AI回答完成后再提示');
|
|
|
- return;
|
|
|
- }
|
|
|
- emit('dialogueEnded', props.isLastCourse);
|
|
|
- isPlaying.value = false;
|
|
|
- return;
|
|
|
- }
|
|
|
- // 播放失败时,2秒后跳转
|
|
|
- if (isAutoPlay && isPlaying.value) {
|
|
|
- setTimeout(() => {
|
|
|
- if (!playNext(true)) {
|
|
|
- // 播放完毕
|
|
|
- isPlaying.value = false
|
|
|
- stopAllAudio()
|
|
|
- }
|
|
|
- }, 2000)
|
|
|
- }
|
|
|
- })
|
|
|
- } else if (currentDialogue.value?.type !== 'video') {
|
|
|
- // 检查是否是最后一个对话
|
|
|
- if (isAtLastDialogue()) {
|
|
|
- // 如果是用户输入类型,不立即触发dialogueEnded,等待AI回答完成
|
|
|
- if (currentDialogue.value?.type === 'user') {
|
|
|
- console.log('用户输入类型,等待AI回答完成后再提示');
|
|
|
- return;
|
|
|
- }
|
|
|
- emit('dialogueEnded', props.isLastCourse);
|
|
|
- isPlaying.value = false;
|
|
|
- return;
|
|
|
- }
|
|
|
- // 如果没有语音文件,2秒后跳转
|
|
|
- if (isAutoPlay && isPlaying.value && currentDialogue.value?.type !== 'user') {
|
|
|
- setTimeout(() => {
|
|
|
- if (!playNext(true)) {
|
|
|
- // 播放完毕
|
|
|
- isPlaying.value = false
|
|
|
- stopAllAudio()
|
|
|
- }
|
|
|
- }, 2000)
|
|
|
- }
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-const togglePlay = () => {
|
|
|
- isPlaying.value = !isPlaying.value
|
|
|
- if (isPlaying.value) {
|
|
|
- // 播放背景音
|
|
|
- playBackgroundAudio()
|
|
|
- if(!getIsPlaying() && !conversationInProgress.value){
|
|
|
- if(currentDialogue.value?.type === 'video'){
|
|
|
- // 视频类型对话,播放视频
|
|
|
- if (dialogueVideoRef.value) {
|
|
|
- dialogueVideoRef.value.play().catch(e => console.error('对话视频播放失败:', e))
|
|
|
- }
|
|
|
- } else {
|
|
|
- // 开始播放序列
|
|
|
- playSequence()
|
|
|
- }
|
|
|
- }
|
|
|
- } else {
|
|
|
- // 暂停所有音频
|
|
|
- stopAllAudio()
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-// 处理视频结束事件
|
|
|
-const handleVideoEnded = () => {
|
|
|
- // 视频播放完毕后,继续播放下一条对话
|
|
|
- if (isPlaying.value) {
|
|
|
- setTimeout(() => {
|
|
|
- if (!playNext(true)) {
|
|
|
- // 播放完毕,检查是否是最后一个对话
|
|
|
- if (isAtLastDialogue()) {
|
|
|
- console.log('视频序列:已到达最后一个对话,触发 dialogueEnded 事件');
|
|
|
- emit('dialogueEnded', props.isLastCourse);
|
|
|
- }
|
|
|
- isPlaying.value = false
|
|
|
- stopAllAudio()
|
|
|
- }
|
|
|
- }, 1500)
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-// 自动播放序列
|
|
|
-const playSequence = () => {
|
|
|
- if (!isPlaying.value) return
|
|
|
-
|
|
|
- // 如果当前是用户输入卡片,暂停播放等待用户输入
|
|
|
- if (currentDialogue.value?.type === 'user') return
|
|
|
-
|
|
|
- // 检查当前对话是否为视频类型
|
|
|
- if (currentDialogue.value?.type === 'video') {
|
|
|
- // 视频类型对话,不自动播放下一条,等待视频结束
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- // 检查当前对话是否为诗词类型
|
|
|
- if (currentDialogue.value?.type === 'poem') {
|
|
|
- // 显示诗词并替换内容为最新的诗词
|
|
|
- showPoem.value = true
|
|
|
- currentPoemContent.value = currentDialogue.value.content
|
|
|
-
|
|
|
- // 检查是否有语音
|
|
|
- if (currentDialogue.value?.voiceoverUrl) {
|
|
|
- // 播放诗词语音
|
|
|
- playDialogueAudio(true)
|
|
|
- } else {
|
|
|
- // 没有语音,直接切换到下一条对话
|
|
|
- setTimeout(() => {
|
|
|
- if (!playNext(true)) {
|
|
|
- // 播放完毕,检查是否是最后一个对话
|
|
|
- if (isAtLastDialogue()) {
|
|
|
- console.log('诗词序列:已到达最后一个对话,触发 dialogueEnded 事件');
|
|
|
- emit('dialogueEnded', props.isLastCourse);
|
|
|
- }
|
|
|
- // 播放完毕
|
|
|
- isPlaying.value = false
|
|
|
- stopAllAudio()
|
|
|
- }
|
|
|
- }, 500)
|
|
|
- }
|
|
|
- } else if (currentDialogue.value?.type === 'video') {
|
|
|
- // 视频类型对话,不自动播放下一条,等待视频结束事件
|
|
|
- return
|
|
|
- } else {
|
|
|
- // 播放当前对话语音,传递isAutoPlay参数
|
|
|
- playDialogueAudio(true)
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-const playPrevious = () => {
|
|
|
- // 如果已经到达第一句,直接返回,不执行任何操作
|
|
|
- if (currentDialogueIndex.value === 0 && currentSectionIndex.value === 0) {
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- // 停止当前音频
|
|
|
- stopAllAudio()
|
|
|
-
|
|
|
- // 如果正在进行数字人对话,调用stopStream清理
|
|
|
- recoverQuestDialogue()
|
|
|
- stopPlayback(false) // 不触发回调
|
|
|
- if (conversationInProgress.value) {
|
|
|
- stopStream()
|
|
|
- }
|
|
|
-
|
|
|
- if (currentDialogueIndex.value > 0) {
|
|
|
- currentDialogueIndex.value--
|
|
|
- } else if (currentSectionIndex.value > 0) {
|
|
|
- currentSectionIndex.value--
|
|
|
- const section = props.scriptData.sections[currentSectionIndex.value]
|
|
|
- currentDialogueIndex.value = section.dialogues.length - 1
|
|
|
- // 切换环节时隐藏诗词
|
|
|
- showPoem.value = false
|
|
|
- currentPoemContent.value = ''
|
|
|
- }
|
|
|
-
|
|
|
- // 检查当前对话是否为诗词类型
|
|
|
- if (currentDialogue.value?.type === 'poem') {
|
|
|
- // 显示诗词并替换内容为最新的诗词
|
|
|
- showPoem.value = true
|
|
|
- currentPoemContent.value = currentDialogue.value.content
|
|
|
- } else if (currentDialogue.value?.type === 'video') {
|
|
|
- // 视频类型对话,隐藏诗词
|
|
|
- showPoem.value = false
|
|
|
- currentPoemContent.value = ""
|
|
|
- } else {
|
|
|
- showPoem.value = false
|
|
|
- currentPoemContent.value = ""
|
|
|
- //读取上一条诗词内容显示
|
|
|
- for (let i = currentDialogueIndex.value; i >= 0; i--) {
|
|
|
- let dialogueTemp = currentSection.value.dialogues[i];
|
|
|
- if (dialogueTemp.type === 'poem'){
|
|
|
- // 显示诗词并替换内容为最新的诗词
|
|
|
- showPoem.value = true
|
|
|
- currentPoemContent.value = dialogueTemp.content
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // 播放背景音
|
|
|
- nextTick(() => {
|
|
|
- playBackgroundAudio()
|
|
|
- // 播放当前对话语音(非诗词类型)
|
|
|
- if (currentDialogue.value?.type !== 'poem') {
|
|
|
- if (isPlaying.value) {
|
|
|
- playDialogueAudio(true)
|
|
|
- } else {
|
|
|
- playDialogueAudio()
|
|
|
- }
|
|
|
- }
|
|
|
- })
|
|
|
-}
|
|
|
-
|
|
|
-const playNext = (isAutoPlay = false) => {
|
|
|
- // 如果已经到达最后一句,直接返回,不执行任何操作
|
|
|
- if (isAtLastDialogue()) {
|
|
|
- return false
|
|
|
- }
|
|
|
-
|
|
|
- // 停止当前对话语音
|
|
|
- if (dialogueAudio.value) {
|
|
|
- dialogueAudio.value.pause()
|
|
|
- dialogueAudio.value.currentTime = 0
|
|
|
- }
|
|
|
-
|
|
|
- // 如果正在进行数字人对话,调用stopStream清理
|
|
|
- recoverQuestDialogue()
|
|
|
- stopPlayback(false)
|
|
|
- if (conversationInProgress.value) {
|
|
|
- stopStream()
|
|
|
- }
|
|
|
-
|
|
|
- if (currentSection.value && currentDialogueIndex.value < currentSection.value.dialogues.length - 1) {
|
|
|
- currentDialogueIndex.value++
|
|
|
-
|
|
|
- // 检查当前对话是否为诗词类型
|
|
|
- if (currentDialogue.value?.type === 'poem') {
|
|
|
- // 显示诗词并替换内容为最新的诗词
|
|
|
- showPoem.value = true
|
|
|
- currentPoemContent.value = currentDialogue.value.content
|
|
|
-
|
|
|
- // 检查是否有语音
|
|
|
- if (currentDialogue.value?.voiceoverUrl) {
|
|
|
- // 播放诗词语音
|
|
|
- if (isPlaying.value) {
|
|
|
- playDialogueAudio(true)
|
|
|
- } else {
|
|
|
- playDialogueAudio()
|
|
|
- }
|
|
|
- } else {
|
|
|
- // 没有语音,直接切换到下一条对话
|
|
|
- setTimeout(() => {
|
|
|
- playNext(isAutoPlay)
|
|
|
- }, 500)
|
|
|
- }
|
|
|
- return true
|
|
|
- } else if (currentDialogue.value?.type === 'video') {
|
|
|
- // 视频类型对话,隐藏诗词,不自动播放下一条
|
|
|
- showPoem.value = false
|
|
|
- currentPoemContent.value = ''
|
|
|
- return true
|
|
|
- }
|
|
|
-
|
|
|
- // 根据是否为自动播放状态决定如何播放语音
|
|
|
- if (isPlaying.value) {
|
|
|
- playDialogueAudio(true)
|
|
|
- } else {
|
|
|
- playDialogueAudio()
|
|
|
- }
|
|
|
- return true
|
|
|
- } else if (currentSectionIndex.value < props.scriptData.sections.length - 1) {
|
|
|
- currentSectionIndex.value++
|
|
|
- currentDialogueIndex.value = 0
|
|
|
- // 切换环节时隐藏诗词
|
|
|
- showPoem.value = false
|
|
|
- currentPoemContent.value = ''
|
|
|
- // 播放背景音
|
|
|
- nextTick(() => {
|
|
|
- playBackgroundAudio()
|
|
|
- // 检查新环节的第一个对话是否为诗词类型
|
|
|
- if (currentDialogue.value?.type === 'poem') {
|
|
|
- // 显示诗词
|
|
|
- showPoem.value = true
|
|
|
- currentPoemContent.value = currentDialogue.value.content
|
|
|
-
|
|
|
- // 检查是否有语音
|
|
|
- if (currentDialogue.value?.voiceoverUrl) {
|
|
|
- // 播放诗词语音
|
|
|
- if (isPlaying.value) {
|
|
|
- playDialogueAudio(true)
|
|
|
- } else {
|
|
|
- playDialogueAudio()
|
|
|
- }
|
|
|
- } else {
|
|
|
- // 没有语音,直接切换到下一条对话
|
|
|
- setTimeout(() => {
|
|
|
- playNext(isAutoPlay)
|
|
|
- }, 500)
|
|
|
- }
|
|
|
- } else if (currentDialogue.value?.type === 'video') {
|
|
|
- // 视频类型对话,隐藏诗词
|
|
|
- showPoem.value = false
|
|
|
- currentPoemContent.value = ''
|
|
|
- } else {
|
|
|
- // 根据是否为自动播放状态决定如何播放语音
|
|
|
- if (isPlaying.value) {
|
|
|
- playDialogueAudio(true)
|
|
|
- } else {
|
|
|
- playDialogueAudio()
|
|
|
- }
|
|
|
- }
|
|
|
- })
|
|
|
- return true
|
|
|
- }
|
|
|
- return false
|
|
|
-}
|
|
|
-
|
|
|
-// 返回主页按钮点击事件
|
|
|
-const goBackToMain = () => {
|
|
|
- // 停止所有音频
|
|
|
- stopAllAudio()
|
|
|
- // 跳转到 ai-general-course 页面,保持侧边栏选中状态
|
|
|
- router.push('/ai-general-course')
|
|
|
-}
|
|
|
-
|
|
|
-// 开始播放
|
|
|
-const maskLayer = ref(null)
|
|
|
-const startPlayback = () => {
|
|
|
- // 设置开始播放状态
|
|
|
- isPlaybackStarted.value = true
|
|
|
-
|
|
|
- // 开始播放背景视频
|
|
|
- if (backgroundVideoRef.value) {
|
|
|
- backgroundVideoRef.value.play().catch(e => console.error('背景视频播放失败:', e))
|
|
|
- }
|
|
|
-
|
|
|
- // 消失动画
|
|
|
- if (maskLayer.value) {
|
|
|
- maskLayer.value.classList.add('fade-out')
|
|
|
- // 等待动画完成后隐藏遮罩层
|
|
|
- setTimeout(() => {
|
|
|
- showMask.value = false
|
|
|
- }, 500)
|
|
|
- }
|
|
|
-
|
|
|
- // 检查当前对话是否为诗词类型
|
|
|
- if (currentDialogue.value?.type === 'poem') {
|
|
|
- // 显示诗词
|
|
|
- showPoem.value = true
|
|
|
- currentPoemContent.value = currentDialogue.value.content
|
|
|
-
|
|
|
- // 检查是否有语音
|
|
|
- if (currentDialogue.value?.voiceoverUrl) {
|
|
|
- // 播放诗词语音
|
|
|
- playDialogueAudio()
|
|
|
- } else {
|
|
|
- // 没有语音,直接切换到下一条对话
|
|
|
- setTimeout(() => {
|
|
|
- playNext()
|
|
|
- }, 500)
|
|
|
- }
|
|
|
- } else if (currentDialogue.value?.type === 'video') {
|
|
|
- // 视频类型对话,隐藏诗词
|
|
|
- showPoem.value = false
|
|
|
- currentPoemContent.value = ''
|
|
|
- } else {
|
|
|
- // 播放当前对话语音
|
|
|
- playDialogueAudio()
|
|
|
- }
|
|
|
-}
|
|
|
+});
|
|
|
|
|
|
-// 键盘事件处理,键盘左右箭头控制对话
|
|
|
-const handleKeydown = (event) => {
|
|
|
- // 如果遮罩层显示,不处理键盘事件
|
|
|
- if (showMask.value) {
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- // 如果当前是用户输入对话,不处理键盘事件,让默认行为生效(在输入框中左右移动光标)
|
|
|
- if (currentDialogue.value?.type === 'user') {
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- // 处理左右箭头键
|
|
|
- switch (event.key) {
|
|
|
- case 'ArrowLeft':
|
|
|
- playPrevious()
|
|
|
- event.preventDefault() // 防止默认行为
|
|
|
- break
|
|
|
- case 'ArrowRight':
|
|
|
- playNext()
|
|
|
- event.preventDefault() // 防止默认行为
|
|
|
- break
|
|
|
- default:
|
|
|
- // 其他按键不做处理
|
|
|
- break
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-// 监听环节变化
|
|
|
-watch(currentSectionIndex, () => {
|
|
|
- playBackgroundAudio()
|
|
|
-})
|
|
|
-
|
|
|
-// 监听 scriptData 变化(侧边栏切换话题时)
|
|
|
-watch(() => props.scriptData, (newVal, oldVal) => {
|
|
|
- if (newVal && oldVal && newVal !== oldVal) {
|
|
|
- // 停止所有音频
|
|
|
- stopAllAudio()
|
|
|
- // 如果正在进行数字人对话,调用stopStream清理
|
|
|
- recoverQuestDialogue()
|
|
|
- stopPlayback(false)
|
|
|
- if (conversationInProgress.value) {
|
|
|
- stopStream()
|
|
|
- }
|
|
|
- // 清空索引
|
|
|
- currentSectionIndex.value = 0
|
|
|
- currentDialogueIndex.value = 0
|
|
|
- // 重置播放状态
|
|
|
- isPlaying.value = false
|
|
|
- // 显示遮罩层
|
|
|
- showMask.value = true
|
|
|
- // 清空用户输入
|
|
|
- userInput.value = ''
|
|
|
- // 清空对话缓存
|
|
|
- currentDialogueCache.value = null
|
|
|
- // 隐藏诗词
|
|
|
- showPoem.value = false
|
|
|
- currentPoemContent.value = ''
|
|
|
- }
|
|
|
-}, { deep: true })
|
|
|
-
|
|
|
-// 会话ID
|
|
|
-const activeConversationId = ref(null)
|
|
|
-const conversationInProgress = ref(false)
|
|
|
-const conversationInAbortController = ref()
|
|
|
-const receiveMessageFullText = ref('')
|
|
|
-
|
|
|
-//创建对话
|
|
|
-const createAiChart = async () => {
|
|
|
- let role = props.scriptRoles.find(r => r.name === currentDialogue.value.roleName)
|
|
|
- // 智能问答
|
|
|
- await CreateDialogue({ roleId: role.id })
|
|
|
- .then(res => {
|
|
|
- console.log("创建会话:", res.data);
|
|
|
- activeConversationId.value = res.data
|
|
|
- })
|
|
|
- .catch(error => {
|
|
|
- console.error('请求出错:', error)
|
|
|
- })
|
|
|
-}
|
|
|
-
|
|
|
-/** 真正执行【发送】消息操作 */
|
|
|
-const doSendMessage = async () => {
|
|
|
- // 校验
|
|
|
- if (userInput.value.length < 1) {
|
|
|
- console.error('发送失败,原因:内容为空!')
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- if (activeConversationId.value == null) {
|
|
|
- console.error('还没创建对话,不能发送!')
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- let userInputTemp = userInput.value;
|
|
|
- let currentDialogueTemp = currentSection.value.dialogues[currentDialogueIndex.value-1]
|
|
|
- userInputTemp += "(此内容是帮我解答的问题,问题是:" + currentDialogueTemp.content + ",回复要求:根据问题回复我回答的内容是否正确,并给予鼓励或夸赞;注意请使用精简回答,尽量控制字体数量在50个字内)"
|
|
|
- // 执行发送
|
|
|
- await doSendMessageStream({
|
|
|
- conversationId: activeConversationId.value,
|
|
|
- content: userInputTemp,
|
|
|
- contentAnswer: null,
|
|
|
- })
|
|
|
- // 清空输入框
|
|
|
- userInput.value = ''
|
|
|
-}
|
|
|
-
|
|
|
-/** 发送单选问题消息 */
|
|
|
-const doSendSingleChoiceMessage = async () => {
|
|
|
- if (activeConversationId.value == null) {
|
|
|
- console.error('还没创建对话,不能发送!')
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- // 使用上一条quest对话的数据
|
|
|
- const dialogue = previousQuestDialogue.value
|
|
|
- if (!dialogue) {
|
|
|
- console.error('找不到上一条quest对话!')
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- // 构建选项字符串
|
|
|
- const optionsStr = dialogue.options.map((opt, idx) => `${optionLabels[idx]}. ${opt.content}`).join(';')
|
|
|
-
|
|
|
- // 构建发送内容:包含问题、选项、用户答案
|
|
|
- const content = `问题:${dialogue.content}\n选项:${optionsStr}\n我的答案:${selectedOption.value}\n正确答案:${dialogue.answer}\n\n请判断我的答案是否正确,并给予鼓励或夸赞,回复请精简,控制在50字内。`
|
|
|
-
|
|
|
- console.log('发送单选问题:', content)
|
|
|
-
|
|
|
- // 执行发送
|
|
|
- await doSendMessageStream({
|
|
|
- conversationId: activeConversationId.value,
|
|
|
- content: content,
|
|
|
- contentAnswer: null,
|
|
|
- })
|
|
|
-
|
|
|
- // 清空选择
|
|
|
- selectedOption.value = ''
|
|
|
-}
|
|
|
-
|
|
|
-// 获取上一条对话
|
|
|
-const getPreviousDialogue = () => {
|
|
|
- const section = props.scriptData.sections[currentSectionIndex.value]
|
|
|
- if (!section) return null
|
|
|
- if (currentDialogueIndex.value > 0) {
|
|
|
- return section.dialogues[currentDialogueIndex.value - 1]
|
|
|
- }
|
|
|
- return null
|
|
|
-}
|
|
|
+const emit = defineEmits(['dialogueEnded']);
|
|
|
|
|
|
-
|
|
|
-
|
|
|
-/** 显示问题回答对话 */
|
|
|
-const showQuestAnswerDialogue = () => {
|
|
|
- // 缓存当前对话
|
|
|
- currentDialogueCache.value = JSON.parse(JSON.stringify(currentDialogue.value))
|
|
|
-
|
|
|
- // 将当前对话类型设置为数字人对话
|
|
|
- currentDialogue.value.type = "digital"
|
|
|
- // 设置默认内容为"让我思考一下..."
|
|
|
- currentDialogue.value.content = "让我思考一下..."
|
|
|
-
|
|
|
-}
|
|
|
-
|
|
|
-//延时恢复对话,避免立即回复导致对话内容被覆盖
|
|
|
-const delayRecoverQuestDialogue = () => {
|
|
|
- // Message().error('当前网络无反应,请稍后重试!', true);
|
|
|
- // 设置默认内容为"让我思考一下..."
|
|
|
- currentDialogue.value.content = "当前网络无反应,请稍后重试!"
|
|
|
-
|
|
|
- setTimeout(() => {
|
|
|
- recoverQuestDialogue()
|
|
|
- }, 1500)
|
|
|
-}
|
|
|
-
|
|
|
-/** 回复对话 */
|
|
|
-const recoverQuestDialogue = () => {
|
|
|
- // 如果有缓存的对话
|
|
|
- if (currentDialogueCache.value){
|
|
|
- // 恢复当前对话
|
|
|
- const currentSection = props.scriptData.sections[currentSectionIndex.value]
|
|
|
- if (currentSection) {
|
|
|
- currentSection.dialogues[currentDialogueIndex.value] = JSON.parse(JSON.stringify(currentDialogueCache.value))
|
|
|
- }
|
|
|
- // 清空缓存
|
|
|
- currentDialogueCache.value = null
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-/** 真正执行【发送】消息操作 */
|
|
|
-const doSendMessageStream = async userMessage => {
|
|
|
- // 创建 AbortController 实例,以便中止请求
|
|
|
- conversationInAbortController.value = new AbortController()
|
|
|
- // 标记对话进行中
|
|
|
- conversationInProgress.value = true
|
|
|
- // 设置为空
|
|
|
- receiveMessageFullText.value = ''
|
|
|
-
|
|
|
- showQuestAnswerDialogue()
|
|
|
-
|
|
|
- try {
|
|
|
-
|
|
|
- // 发送 event stream
|
|
|
- let isFirstChunk = true // 是否是第一个 chunk 消息段
|
|
|
- await sendChatMessageStream(
|
|
|
- userMessage.conversationId,
|
|
|
- userMessage.content,
|
|
|
- userMessage.contentAnswer,
|
|
|
- conversationInAbortController.value,
|
|
|
- true, // enableContext 参数
|
|
|
- async res => {
|
|
|
- const { code, data, msg } = JSON.parse(res.data)
|
|
|
- if (code !== 0) {
|
|
|
- console.log(`对话异常! ${msg}`)
|
|
|
- stopStream();
|
|
|
- delayRecoverQuestDialogue()
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- if (data.eventType === 'TEXT') {
|
|
|
-
|
|
|
- // 如果内容为空,就不处理。
|
|
|
- if (data.receive?.content === '') {
|
|
|
- return
|
|
|
- }
|
|
|
- receiveMessageFullText.value += data.receive.content
|
|
|
- // 更新数字人对话框内容
|
|
|
- currentDialogue.value.content = receiveMessageFullText.value
|
|
|
- // 首次返回需要添加一个 message 到页面,后面的都是更新
|
|
|
- if (isFirstChunk) {
|
|
|
- isFirstChunk = false
|
|
|
- //第一次返回
|
|
|
- } else {
|
|
|
- //更新最后一条消息
|
|
|
- }
|
|
|
- }
|
|
|
- if (data.eventType === 'AUDIO') {
|
|
|
- // 处理音频消息
|
|
|
- await playAudioChunk(data.audioData);
|
|
|
- }
|
|
|
- },
|
|
|
- error => {
|
|
|
- console.log(`对话异常! ${error}`)
|
|
|
- stopStream()
|
|
|
-
|
|
|
- delayRecoverQuestDialogue()
|
|
|
- // 需要抛出异常,禁止重试
|
|
|
- throw error
|
|
|
- },
|
|
|
- () => {
|
|
|
- console.log(`结束对话! `)
|
|
|
- stopStream()
|
|
|
- // AI回答完成,检查是否是最后一个对话且是用户输入类型
|
|
|
- if (isAtLastDialogue() && currentDialogue.value?.type === 'user') {
|
|
|
- console.log('AI回答完成,触发 dialogueEnded 事件');
|
|
|
- emit('dialogueEnded', props.isLastCourse);
|
|
|
- isPlaying.value = false;
|
|
|
- }
|
|
|
- }
|
|
|
- )
|
|
|
- } catch (error) {
|
|
|
- console.error('发送消息失败:', error)
|
|
|
- stopStream()
|
|
|
- delayRecoverQuestDialogue()
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-/** 停止 stream 流式调用 */
|
|
|
-const stopStream = async () => {
|
|
|
- // 如果 stream 进行中的 message,就需要调用 controller 结束
|
|
|
- if (conversationInAbortController.value) {
|
|
|
- conversationInAbortController.value.abort()
|
|
|
- }
|
|
|
- // 销毁语音读取
|
|
|
- // stopPlayback();
|
|
|
- // 设置为 false
|
|
|
- conversationInProgress.value = false
|
|
|
-
|
|
|
- console.log(`结束对话!更改状态: `,conversationInProgress.value)
|
|
|
-}
|
|
|
-
|
|
|
-// 处理音频播放完成
|
|
|
-const handleAudioPlaybackComplete = () => {
|
|
|
- console.log('智能问答音频播放完成');
|
|
|
-
|
|
|
- // 先清除回调,防止 playNext 内部调用 stopPlayback(false) 时
|
|
|
- // 关闭 audioContext 触发 source.onended → processAudioQueue → 再次触发回调,造成二次跳转
|
|
|
- setOnPlaybackComplete(null);
|
|
|
-
|
|
|
- stopAllAudio();
|
|
|
-
|
|
|
- // 检查是否是最后一个对话
|
|
|
- if (isAtLastDialogue()) {
|
|
|
- // 语音播报完成且是最后一个对话,触发事件通知父组件
|
|
|
- console.log('已到达最后一个对话,触发 dialogueEnded 事件');
|
|
|
- emit('dialogueEnded', props.isLastCourse);
|
|
|
- isPlaying.value = false;
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- // 如果处于自动播放状态,继续播放下一条对话
|
|
|
- if (isPlaying.value) {
|
|
|
- // 恢复回调,供下一条 AI 对话使用
|
|
|
- setOnPlaybackComplete(handleAudioPlaybackComplete);
|
|
|
- if (playNext(true)) {
|
|
|
- // playNext 内部已调用 playDialogueAudio(true),无需再调 playSequence
|
|
|
- } else {
|
|
|
- // 播放完毕
|
|
|
- isPlaying.value = false;
|
|
|
- stopAllAudio();
|
|
|
- }
|
|
|
- }
|
|
|
+const handleDialogueEnded = (isLastCourse) => {
|
|
|
+ emit('dialogueEnded', isLastCourse);
|
|
|
};
|
|
|
-
|
|
|
-// 判断是否是最后一个对话
|
|
|
-const isAtLastDialogue = () => {
|
|
|
- if (!props.scriptData.sections || props.scriptData.sections.length === 0) {
|
|
|
- return false
|
|
|
- }
|
|
|
- const lastSectionIndex = props.scriptData.sections.length - 1
|
|
|
- const lastSection = props.scriptData.sections[lastSectionIndex]
|
|
|
- if (!lastSection.dialogues || lastSection.dialogues.length === 0) {
|
|
|
- return false
|
|
|
- }
|
|
|
- const lastDialogueIndex = lastSection.dialogues.length - 1
|
|
|
-
|
|
|
- return currentSectionIndex.value === lastSectionIndex &&
|
|
|
- currentDialogueIndex.value === lastDialogueIndex
|
|
|
-}
|
|
|
-
|
|
|
-// 组件挂载时添加键盘事件监听
|
|
|
-onMounted(() => {
|
|
|
- window.addEventListener('keydown', handleKeydown)
|
|
|
- // 播放背景音
|
|
|
- playBackgroundAudio()
|
|
|
- // // 播放当前对话语音
|
|
|
- // playDialogueAudio()
|
|
|
- // 设置音频播放完成回调
|
|
|
- setOnPlaybackComplete(handleAudioPlaybackComplete)
|
|
|
-})
|
|
|
-
|
|
|
-// 组件卸载时移除键盘事件监听和停止音频
|
|
|
-onUnmounted(() => {
|
|
|
- window.removeEventListener('keydown', handleKeydown)
|
|
|
- stopAllAudio()
|
|
|
- stopPlayback(false)
|
|
|
-})
|
|
|
</script>
|
|
|
|
|
|
<style scoped lang="scss">
|
|
|
-@use "sass:math";
|
|
|
-
|
|
|
-@function rpx($px) {
|
|
|
- @return math.div($px, 750) * 100vw;
|
|
|
-}
|
|
|
-
|
|
|
-.dialog-content-wrapper {
|
|
|
- width: 100%;
|
|
|
- height: 100%;
|
|
|
- position: absolute;
|
|
|
- top: 0;
|
|
|
- left: 0;
|
|
|
- z-index: 100;
|
|
|
-}
|
|
|
-
|
|
|
-.title-box {
|
|
|
- position: absolute;
|
|
|
- top: 0;
|
|
|
- left: 0;
|
|
|
- right: 0;
|
|
|
- height: rpx(60);
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- justify-content: space-between;
|
|
|
- color: white;
|
|
|
- padding: 0 rpx(20);
|
|
|
-}
|
|
|
-
|
|
|
-.title-left {
|
|
|
- width: 33.33%;
|
|
|
- height: 100%;
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- gap: rpx(5);
|
|
|
- z-index: 30;
|
|
|
- .box-icon {
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- color: #0064BE;
|
|
|
- gap: rpx(5);
|
|
|
- padding: rpx(5) rpx(10);
|
|
|
- background: linear-gradient(135deg, #A0DCF0, #50BEF0);
|
|
|
- border: rpx(1) solid rgba(0, 100, 192);
|
|
|
- border-radius: rpx(30);
|
|
|
- backdrop-filter: blur(10px);
|
|
|
- cursor: pointer;
|
|
|
- transition: all 0.3s ease;
|
|
|
- font-size: rpx(9);
|
|
|
- font-weight: 500;
|
|
|
- width: fit-content;
|
|
|
- }
|
|
|
-
|
|
|
- .box-icon:hover {
|
|
|
- background-color: rgba(255, 255, 255, 90%);
|
|
|
- transform: translateX(-3px);
|
|
|
- }
|
|
|
-
|
|
|
- .left-icon {
|
|
|
- font-size: rpx(12);
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-.title-center {
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- justify-content: center;
|
|
|
- height: 100%;
|
|
|
- background-image: url('@/assets/dialogue/number-title.png');
|
|
|
- background-size: 100% 85%;
|
|
|
- background-repeat: no-repeat;
|
|
|
- background-position: center;
|
|
|
- width: fit-content;
|
|
|
- padding: 0 rpx(100);
|
|
|
- min-width: rpx(100);
|
|
|
- z-index: 10;
|
|
|
-}
|
|
|
-
|
|
|
-.title-text {
|
|
|
- height: 100%;
|
|
|
- font-size: rpx(11);
|
|
|
- font-weight: bold;
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- justify-content: center;
|
|
|
- white-space: nowrap;
|
|
|
-}
|
|
|
-
|
|
|
-.title-right {
|
|
|
- width: 33.33%;
|
|
|
- height: 100%;
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- justify-content: flex-end;
|
|
|
- gap: rpx(10);
|
|
|
- z-index: 10;
|
|
|
- .box-icon {
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- color: #0064BE;
|
|
|
- gap: rpx(5);
|
|
|
- padding: rpx(5) rpx(10);
|
|
|
- background: linear-gradient(135deg, #A0DCF0, #50BEF0);
|
|
|
- border: rpx(1) solid rgba(0, 100, 192);
|
|
|
- border-radius: rpx(30);
|
|
|
- backdrop-filter: blur(10px);
|
|
|
- cursor: pointer;
|
|
|
- transition: all 0.3s ease;
|
|
|
- font-size: rpx(9);
|
|
|
- font-weight: 500;
|
|
|
- width: fit-content;
|
|
|
- }
|
|
|
-
|
|
|
- .box-icon:hover {
|
|
|
- background-color: rgba(255, 255, 255, 90%);
|
|
|
- transform: translateX(-3px);
|
|
|
- }
|
|
|
-
|
|
|
- .left-icon {
|
|
|
- font-size: rpx(12);
|
|
|
- }
|
|
|
-
|
|
|
- .play-text {
|
|
|
- font-size: rpx(9);
|
|
|
- color: #0064BE;
|
|
|
- font-weight: 500;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
-.background-image {
|
|
|
- width: 100%;
|
|
|
- height: 100%;
|
|
|
- object-fit: cover;
|
|
|
- z-index: 1;
|
|
|
- position: relative;
|
|
|
-}
|
|
|
-
|
|
|
-.background-video {
|
|
|
- width: 100%;
|
|
|
- height: 100%;
|
|
|
- object-fit: cover;
|
|
|
- z-index: 1;
|
|
|
- position: relative;
|
|
|
-}
|
|
|
-
|
|
|
-/* 遮罩层样式 */
|
|
|
-.mask-layer {
|
|
|
- position: absolute;
|
|
|
- top: 0;
|
|
|
- left: 0;
|
|
|
- right: 0;
|
|
|
- bottom: 0;
|
|
|
- background-color: rgba(0, 0, 0, 0.7);
|
|
|
- z-index: 20;
|
|
|
- display: flex;
|
|
|
- justify-content: center;
|
|
|
- align-items: center;
|
|
|
- opacity: 1;
|
|
|
- transition: all 0.5s ease-out;
|
|
|
-}
|
|
|
-
|
|
|
-.mask-layer.fade-out {
|
|
|
- opacity: 0;
|
|
|
- transform: scale(1.1);
|
|
|
- z-index: -1;
|
|
|
-}
|
|
|
-
|
|
|
-.play-button-container {
|
|
|
- display: flex;
|
|
|
- justify-content: center;
|
|
|
- align-items: center;
|
|
|
-}
|
|
|
-
|
|
|
-.play-button {
|
|
|
- width: rpx(80);
|
|
|
- height: rpx(80);
|
|
|
- border-radius: 50%;
|
|
|
- border: none;
|
|
|
- background: transparent;
|
|
|
- display: flex;
|
|
|
- justify-content: center;
|
|
|
- align-items: center;
|
|
|
- cursor: pointer;
|
|
|
- outline: none;
|
|
|
- -webkit-tap-highlight-color: transparent;
|
|
|
-}
|
|
|
-
|
|
|
-.play-icon {
|
|
|
- font-size: rpx(40);
|
|
|
- color: #A0DCF0;
|
|
|
- transition: all 0.3s ease;
|
|
|
- cursor: pointer;
|
|
|
-}
|
|
|
-
|
|
|
-.play-icon:hover {
|
|
|
- transform: scale(1.2);
|
|
|
- color: #50BEF0;
|
|
|
- text-shadow: 0 0 rpx(10) rgba(64, 158, 255, 0.5);
|
|
|
-}
|
|
|
-
|
|
|
-.content-box {
|
|
|
- position: absolute;
|
|
|
- top: rpx(60);
|
|
|
- left: 0;
|
|
|
- right: 0;
|
|
|
- bottom: 0;
|
|
|
- display: flex;
|
|
|
- align-items: flex-end;
|
|
|
- justify-content: center;
|
|
|
- padding-bottom: rpx(0);
|
|
|
- z-index: 10;
|
|
|
-}
|
|
|
-
|
|
|
-.character {
|
|
|
- position: absolute;
|
|
|
- bottom: 0;
|
|
|
- width: rpx(135);
|
|
|
- height: rpx(240);
|
|
|
- background-size: contain;
|
|
|
- background-position: bottom;
|
|
|
- background-repeat: no-repeat;
|
|
|
- opacity: 0;
|
|
|
- z-index: 1;
|
|
|
- // background-color: #fff;
|
|
|
-}
|
|
|
-
|
|
|
-.character.left {
|
|
|
- left: rpx(17);
|
|
|
- transform: translateX(-100%);
|
|
|
- animation: characterEnterLeft 0.8s ease forwards;
|
|
|
-}
|
|
|
-
|
|
|
-.character.right {
|
|
|
- right: rpx(17);
|
|
|
- transform: translateX(100%) scaleX(-1);
|
|
|
- animation: characterEnterRight 0.8s ease forwards;
|
|
|
-}
|
|
|
-
|
|
|
-@keyframes characterEnterLeft {
|
|
|
- 0% {
|
|
|
- opacity: 0;
|
|
|
- transform: translateX(-100%) scale(0.8);
|
|
|
- }
|
|
|
- 70% {
|
|
|
- opacity: 0.9;
|
|
|
- transform: translateX(10%) scale(1.05);
|
|
|
- }
|
|
|
- 100% {
|
|
|
- opacity: 1;
|
|
|
- transform: translateX(0) scale(1);
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-@keyframes characterEnterRight {
|
|
|
- 0% {
|
|
|
- opacity: 0;
|
|
|
- transform: translateX(100%) scale(0.8) scaleX(-1);
|
|
|
- }
|
|
|
- 70% {
|
|
|
- opacity: 0.9;
|
|
|
- transform: translateX(-10%) scale(1.05) scaleX(-1);
|
|
|
- }
|
|
|
- 100% {
|
|
|
- opacity: 1;
|
|
|
- transform: translateX(0) scale(1) scaleX(-1);
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-.dialogue-card {
|
|
|
- background: rgba(255, 255, 255, 0.9);
|
|
|
- border-radius: rpx(6);
|
|
|
- padding: rpx(8);
|
|
|
- max-width: 35%;
|
|
|
- min-width: rpx(200);
|
|
|
- box-shadow: 0 rpx(3.5) rpx(10) rgba(0, 0, 0, 0.15);
|
|
|
- position: absolute;
|
|
|
- bottom: rpx(50);
|
|
|
- width: auto;
|
|
|
- display: inline-block;
|
|
|
- z-index: 2;
|
|
|
-}
|
|
|
-
|
|
|
-.dialogue-card.left {
|
|
|
- left: rpx(145);
|
|
|
- animation: dialogueEnterLeft 0.6s ease forwards;
|
|
|
-}
|
|
|
-
|
|
|
-.dialogue-card.right {
|
|
|
- right: rpx(145);
|
|
|
- animation: dialogueEnterRight 0.6s ease forwards;
|
|
|
-}
|
|
|
-
|
|
|
-.dialogue-card.left::before {
|
|
|
- content: '';
|
|
|
- position: absolute;
|
|
|
- left: rpx(-11.5);
|
|
|
- bottom: rpx(12);
|
|
|
- width: 0;
|
|
|
- height: 0;
|
|
|
- border-top: rpx(7) solid transparent;
|
|
|
- border-bottom: rpx(7) solid transparent;
|
|
|
- border-right: rpx(12) solid rgba(255, 255, 255, 0.9);
|
|
|
- transform: translateY(0);
|
|
|
-}
|
|
|
-
|
|
|
-.dialogue-card.right::before {
|
|
|
- content: '';
|
|
|
- position: absolute;
|
|
|
- right: rpx(-11.5);
|
|
|
- bottom: rpx(12);
|
|
|
- width: 0;
|
|
|
- height: 0;
|
|
|
- border-top: rpx(7) solid transparent;
|
|
|
- border-bottom: rpx(7) solid transparent;
|
|
|
- border-left: rpx(12) solid rgba(255, 255, 255, 0.9);
|
|
|
- transform: translateY(0);
|
|
|
-}
|
|
|
-
|
|
|
-@keyframes dialogueEnterLeft {
|
|
|
- from {
|
|
|
- opacity: 0;
|
|
|
- transform: translateX(-rpx(30)) translateY(rpx(20));
|
|
|
- }
|
|
|
- to {
|
|
|
- opacity: 1;
|
|
|
- transform: translateX(0) translateY(0);
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-@keyframes dialogueEnterRight {
|
|
|
- from {
|
|
|
- opacity: 0;
|
|
|
- transform: translateX(rpx(30)) translateY(rpx(20));
|
|
|
- }
|
|
|
- to {
|
|
|
- opacity: 1;
|
|
|
- transform: translateX(0) translateY(0);
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-.dialogue-header {
|
|
|
- position: absolute;
|
|
|
- top: rpx(-11);
|
|
|
- left: rpx(12);
|
|
|
- background: #409EFF;
|
|
|
- color: white;
|
|
|
- padding: rpx(1.2) rpx(6);
|
|
|
- border-radius: rpx(5);
|
|
|
- font-size: rpx(8);
|
|
|
- box-shadow: 0 rpx(2.5) rpx(10) rgba(0, 0, 0, 0.2);
|
|
|
-}
|
|
|
-
|
|
|
-.dialogue-card.right .dialogue-header {
|
|
|
- left: rpx(10);
|
|
|
-}
|
|
|
-
|
|
|
-.role-name {
|
|
|
- font-weight: 600;
|
|
|
- color: white;
|
|
|
- font-size: rpx(10);
|
|
|
-}
|
|
|
-
|
|
|
-.dialogue-content {
|
|
|
- font-size: rpx(12);
|
|
|
- line-height: 1.2;
|
|
|
- color: #333;
|
|
|
- text-align: left;
|
|
|
- display: flex;
|
|
|
- flex-direction: column;
|
|
|
- align-items: center;
|
|
|
- justify-content: center;
|
|
|
- width: 100%;
|
|
|
-}
|
|
|
-
|
|
|
-.user-input-card {
|
|
|
- max-width: 50%;
|
|
|
- position: absolute;
|
|
|
- left: 50%;
|
|
|
- transform: translateX(-50%);
|
|
|
- bottom: rpx(50);
|
|
|
- right: auto;
|
|
|
- animation: dialogueEnterCenter 0.6s ease forwards;
|
|
|
-}
|
|
|
-
|
|
|
-.user-input-card::before {
|
|
|
- display: none;
|
|
|
-}
|
|
|
-
|
|
|
-.single-choice-card {
|
|
|
- max-width: 55%;
|
|
|
- position: absolute;
|
|
|
- left: 50%;
|
|
|
- transform: translateX(-50%);
|
|
|
- bottom: rpx(50);
|
|
|
- right: auto;
|
|
|
- animation: dialogueEnterCenter 0.6s ease forwards;
|
|
|
- z-index: 10;
|
|
|
-}
|
|
|
-
|
|
|
-.single-choice-card::before {
|
|
|
- display: none;
|
|
|
-}
|
|
|
-
|
|
|
-.single-choice-content {
|
|
|
- font-size: rpx(12);
|
|
|
- line-height: 1.4;
|
|
|
- color: #333;
|
|
|
- text-align: left;
|
|
|
- width: 100%;
|
|
|
-}
|
|
|
-
|
|
|
-.question-text {
|
|
|
- margin-bottom: rpx(10);
|
|
|
- padding-bottom: rpx(8);
|
|
|
- border-bottom: rpx(1) dashed #ddd;
|
|
|
- font-weight: 500;
|
|
|
-}
|
|
|
-
|
|
|
-.options-list {
|
|
|
- display: flex;
|
|
|
- flex-direction: column;
|
|
|
- gap: rpx(6);
|
|
|
- margin-bottom: rpx(12);
|
|
|
-}
|
|
|
-
|
|
|
-.option-item {
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- padding: rpx(8) rpx(12);
|
|
|
- background: #f8f9fa;
|
|
|
- border: rpx(1) solid #e9ecef;
|
|
|
- border-radius: rpx(6);
|
|
|
- cursor: pointer;
|
|
|
- transition: all 0.3s ease;
|
|
|
-
|
|
|
- &:hover {
|
|
|
- background: #e8f4fd;
|
|
|
- border-color: #409EFF;
|
|
|
- }
|
|
|
-
|
|
|
- &.selected {
|
|
|
- background: #e8f4fd;
|
|
|
- border-color: #409EFF;
|
|
|
- box-shadow: 0 rpx(2) rpx(8) rgba(64, 158, 255, 0.2);
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-.option-label {
|
|
|
- width: rpx(24);
|
|
|
- height: rpx(24);
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- justify-content: center;
|
|
|
- background: #fff;
|
|
|
- border: rpx(1) solid #ddd;
|
|
|
- border-radius: 50%;
|
|
|
- font-size: rpx(10);
|
|
|
- font-weight: 600;
|
|
|
- color: #666;
|
|
|
- margin-right: rpx(8);
|
|
|
- flex-shrink: 0;
|
|
|
- transition: all 0.3s ease;
|
|
|
-
|
|
|
- .option-item.selected & {
|
|
|
- background: #409EFF;
|
|
|
- border-color: #409EFF;
|
|
|
- color: #fff;
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-.option-content {
|
|
|
- font-size: rpx(11);
|
|
|
- color: #333;
|
|
|
- flex: 1;
|
|
|
-}
|
|
|
-
|
|
|
-@keyframes dialogueEnterCenter {
|
|
|
- from {
|
|
|
- opacity: 0;
|
|
|
- transform: translateX(-50%) translateY(rpx(20));
|
|
|
- }
|
|
|
- to {
|
|
|
- opacity: 1;
|
|
|
- transform: translateX(-50%) translateY(0);
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-.user-input-textarea {
|
|
|
- width: 95%;
|
|
|
- min-height: rpx(50);
|
|
|
- max-height: rpx(150);
|
|
|
- border: none;
|
|
|
- outline: none;
|
|
|
- background: transparent;
|
|
|
- font-size: rpx(10);
|
|
|
- line-height: 1.2;
|
|
|
- color: #333;
|
|
|
- resize: none;
|
|
|
- font-family: inherit;
|
|
|
- overflow-y: auto;
|
|
|
- text-align: left;
|
|
|
- padding: rpx(5) 0;
|
|
|
-}
|
|
|
-
|
|
|
-.voice-input-content {
|
|
|
- width: 95%;
|
|
|
- min-height: rpx(50);
|
|
|
- max-height: rpx(150);
|
|
|
- font-size: rpx(10);
|
|
|
- line-height: 1.2;
|
|
|
- color: #333;
|
|
|
- text-align: left;
|
|
|
- padding: rpx(5) 0;
|
|
|
- overflow-y: auto;
|
|
|
-}
|
|
|
-
|
|
|
-.voice-input-placeholder {
|
|
|
- width: 95%;
|
|
|
- min-height: rpx(50);
|
|
|
- font-size: rpx(10);
|
|
|
- line-height: 1.2;
|
|
|
- color: #999;
|
|
|
- text-align: left;
|
|
|
- padding: rpx(5) 0;
|
|
|
- font-style: italic;
|
|
|
-}
|
|
|
-
|
|
|
-/* 滚动条样式 */
|
|
|
-.user-input-textarea::-webkit-scrollbar {
|
|
|
- width: rpx(0);
|
|
|
-}
|
|
|
-
|
|
|
-.user-input-textarea::-webkit-scrollbar-track {
|
|
|
- background: rgba(0, 0, 0, 0.05);
|
|
|
- border-radius: rpx(3);
|
|
|
-}
|
|
|
-
|
|
|
-.user-input-textarea::-webkit-scrollbar-thumb {
|
|
|
- background: rgba(64, 158, 255, 0.5);
|
|
|
- border-radius: rpx(3);
|
|
|
-}
|
|
|
-
|
|
|
-.user-input-textarea::-webkit-scrollbar-thumb:hover {
|
|
|
- background: rgba(64, 158, 255, 0.8);
|
|
|
-}
|
|
|
-
|
|
|
-.input-actions {
|
|
|
- display: flex;
|
|
|
- justify-content: flex-end;
|
|
|
- gap: rpx(6);
|
|
|
- margin-top: rpx(6);
|
|
|
- width: 100%;
|
|
|
-}
|
|
|
-
|
|
|
-.cancel-btn, .submit-btn {
|
|
|
- padding: rpx(2.5) rpx(10);
|
|
|
- border: none;
|
|
|
- border-radius: rpx(4);
|
|
|
- font-size: rpx(8);
|
|
|
- cursor: pointer;
|
|
|
- transition: all 0.3s ease;
|
|
|
-}
|
|
|
-
|
|
|
-.cancel-btn {
|
|
|
- background: #E0E0E0;
|
|
|
- color: #666;
|
|
|
-}
|
|
|
-
|
|
|
-.submit-btn {
|
|
|
- background: #409EFF;
|
|
|
- color: white;
|
|
|
-}
|
|
|
-
|
|
|
-.cancel-btn:hover, .submit-btn:hover {
|
|
|
- transform: scale(1.05);
|
|
|
-}
|
|
|
-
|
|
|
-.cancel-btn:active, .submit-btn:active {
|
|
|
- transform: scale(0.95);
|
|
|
-}
|
|
|
-
|
|
|
-.dialogue-content :deep(p) {
|
|
|
- margin: 0 0 rpx(4) 0;
|
|
|
-}
|
|
|
-
|
|
|
-.dialogue-content :deep(strong),
|
|
|
-.dialogue-content :deep(b) {
|
|
|
- font-weight: bold;
|
|
|
-}
|
|
|
-
|
|
|
-.dialogue-content :deep(em),
|
|
|
-.dialogue-content :deep(i) {
|
|
|
- font-style: italic;
|
|
|
-}
|
|
|
-
|
|
|
-.dialogue-content :deep(ul),
|
|
|
-.dialogue-content :deep(ol) {
|
|
|
- margin: rpx(4) 0;
|
|
|
- padding-left: rpx(10);
|
|
|
-}
|
|
|
-
|
|
|
-.dialogue-content :deep(li) {
|
|
|
- margin: rpx(2) 0;
|
|
|
-}
|
|
|
-
|
|
|
-.dialogue-content :deep(code) {
|
|
|
- background-color: #f0f0f0;
|
|
|
- padding: rpx(1) rpx(3);
|
|
|
- border-radius: rpx(2);
|
|
|
- font-family: monospace;
|
|
|
- font-size: rpx(9);
|
|
|
-}
|
|
|
-
|
|
|
-.dialogue-content :deep(a) {
|
|
|
- color: #409EFF;
|
|
|
- text-decoration: underline;
|
|
|
-}
|
|
|
-
|
|
|
-.input-buttons-container {
|
|
|
- position: relative;
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- justify-content: center;
|
|
|
- width: 100%;
|
|
|
- z-index: 10;
|
|
|
- transition: all 0.3s ease;
|
|
|
- transform: scale(0.9);
|
|
|
- gap: rpx(10);
|
|
|
-
|
|
|
- .arrow-icon-circle {
|
|
|
- width: rpx(20);
|
|
|
- height: rpx(20);
|
|
|
- border-radius: 50%;
|
|
|
- border: rpx(1) solid rgba(0, 100, 192);
|
|
|
- background: linear-gradient(135deg, #A0DCF0, #50BEF0);
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- justify-content: center;
|
|
|
- cursor: pointer;
|
|
|
- transition: all 0.3s ease;
|
|
|
-
|
|
|
- &:hover:not(.disabled) {
|
|
|
- transform: scale(1.1);
|
|
|
- box-shadow: 0 rpx(1) rpx(6) rgba(0, 0, 0, 0.3);
|
|
|
- }
|
|
|
-
|
|
|
- &:active:not(.disabled) {
|
|
|
- transform: scale(0.95);
|
|
|
- }
|
|
|
-
|
|
|
- &.disabled {
|
|
|
- opacity: 0.5;
|
|
|
- cursor: not-allowed;
|
|
|
- border-color: rgba(0, 100, 192, 0.3);
|
|
|
- background: linear-gradient(135deg, rgba(160, 220, 240, 0.5), rgba(80, 190, 240, 0.5));
|
|
|
- }
|
|
|
-
|
|
|
- .arrow-icon {
|
|
|
- font-size: rpx(15);
|
|
|
- color: #0064BE;
|
|
|
- }
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-.voice-input-outer,
|
|
|
-.keyboard-input-outer {
|
|
|
- position: absolute;
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- justify-content: center;
|
|
|
- transition: all 0.3s ease;
|
|
|
-}
|
|
|
-
|
|
|
-.voice-input-outer {
|
|
|
- position: relative;
|
|
|
- width: rpx(50);
|
|
|
- height: rpx(50);
|
|
|
- border-radius: 50%;
|
|
|
- background: transparent;
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- justify-content: center;
|
|
|
-
|
|
|
- // 正常状态只显示一圈
|
|
|
- &::before {
|
|
|
- content: '';
|
|
|
- position: absolute;
|
|
|
- width: 85%;
|
|
|
- height: 85%;
|
|
|
- border-radius: 50%;
|
|
|
- border: rpx(2) solid rgba(80, 190, 240, 0.6);
|
|
|
- }
|
|
|
-
|
|
|
- // 录音时显示波纹效果
|
|
|
- &.recording {
|
|
|
- &::before {
|
|
|
- animation: pulse 2s infinite;
|
|
|
- border-color: rgba(0, 100, 192, 0.6);
|
|
|
- }
|
|
|
-
|
|
|
- &::after {
|
|
|
- content: '';
|
|
|
- position: absolute;
|
|
|
- width: 85%;
|
|
|
- height: 85%;
|
|
|
- border-radius: 50%;
|
|
|
- border: rpx(2) solid rgba(0, 100, 192, 0.4);
|
|
|
- animation: pulse 2s infinite 0.5s;
|
|
|
- }
|
|
|
-
|
|
|
- :deep(.voice-input-container) {
|
|
|
- .speech-btn {
|
|
|
- background: linear-gradient(135deg, #A0DCF0, #50BEF0);
|
|
|
- border-color: rgba(0, 100, 192, 1);
|
|
|
-
|
|
|
- .el-icon {
|
|
|
- color: #0064BE;
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // 占位符样式
|
|
|
- &.placeholder {
|
|
|
- visibility: hidden;
|
|
|
- }
|
|
|
-
|
|
|
- :deep(.voice-input-container) {
|
|
|
- position: relative;
|
|
|
- z-index: 10;
|
|
|
- .speech-btn {
|
|
|
- width: rpx(40);
|
|
|
- height: rpx(40);
|
|
|
- border-radius: 50%;
|
|
|
- border: rpx(2) solid rgba(0, 100, 192);
|
|
|
- background: linear-gradient(135deg, #A0DCF0, #50BEF0);
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- justify-content: center;
|
|
|
- padding: 0;
|
|
|
- gap: 0;
|
|
|
- transition: all 0.3s ease;
|
|
|
- cursor: pointer;
|
|
|
- position: relative;
|
|
|
- overflow: hidden;
|
|
|
-
|
|
|
- &:hover {
|
|
|
- transform: scale(1.05);
|
|
|
- box-shadow: 0 rpx(4) rpx(12) rgba(0, 0, 0, 0.3);
|
|
|
- }
|
|
|
-
|
|
|
- &:active {
|
|
|
- transform: scale(0.95);
|
|
|
- }
|
|
|
-
|
|
|
- &::before {
|
|
|
- content: '';
|
|
|
- position: absolute;
|
|
|
- top: 50%;
|
|
|
- left: 50%;
|
|
|
- width: 0;
|
|
|
- height: 0;
|
|
|
- border-radius: 50%;
|
|
|
- background: rgba(255, 255, 255, 0.5);
|
|
|
- transform: translate(-50%, -50%);
|
|
|
- transition: width 0.6s, height 0.6s;
|
|
|
- }
|
|
|
-
|
|
|
- &:active::before {
|
|
|
- width: rpx(80);
|
|
|
- height: rpx(80);
|
|
|
- }
|
|
|
-
|
|
|
- .el-icon {
|
|
|
- font-size: rpx(20);
|
|
|
- color: #0064BE;
|
|
|
- z-index: 1;
|
|
|
- }
|
|
|
-
|
|
|
- .countdown-text {
|
|
|
- display: block;
|
|
|
- font-size: rpx(8);
|
|
|
- color: #0064BE;
|
|
|
- position: absolute;
|
|
|
- bottom: rpx(5);
|
|
|
- left: 50%;
|
|
|
- transform: translateX(-50%);
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-@keyframes pulse {
|
|
|
- 0% {
|
|
|
- transform: scale(1);
|
|
|
- opacity: 1;
|
|
|
- }
|
|
|
- 100% {
|
|
|
- transform: scale(1.15);
|
|
|
- opacity: 0;
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-.keyboard-input-outer {
|
|
|
- width: rpx(30);
|
|
|
- height: rpx(30);
|
|
|
- border-radius: 50%;
|
|
|
- background: transparent;
|
|
|
-
|
|
|
- &.active {
|
|
|
- width: rpx(40);
|
|
|
- height: rpx(40);
|
|
|
- left: 50%;
|
|
|
- transform: translateX(-50%);
|
|
|
- z-index: 11;
|
|
|
-
|
|
|
- .keyboard-btn {
|
|
|
- width: rpx(36);
|
|
|
- height: rpx(36);
|
|
|
- border: rpx(2) solid rgba(0, 100, 192);
|
|
|
- box-shadow: 0 rpx(3) rpx(8) rgba(0, 0, 0, 0.3);
|
|
|
-
|
|
|
- .keyboard-icon {
|
|
|
- font-size: rpx(18);
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- &.inactive {
|
|
|
- width: rpx(24);
|
|
|
- height: rpx(24);
|
|
|
- left: calc(50% + rpx(40));
|
|
|
- transform: translateX(-50%);
|
|
|
- z-index: 10;
|
|
|
-
|
|
|
- .keyboard-btn {
|
|
|
- width: rpx(22);
|
|
|
- height: rpx(22);
|
|
|
- border: rpx(1) solid rgba(128, 128, 128, 0.5);
|
|
|
- box-shadow: none;
|
|
|
- background: linear-gradient(135deg, #E0E0E0, #C0C0C0);
|
|
|
-
|
|
|
- .keyboard-icon {
|
|
|
- font-size: rpx(12);
|
|
|
- color: #808080;
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-.keyboard-btn {
|
|
|
- width: rpx(28);
|
|
|
- height: rpx(28);
|
|
|
- border-radius: 50%;
|
|
|
- border: rpx(1) solid rgba(0, 100, 192);
|
|
|
- background: linear-gradient(135deg, #A0DCF0, #50BEF0);
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- justify-content: center;
|
|
|
- padding: 0;
|
|
|
- gap: 0;
|
|
|
- transition: all 0.3s ease;
|
|
|
- cursor: pointer;
|
|
|
-
|
|
|
- &:hover {
|
|
|
- transform: scale(1.1);
|
|
|
- box-shadow: 0 rpx(2) rpx(8) rgba(0, 0, 0, 0.3);
|
|
|
- }
|
|
|
-
|
|
|
- &:active {
|
|
|
- transform: scale(0.95);
|
|
|
- }
|
|
|
-
|
|
|
- .keyboard-icon {
|
|
|
- font-size: rpx(14);
|
|
|
- color: #0064BE;
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-/* 诗词显示样式 */
|
|
|
-.poem-display {
|
|
|
- position: absolute;
|
|
|
- top: 25%;
|
|
|
- left: 50%;
|
|
|
- transform: translate(-50%, -50%);
|
|
|
- width: rpx(500);
|
|
|
- height: rpx(200);
|
|
|
- padding: rpx(0);
|
|
|
- display: flex;
|
|
|
- justify-content: center;
|
|
|
- align-items: center;
|
|
|
- z-index: 5;
|
|
|
- animation: scrollBackgroundFadeIn 0.8s ease-out;
|
|
|
-}
|
|
|
-
|
|
|
-.poem-display::before {
|
|
|
- content: '';
|
|
|
- position: absolute;
|
|
|
- top: 0;
|
|
|
- left: 0;
|
|
|
- width: 100%;
|
|
|
- height: 100%;
|
|
|
- background-image: url('@/assets/dialogue/long-scroll.png');
|
|
|
- background-size: 100%;
|
|
|
- background-repeat: no-repeat;
|
|
|
- background-position: center;
|
|
|
- opacity: 0.8;
|
|
|
- z-index: -1;
|
|
|
-}
|
|
|
-
|
|
|
-.poem-content {
|
|
|
- text-align: center;
|
|
|
- animation: poemFadeIn 1s ease-in-out;
|
|
|
- transition: all 0.5s ease-in-out;
|
|
|
-}
|
|
|
-
|
|
|
-.poem-text {
|
|
|
- width: 100%;
|
|
|
- max-width: rpx(500);
|
|
|
- max-height: rpx(200);
|
|
|
- height: auto;
|
|
|
- font-family: 'STKaiti', 'KaiTi', '楷体', 'Ma Shan Zheng', cursive;
|
|
|
- font-size: rpx(20);
|
|
|
- color: black;
|
|
|
- position: relative;
|
|
|
- overflow: auto;
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- justify-content: center;
|
|
|
- text-align: center;
|
|
|
- transition: all 0.5s ease-in-out;
|
|
|
-}
|
|
|
-
|
|
|
-.poem-text::-webkit-scrollbar {
|
|
|
- width: rpx(0);
|
|
|
-}
|
|
|
-.poem-text::-webkit-scrollbar-track {
|
|
|
- background: rgba(255, 255, 255, 0.1);
|
|
|
- border-radius: rpx(4);
|
|
|
-}
|
|
|
-.poem-text::-webkit-scrollbar-thumb {
|
|
|
- background: rgba(0, 0, 0, 0.3);
|
|
|
- border-radius: rpx(4);
|
|
|
-}
|
|
|
-
|
|
|
-.poem-text::-webkit-scrollbar-thumb:hover {
|
|
|
- background: rgba(0, 0, 0, 0.5);
|
|
|
-}
|
|
|
-
|
|
|
-.video-display {
|
|
|
- position: absolute;
|
|
|
- top: 45%;
|
|
|
- left: 50%;
|
|
|
- transform: translate(-50%, -50%) scale(0.8);
|
|
|
- z-index: 5;
|
|
|
- width: 100%;
|
|
|
- display: flex;
|
|
|
- justify-content: center;
|
|
|
- align-items: center;
|
|
|
- opacity: 0;
|
|
|
- animation: videoFadeIn 0.8s ease-out forwards;
|
|
|
-}
|
|
|
-
|
|
|
-.video-frame {
|
|
|
- width: rpx(420);
|
|
|
- height: rpx(250);
|
|
|
- padding: rpx(5);
|
|
|
- background: linear-gradient(135deg, rgba(160, 220, 240, 0.8), rgba(80, 190, 240, 0.8));
|
|
|
- border: rpx(2) solid rgba(0, 100, 192, 0.8);
|
|
|
- border-radius: rpx(15);
|
|
|
- box-shadow: 0 rpx(8) rpx(25) rgba(0, 0, 0, 0.4);
|
|
|
- backdrop-filter: blur(rpx(5));
|
|
|
- display: flex;
|
|
|
- justify-content: center;
|
|
|
- align-items: center;
|
|
|
-}
|
|
|
-
|
|
|
-.dialogue-video {
|
|
|
- width: 100%;
|
|
|
- height: 100%;
|
|
|
- border-radius: rpx(10);
|
|
|
- background-color: #000;
|
|
|
- object-fit: contain;
|
|
|
-}
|
|
|
-
|
|
|
-.poem-text::before {
|
|
|
- content: '';
|
|
|
- position: absolute;
|
|
|
- top: 0;
|
|
|
- left: 0;
|
|
|
- right: 0;
|
|
|
- bottom: 0;
|
|
|
- animation: poemShine 3s ease-in-out infinite;
|
|
|
-}
|
|
|
-
|
|
|
-@keyframes poemFadeIn {
|
|
|
- from {
|
|
|
- opacity: 0;
|
|
|
- transform: scale(0.8) translateY(20px);
|
|
|
- }
|
|
|
- to {
|
|
|
- opacity: 1;
|
|
|
- transform: scale(1) translateY(0);
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-@keyframes scrollBackgroundFadeIn {
|
|
|
- from {
|
|
|
- opacity: 0;
|
|
|
- background-size: 80%;
|
|
|
- }
|
|
|
- to {
|
|
|
- opacity: 1;
|
|
|
- background-size: 100%;
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-
|
|
|
-.poem-text p {
|
|
|
- margin: rpx(10) 0;
|
|
|
- opacity: 0;
|
|
|
- animation: poemSlideIn 0.5s ease forwards;
|
|
|
- font-weight: 500;
|
|
|
- letter-spacing: rpx(2);
|
|
|
-}
|
|
|
-
|
|
|
-.poem-text p:nth-child(1) {
|
|
|
- animation-delay: 0.2s;
|
|
|
-}
|
|
|
-
|
|
|
-.poem-text p:nth-child(2) {
|
|
|
- animation-delay: 0.4s;
|
|
|
-}
|
|
|
-
|
|
|
-.poem-text p:nth-child(3) {
|
|
|
- animation-delay: 0.6s;
|
|
|
-}
|
|
|
-
|
|
|
-.poem-text p:nth-child(4) {
|
|
|
- animation-delay: 0.8s;
|
|
|
-}
|
|
|
-
|
|
|
-.poem-text p:nth-child(5) {
|
|
|
- animation-delay: 1s;
|
|
|
-}
|
|
|
-
|
|
|
-@keyframes poemSlideIn {
|
|
|
- from {
|
|
|
- opacity: 0;
|
|
|
- transform: translateY(10px);
|
|
|
- }
|
|
|
- to {
|
|
|
- opacity: 1;
|
|
|
- transform: translateY(0);
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-// 对话视频显示动画
|
|
|
-@keyframes videoFadeIn {
|
|
|
- 0% {
|
|
|
- opacity: 0;
|
|
|
- transform: translate(-50%, -50%) scale(0.8);
|
|
|
- }
|
|
|
- 70% {
|
|
|
- opacity: 1;
|
|
|
- transform: translate(-50%, -50%) scale(1.05);
|
|
|
- }
|
|
|
- 100% {
|
|
|
- opacity: 1;
|
|
|
- transform: translate(-50%, -50%) scale(1);
|
|
|
- }
|
|
|
-}
|
|
|
+/* 原有样式已迁移到各子组件中 */
|
|
|
</style>
|