Quellcode durchsuchen

强生:备份目前完整的两个功能

liyanbo vor 1 Monat
Ursprung
Commit
c74ad6f7f0

+ 14 - 8
src/router/index.js

@@ -22,6 +22,7 @@ const routes = [
   { path: '/promotion-login', component: () => import('../views/PromotionLogin.vue') },
   { path: '/qsfl-demo1', component: () => import('../views/qsfl/LoginQsfl.vue') },
   { path: '/qsfl-demo2', component: () => import('../views/qsfl/LoginQsfl2.vue') },
+  { path: '/qsfl-demo3', component: () => import('../views/qsfl/LoginQsfl3.vue') },
   // //【AI实验课】登录
   // { path: '/ai-login', component: () => import('../views/AiCourseLogin.vue') },
   // //【blockly编程课】免租户登录
@@ -187,12 +188,16 @@ const routes = [
 
   // ==========【强生菲林 - AI智能问答】
   {
-    path: '/ai-qa',
-    component: () => import('../views/qsfl/aiQA.vue')
+    path: '/qs-demo1',
+    component: () => import('../views/qsfl/qsflDemo1.vue')
   },
   {
-    path: '/ai-qa-new',
-    component: () => import('../views/qsfl/aiQANew.vue')
+    path: '/qs-demo2',
+    component: () => import('../views/qsfl/qsflDemo2.vue')
+  },
+  {
+    path: '/qs-demo3',
+    component: () => import('../views/qsfl/qsflDemo3.vue')
   },
 
 
@@ -304,7 +309,7 @@ const router = createRouter({
 // 导航守卫
 router.beforeEach(async (to, from, next) => {
   // ======= 免登录白名单(新增的页面在此注册即可免登录访问)=======
-  if ( ['/qsfl-demo1','/qsfl-demo2',
+  if ( ['/qsfl-demo1','/qsfl-demo2','/qsfl-demo3',
     '/register-login',
     '/reg',
     '/login-mobile',
@@ -323,8 +328,9 @@ router.beforeEach(async (to, from, next) => {
 
   // 如果未登录且不是允许访问的页面,重定向到登录页
   if (!isLoggedIn && !allowedPages.includes(to.path)) {
-    if (to.path === '/ai-qa')next('/qsfl-demo1')
-    if (to.path === '/ai-qa-new')next('/qsfl-demo2')
+    if (to.path === '/qs-demo1')next('/qsfl-demo1')
+    if (to.path === '/qs-demo2')next('/qsfl-demo2')
+    if (to.path === '/qs-demo3')next('/qsfl-demo3')
     next('/login')
     return
   }else if(to.path  === '/sub-jump') {
@@ -365,7 +371,7 @@ router.beforeEach(async (to, from, next) => {
   const hasManagementPermission = true // 管理界面默认允许所有登录用户访问
 
   // 检查目标路由是否在允许的范围内
-  if (['/ai-qa', '/ai-qa-new'].includes(to.path)) {
+  if (['/qs-demo1', '/qs-demo2', '/qs-demo3'].includes(to.path)) {
     // 强生菲林问答页要求已登录,但不受课程菜单角色权限限制。
     isAllowed = true
   } else if ((to.path === managementRoutes.home || managementRoutes.children.includes(to.path)) && hasManagementPermission) {

+ 1 - 1
src/views/qsfl/LoginQsfl.vue

@@ -24,7 +24,7 @@ const QSFL_LOGIN_CONFIG = Object.freeze({
   tenantName: import.meta.env.VITE_APP_TITLE,
   username: 'test',
   password: 'test@2026',
-  redirectPath: '/ai-qa'
+  redirectPath: '/qs-demo1'
 })
 
 const router = useRouter()

+ 1 - 1
src/views/qsfl/LoginQsfl2.vue

@@ -24,7 +24,7 @@ const QSFL_LOGIN_CONFIG = Object.freeze({
   tenantName: import.meta.env.VITE_APP_TITLE,
   username: 'test',
   password: 'test@2026',
-  redirectPath: '/ai-qa-new'
+  redirectPath: '/qs-demo2'
 })
 
 const router = useRouter()

+ 62 - 0
src/views/qsfl/LoginQsfl3.vue

@@ -0,0 +1,62 @@
+<template>
+  <main class="qsfl-login-page">
+    <div class="login-glow glow-blue"></div>
+    <div class="login-glow glow-red"></div>
+    <section class="login-card" aria-live="polite">
+      <span class="login-orbit orbit-one"></span>
+      <span class="login-orbit orbit-two"></span>
+      <div class="login-mark">AI</div>
+      <h1>非临 AI 助教</h1>
+      <p>{{ statusText }}</p>
+      <div v-if="isLoading" class="loading-line"><i></i><i></i><i></i></div>
+      <button v-else type="button" @click="startLogin">重新进入</button>
+    </section>
+  </main>
+</template>
+
+<script setup>
+import { onMounted, ref } from 'vue'
+import { useRouter } from 'vue-router'
+import { autoLogin } from '@/utils/loginUtils.js'
+
+// 强生菲林 AI 问答专用的固定登录配置。
+const QSFL_LOGIN_CONFIG = Object.freeze({
+  tenantName: import.meta.env.VITE_APP_TITLE,
+  username: 'test',
+  password: 'test@2026',
+  redirectPath: '/qs-demo3'
+})
+
+const router = useRouter()
+const isLoading = ref(false)
+const statusText = ref('正在验证访问权限…')
+
+const startLogin = async () => {
+  if (isLoading.value) return
+  isLoading.value = true
+  statusText.value = '正在安全校验…'
+  const success = await autoLogin(
+      QSFL_LOGIN_CONFIG.tenantName,
+      QSFL_LOGIN_CONFIG.username,
+      QSFL_LOGIN_CONFIG.password,
+      router,
+      QSFL_LOGIN_CONFIG.redirectPath
+  )
+  if (!success) {
+    statusText.value = '校验失败,请稍后重试'
+    isLoading.value = false
+  }
+}
+
+onMounted(startLogin)
+</script>
+
+<style scoped>
+.qsfl-login-page { position: fixed; inset: 0; display: grid; place-items: center; overflow: hidden; color: #edf4ff; background: radial-gradient(circle at 50% 20%, #183c79, #071a41 52%, #020a1d 100%); }
+.login-glow { position: absolute; pointer-events: none; filter: blur(35px); }.glow-blue { width: 45vw; height: 45vw; top: -28vw; right: -8vw; border-radius: 50%; background: rgba(87, 150, 255, .29); }.glow-red { width: 42vw; height: 60vw; bottom: -40vw; left: -17vw; background: rgba(214, 35, 57, .38); transform: rotate(-35deg); }
+.login-card { position: relative; display: flex; width: min(360px, calc(100vw - 48px)); min-height: 260px; align-items: center; flex-direction: column; justify-content: center; overflow: hidden; border: 1px solid rgba(190, 215, 255, .25); border-radius: 22px; background: rgba(7, 25, 60, .65); box-shadow: 0 28px 80px rgba(0, 0, 0, .32), inset 0 1px rgba(255, 255, 255, .12); backdrop-filter: blur(18px); }
+.login-mark { position: relative; z-index: 1; display: grid; width: 58px; height: 58px; margin-bottom: 19px; place-items: center; border: 1px solid rgba(225, 236, 255, .5); border-radius: 17px; background: linear-gradient(135deg, #e33a4a, #3f82dc); box-shadow: 0 10px 28px rgba(58, 128, 225, .3); font-size: 21px; font-weight: 700; letter-spacing: .06em; }
+h1, p, button { position: relative; z-index: 1; }h1 { margin: 0; font-size: 24px; font-weight: 600; letter-spacing: .03em; }p { margin: 11px 0 0; color: rgba(222, 234, 255, .7); font-size: 14px; }button { margin-top: 20px; padding: 8px 15px; border: 1px solid rgba(201, 221, 255, .32); border-radius: 8px; color: #eff5ff; background: rgba(255,255,255,.08); cursor: pointer; }
+.loading-line { position: relative; z-index: 1; display: flex; gap: 6px; margin-top: 24px; }.loading-line i { width: 6px; height: 6px; border-radius: 50%; background: #a6c9ff; animation: loading-dot 1s ease-in-out infinite; }.loading-line i:nth-child(2) { animation-delay: .15s; }.loading-line i:nth-child(3) { animation-delay: .3s; }.login-orbit { position: absolute; width: 180px; height: 180px; border: 1px solid rgba(127, 173, 250, .15); border-radius: 50%; }.orbit-one { top: -112px; right: -80px; }.orbit-two { bottom: -120px; left: -95px; border-style: dashed; animation: orbit 15s linear infinite; }
+@keyframes loading-dot { 50% { transform: translateY(-5px); opacity: .4; } } @keyframes orbit { to { transform: rotate(360deg); } }
+</style>

+ 17 - 5
src/views/qsfl/components/AiQAChat.vue

@@ -431,10 +431,6 @@ const resetDigitalHumanVideo = () => {
   if (video.readyState >= HTMLMediaElement.HAVE_METADATA) video.currentTime = 0
 }
 
-const handleDigitalHumanVideoReady = () => {
-  if (!isAudioSpeaking.value) resetDigitalHumanVideo()
-}
-
 watch(isAudioSpeaking, async (speaking) => {
   await nextTick()
   const video = digitalHumanVideoRef.value
@@ -721,7 +717,17 @@ onUnmounted(() => {
           <div class="digital-human-copy"><span>AI ASSISTANT</span><h2>非临 AI 助教</h2><p>随时为您解答健康与服务问题</p></div>
           <div class="digital-human-stage">
             <span class="stage-orbit orbit-one"></span><span class="stage-orbit orbit-two"></span>
-            <img class="digital-human-image" src="@/assets/images/ai-qa/doctor-avatar-cropped.png" alt="非临 AI 助教" />
+            <video
+              ref="digitalHumanVideoRef"
+              class="digital-human-video"
+              src="@/assets/images/ai-qa/ai-qa.mp4"
+              poster="@/assets/images/ai-qa/ai-qa-first-frame.png"
+              muted
+              playsinline
+              preload="auto"
+              @loadedmetadata="resetDigitalHumanVideo"
+              @ended="resetDigitalHumanVideo"
+            ></video>
           </div>
           <div class="digital-human-status"><i></i>{{ isAudioSpeaking ? '正在为您播报' : '在线为您服务' }}</div>
         </aside>
@@ -871,6 +877,10 @@ onUnmounted(() => {
 .history-list::-webkit-scrollbar-thumb:hover, .reference-script-textarea::-webkit-scrollbar-thumb:hover { background-color: rgba(180, 216, 255, .92); }
 .mouth-sync { top: 34.2%; left: 49.5%; width: 12.5%; height: 1.15%; min-height: 5px; border-radius: 0 0 999px 999px; background: linear-gradient(180deg, #632b37, #963d4d); box-shadow: 0 1px 1px rgba(255, 209, 207, .22) inset; transform: translateX(-50%) scale(.72, .58); transform-origin: center top; }
 .digital-human-video { position: relative; z-index: 2; display: block; width: min(100%, 330px); max-height: 430px; object-fit: contain; filter: drop-shadow(0 22px 22px rgba(0, 0, 0, .28)); }
+.digital-human-panel { --digital-human-panel-padding: clamp(20px, 2vw, 30px); background: linear-gradient(180deg, #103261, #0a2148); backdrop-filter: none; }
+.digital-human-stage { margin-inline: calc(0px - var(--digital-human-panel-padding)); overflow: hidden; background: #0e2e59; }
+.digital-human-video { position: absolute; inset: 0; width: 100%; height: 100%; max-height: none; object-fit: cover; filter: none; }
+.digital-human-stage .stage-orbit { display: none; }
 .digital-human-panel.speaking .digital-human-image { animation: none; }
 .digital-human-panel.speaking .mouth-sync { opacity: .78; animation: medical-mouth-talk .3s ease-in-out infinite alternate; }
 .digital-human-panel.speaking .stage-orbit { border-color: rgba(139, 204, 255, .72); box-shadow: inset 0 0 44px rgba(84, 158, 255, .28), 0 0 38px rgba(71, 151, 255, .4); }
@@ -881,4 +891,6 @@ onUnmounted(() => {
 @media (max-width: 1100px) { .digital-human-panel { min-width: 205px; padding: 18px; }.digital-human-copy h2 { font-size: 20px; }.suggestion-list { grid-template-columns: 1fr; width: min(100%, 380px); }.suggestion-list button { min-height: 54px; }.header-toggle { display: none; } }
 @media (max-width: 900px) { .conversation-history { width: 200px; padding-right: 10px; padding-left: 10px; }.right-workspace { padding: 12px; gap: 12px; }.digital-human-panel { min-width: 185px; padding: 14px; }.digital-human-copy p { font-size: 12px; }.messages { padding-right: 20px; padding-left: 20px; } }
 @media (max-width: 760px) { .page-header { height: 62px; padding: 0 15px; }.brand { font-size: 16px; }.new-conversation span { display: none; }.new-conversation { width: 36px; justify-content: center; padding: 0; }.qa-workspace { height: calc(100dvh - 62px); min-height: 0; }.conversation-history { width: 72px; padding: 15px 8px; }.history-heading { justify-content: center; padding: 0 0 14px; }.history-heading > div, .history-new { display: none; }.history-list { padding: 0; }.history-item { display: block; padding: 10px 4px; text-align: center; }.history-item-title { display: block; font-size: 0; }.history-item-title::before { color: #b7d4ff; content: '问答'; font-size: 11px; }.history-item-time { display: none; }.right-workspace { padding: 9px; gap: 0; }.digital-human-panel { display: none; }.chat-panel { border-radius: 14px; }.welcome { padding: 20px; }.welcome h1 { font-size: 28px; }.messages { padding: 20px 15px; }.message-card { max-width: 86%; }.composer-wrap { padding: 9px 10px 12px; } }
+@media (max-width: 1100px) { .digital-human-panel { --digital-human-panel-padding: 18px; } }
+@media (max-width: 900px) { .digital-human-panel { --digital-human-panel-padding: 14px; } }
 </style>

+ 48 - 34
src/views/qsfl/components/AiQAChatNew.vue → src/views/qsfl/components/AiQAChat2.vue

@@ -24,19 +24,17 @@ const DEFAULT_AI_QA_CONFIG = Object.freeze({
   maxInputLength: Infinity,
   acceptedFileExtensions: ['pdf', 'doc', 'docx', 'xls', 'xlsx'],
   uploadHint: '',
-  inputHint: '发消息,或上传附件...',
+  inputHint: '请输入或粘贴学员拜访记录文字稿…',
   enableFileUpload: false,
   enableContext: true,
-  enableVoicePlayback: true,
+  enableVoicePlayback: false,//语音
   typewriterDelay: 28,
   fastTypewriterDelay: 8,
   maxInputHeight: 180,
   voiceLanguage: 'zh-CN',
   voiceMaxDuration: 30,
   suggestions: [
-    '开始陪练',
-    '你都知道什么',
-    '我需要知识点....'
+    '请根据标准评分规则对以下学员拜访记录进行评分'
   ]
 })
 
@@ -147,7 +145,7 @@ const loadConversationList = () => {
     conversations.value = Array.isArray(stored)
         ? stored.filter((item) => item?.id).map((item) => ({
           id: item.id,
-          title: item.title || '未命名对话',
+          title: item.title || '未命名评分记录',
           updatedAt: Number(item.updatedAt) || Date.now(),
           messages: Array.isArray(item.messages) ? item.messages : []
         })).sort((a, b) => b.updatedAt - a.updatedAt)
@@ -161,7 +159,7 @@ const loadConversationList = () => {
 const getConversationTitle = () => {
   const firstQuestion = messages.value.find((message) => message.type === 'user')
   if (!firstQuestion) return ''
-  return firstQuestion.content?.trim() || firstQuestion.attachments?.[0]?.name || '附件问答'
+  return firstQuestion.content?.trim() || firstQuestion.attachments?.[0]?.name || '拜访记录评分'
 }
 
 const persistConversation = () => {
@@ -473,14 +471,14 @@ const copyMessageContent = async (content, event) => {
     } else if (navigator.clipboard?.writeText) {
       await navigator.clipboard.writeText(content)
     }
-    Message().success('已复制 AI 回复', true)
+    Message().success('已复制评分结果', true)
   } catch (error) {
-    console.error('复制 AI 回复失败', error)
+    console.error('复制评分结果失败', error)
     Message().error('复制失败,请手动选择内容复制', true)
   }
 }
 
-const getRequestContent = (content, script) => script ? `评分参考话术:\n${script}\n\n用户问题:\n${content}` : content
+const getRequestContent = (content, script) => script ? `补充评分规则:\n${script}\n\n学员拜访记录:\n${content}` : content
 const selectFile = () => fileInputRef.value?.click()
 const formatSize = (size) => size < 1024 * 1024 ? `${Math.max(1, Math.round(size / 1024))} KB` : `${(size / 1024 / 1024).toFixed(1)} MB`
 const getFileExtension = (fileName) => fileName.split('.').pop()?.toLowerCase() || ''
@@ -504,7 +502,7 @@ const removeAttachment = (index) => attachments.value.splice(index, 1)
 
 const sendMessage = async () => {
   if (!canSend.value) return
-  if (hasInputLengthLimit.value && input.value.length > AI_QA_CONFIG.maxInputLength) return Message().warning(`单次提问最多输入 ${AI_QA_CONFIG.maxInputLength.toLocaleString()} 个字符`, true)
+  if (hasInputLengthLimit.value && input.value.length > AI_QA_CONFIG.maxInputLength) return Message().warning(`单次最多输入 ${AI_QA_CONFIG.maxInputLength.toLocaleString()} 个字符`, true)
   stopPlayback()
   stopTypewriter()
   const content = input.value.trim()
@@ -515,7 +513,7 @@ const sendMessage = async () => {
   input.value = ''
   attachments.value = []
   userHasScrolled.value = false
-  messages.value.push({ type: 'user', content: content || '请查看我上传的附件。', attachments: files, referenceScript: script })
+  messages.value.push({ type: 'user', content: content || '请根据标准评分规则对我上传的学员拜访记录进行评分。', attachments: files, referenceScript: script })
   messages.value.push({
     id: null,
     type: 'assistant',
@@ -592,7 +590,7 @@ const sendMessage = async () => {
       assistantMessage.pending = false
       throw error
     }, () => {
-      assistantMessage.content ||= '暂未收到有效回复,请换一种方式提问。'
+      assistantMessage.content ||= '暂未收到有效评分结果,请稍后重试。'
       assistantMessage.pending = false
       assistantMessage.audioStreamEnded = true
       if (!assistantMessage.hasAudio || !getIsPlaying()) {
@@ -683,11 +681,11 @@ onUnmounted(() => {
         <span>非临 AI 助教</span>
       </div>
       <div class="header-actions">
-        <button class="header-toggle" :class="{ active: voicePlaybackEnabled }" type="button" @click="toggleVoicePlayback">
-          <span class="speaker-dot"></span>{{ voicePlaybackEnabled ? '语音播报已开启' : '语音播报已关闭' }}
-        </button>
+<!--        <button class="header-toggle" :class="{ active: voicePlaybackEnabled }" type="button" @click="toggleVoicePlayback">-->
+<!--          <span class="speaker-dot"></span>{{ voicePlaybackEnabled ? '语音播报已开启' : '语音播报已关闭' }}-->
+<!--        </button>-->
         <button class="new-conversation" type="button" @click="startNewConversation">
-          <el-icon><RefreshRight /></el-icon><span>新建对话</span>
+          <el-icon><RefreshRight /></el-icon><span>新建评分</span>
         </button>
       </div>
     </header>
@@ -695,39 +693,49 @@ onUnmounted(() => {
     <section class="qa-workspace" :class="{ 'history-collapsed': !historyDrawerOpen }">
       <aside class="conversation-history">
         <div class="history-heading">
-          <div><span class="history-overline">HISTORY</span><h2>历史问答</h2></div>
+          <div><span class="history-overline">HISTORY</span><h2>历史评分</h2></div>
           <button class="history-drawer-toggle" type="button" :title="historyDrawerOpen ? '收起历史记录' : '展开历史记录'" @click="historyDrawerOpen = !historyDrawerOpen"><el-icon><ArrowLeft v-if="historyDrawerOpen" /><ArrowRight v-else /></el-icon></button>
         </div>
-        <button class="history-new" type="button" @click="startNewConversation"><el-icon><Plus /></el-icon>发起新的问答</button>
+        <button class="history-new" type="button" @click="startNewConversation"><el-icon><Plus /></el-icon>发起新的评分</button>
         <div v-if="conversations.length" class="history-list">
           <button v-for="conversation in conversations" :key="conversation.id" class="history-item" :class="{ active: conversation.id === conversationId }" type="button" @click="selectConversation(conversation)">
             <span class="history-item-title">{{ conversation.title }}</span>
             <span class="history-item-time">{{ formatHistoryTime(conversation.updatedAt) }}</span>
           </button>
         </div>
-        <div v-else class="history-empty"><span>暂无历史记录</span><small>你的问答将在此处保存</small></div>
+        <div v-else class="history-empty"><span>暂无历史记录</span><small>你的评分将在此处保存</small></div>
       </aside>
 
       <section class="right-workspace">
         <aside class="digital-human-panel" :class="{ speaking: isAudioSpeaking }">
-          <div class="digital-human-copy"><span>HOSPITAL AI</span><h2>非临 AI 助教</h2><p>随时为您解答健康与服务问题</p></div>
+          <div class="digital-human-copy"><span>AI ASSISTANT</span><h2>非临 AI 助教</h2><p>我将依据标准评分规则,对学员拜访记录文字稿进行客观评分。</p></div>
           <div class="digital-human-stage">
             <span class="stage-orbit orbit-one"></span><span class="stage-orbit orbit-two"></span>
-            <img class="digital-human-image" src="@/assets/images/ai-qa/doctor-avatar-cropped.png" alt="非临 AI 助教" />
+            <video
+              ref="digitalHumanVideoRef"
+              class="digital-human-video"
+              src="@/assets/images/ai-qa/ai-qa.mp4"
+              poster="@/assets/images/ai-qa/ai-qa-first-frame.png"
+              muted
+              playsinline
+              preload="auto"
+              @loadedmetadata="handleDigitalHumanVideoReady"
+              @ended="resetDigitalHumanVideo"
+            ></video>
           </div>
           <div class="digital-human-status"><i></i>{{ isAudioSpeaking ? '正在为您播报' : '在线为您服务' }}</div>
         </aside>
 
         <section class="chat-panel">
           <div v-if="!hasConversation" class="welcome empty-conversation">
-            <p class="eyebrow">AI MEDICAL ASSISTANT</p><h1>开始一次新的问答</h1>
-            <p class="welcome-copy">向非临 AI 助教描述您的问题,获得及时的智能解答。</p>
-            <button class="initial-conversation-button" type="button" :disabled="isCreatingConversation" @click="makeConversation"><el-icon><Plus /></el-icon>智能问答</button>
+            <p class="eyebrow">AI ASSISTANT</p><h1>开始一次新的评分</h1>
+            <p class="welcome-copy">提交或上传学员拜访记录文字稿,AI 将依据标准评分规则生成评分结果。</p>
+            <button class="initial-conversation-button" type="button" :disabled="isCreatingConversation" @click="makeConversation"><el-icon><Plus /></el-icon>开始评分</button>
           </div>
 
           <div v-else-if="!hasMessages" class="welcome">
-            <p class="eyebrow">AI MEDICAL ASSISTANT</p><h1>有什么可以帮您?</h1>
-            <p class="welcome-copy">请输入问题,您的本次对话将自动保存到左侧历史记录。</p>
+            <p class="eyebrow">AI ASSISTANT</p><h1>提交拜访记录进行评分</h1>
+            <p class="welcome-copy">请输入或上传学员拜访记录文字稿;本次评分将自动保存到左侧历史记录。</p>
             <div class="suggestion-list"><button v-for="suggestion in AI_QA_CONFIG.suggestions" :key="suggestion" type="button" @click="chooseSuggestion(suggestion)"><span>{{ suggestion }}</span><el-icon><ArrowUp /></el-icon></button></div>
           </div>
 
@@ -743,7 +751,7 @@ onUnmounted(() => {
                     :citation-ids="message.segments?.map((item) => item.id) || []"
                     @citation-click="openCitationById(message, $event)"
                   />
-                  <div v-if="message.segments?.length" class="citation-list">
+<!--                  <div v-if="message.segments?.length" class="citation-list">
                     <button class="citation-summary" type="button" @click="toggleCitationList(message)">
                       <span class="citation-summary-name">《{{ message.segments[0]?.documentName || '知识库文档' }}》</span>
                       <span class="citation-summary-toggle">{{ getCitationSummaryAction(message) }}</span>
@@ -761,13 +769,13 @@ onUnmounted(() => {
                         </div>
                       </div>
                     </div>
-                  </div>
+                  </div>-->
                 </template>
                 <p v-else class="user-content">{{ message.content }}</p>
                 <div v-if="message.attachments?.length" class="attachment-list"><div v-for="file in message.attachments" :key="file.id" class="attachment-card"><el-icon><Paperclip /></el-icon><span class="attachment-name">{{ file.name }}</span><small>{{ formatSize(file.size) }}</small></div></div>
-                <div v-if="message.referenceScript" class="reference-script-summary"><span>评分参考话术</span><p>{{ message.referenceScript }}</p></div>
+                <div v-if="message.referenceScript" class="reference-script-summary"><span>补充评分规则</span><p>{{ message.referenceScript }}</p></div>
                 <button v-if="voicePlaybackEnabled && message.type === 'assistant' && message.showVoiceStop" class="stop-voice-button" type="button" @click="stopVoicePlayback(message)"><el-icon><VideoPause /></el-icon>停止播报</button>
-                <div v-if="message.type === 'assistant' && !message.pending && getAnswerBody(message)" class="assistant-message-actions"><button class="copy-message-button" type="button" title="复制回复" @click="copyMessageContent(getAnswerBody(message), $event)"><el-icon><DocumentCopy /></el-icon></button></div>
+                <div v-if="message.type === 'assistant' && !message.pending && getAnswerBody(message)" class="assistant-message-actions"><button class="copy-message-button" type="button" title="复制评分结果" @click="copyMessageContent(getAnswerBody(message), $event)"><el-icon><DocumentCopy /></el-icon></button></div>
               </div>
             </article>
           </div>
@@ -776,9 +784,9 @@ onUnmounted(() => {
             <p v-if="uploadEnabled && AI_QA_CONFIG.uploadHint" class="composer-limit-hint">{{ AI_QA_CONFIG.uploadHint }}</p>
             <div v-if="uploadEnabled && attachments.length" class="pending-files"><div v-for="(file, index) in attachments" :key="file.id" class="pending-file"><el-icon><Paperclip /></el-icon><span>{{ file.name }}</span><button type="button" aria-label="移除附件" @click="removeAttachment(index)">×</button></div></div>
             <div class="composer" :class="{ recording: isVoiceRecording, 'reference-script-open': referenceScriptEnabled && referenceScriptVisible }">
-              <label v-if="referenceScriptEnabled && referenceScriptVisible" class="reference-script-field"><span class="reference-script-label">评分参考话术 <em>选填</em></span><textarea v-model="referenceScript" class="reference-script-textarea" rows="4" :maxlength="AI_QA_CONFIG.referenceScriptMaxLength" :disabled="isSending" placeholder="可填写评分规则中指定项的参考话术,AI 将据此辅助评分"></textarea><small>{{ referenceScript.length }}/{{ AI_QA_CONFIG.referenceScriptMaxLength }}</small></label>
+              <label v-if="referenceScriptEnabled && referenceScriptVisible" class="reference-script-field"><span class="reference-script-label">补充评分规则 <em>选填</em></span><textarea v-model="referenceScript" class="reference-script-textarea" rows="4" :maxlength="AI_QA_CONFIG.referenceScriptMaxLength" :disabled="isSending" placeholder="可补充本次评分所适用的标准、维度或要求,AI 将据此评分"></textarea><small>{{ referenceScript.length }}/{{ AI_QA_CONFIG.referenceScriptMaxLength }}</small></label>
               <textarea ref="textareaRef" v-model="input" class="ai-qa-textarea" :maxlength="hasInputLengthLimit ? AI_QA_CONFIG.maxInputLength : undefined" :placeholder="AI_QA_CONFIG.inputHint" :disabled="isSending" @keydown="handleKeydown"></textarea>
-              <div class="composer-toolbar"><div class="composer-tools"><input ref="fileInputRef" class="file-input" type="file" accept="image/*,.pdf,.doc,.docx,.xls,.xlsx" @change="handleFileChange" /><button v-if="uploadEnabled" class="add-button" type="button" title="上传附件" :disabled="isSending" @click="selectFile"><el-icon><Plus /></el-icon></button><VoiceInputDoubao input-selector=".ai-qa-textarea" :lang="AI_QA_CONFIG.voiceLanguage" :max-duration="AI_QA_CONFIG.voiceMaxDuration" :disabled="isSending" @voice-recognized="onVoiceRecognized" @recording-status-changed="isVoiceRecording = $event" /><button v-if="referenceScriptEnabled" class="reference-script-toggle" :class="{ active: referenceScriptVisible }" type="button" :disabled="isSending" @click="referenceScriptVisible = !referenceScriptVisible">评分话术</button></div><button v-if="isSending || isTyping" class="send-button stop-button" type="button" @click="stopResponse"><el-icon><VideoPause /></el-icon></button><button v-else class="send-button" type="button" :disabled="!canSend" @click="sendMessage"><el-icon><ArrowUp /></el-icon></button></div>
+              <div class="composer-toolbar"><div class="composer-tools"><input ref="fileInputRef" class="file-input" type="file" accept="image/*,.pdf,.doc,.docx,.xls,.xlsx" @change="handleFileChange" /><button v-if="uploadEnabled" class="add-button" type="button" title="上传拜访记录附件" :disabled="isSending" @click="selectFile"><el-icon><Plus /></el-icon></button><VoiceInputDoubao input-selector=".ai-qa-textarea" :lang="AI_QA_CONFIG.voiceLanguage" :max-duration="AI_QA_CONFIG.voiceMaxDuration" :disabled="isSending" @voice-recognized="onVoiceRecognized" @recording-status-changed="isVoiceRecording = $event" /><button v-if="referenceScriptEnabled" class="reference-script-toggle" :class="{ active: referenceScriptVisible }" type="button" :disabled="isSending" @click="referenceScriptVisible = !referenceScriptVisible">评分规则</button></div><button v-if="isSending || isTyping" class="send-button stop-button" type="button" @click="stopResponse"><el-icon><VideoPause /></el-icon></button><button v-else class="send-button" type="button" :disabled="!canSend" @click="sendMessage"><el-icon><ArrowUp /></el-icon></button></div>
             </div>
           </section>
         </section>
@@ -860,6 +868,10 @@ onUnmounted(() => {
 .history-list::-webkit-scrollbar-thumb:hover, .reference-script-textarea::-webkit-scrollbar-thumb:hover { background-color: rgba(180, 216, 255, .92); }
 .mouth-sync { top: 34.2%; left: 49.5%; width: 12.5%; height: 1.15%; min-height: 5px; border-radius: 0 0 999px 999px; background: linear-gradient(180deg, #632b37, #963d4d); box-shadow: 0 1px 1px rgba(255, 209, 207, .22) inset; transform: translateX(-50%) scale(.72, .58); transform-origin: center top; }
 .digital-human-video { position: relative; z-index: 2; display: block; width: min(100%, 330px); max-height: 430px; object-fit: contain; filter: drop-shadow(0 22px 22px rgba(0, 0, 0, .28)); }
+.digital-human-panel { --digital-human-panel-padding: clamp(20px, 2vw, 30px); background: linear-gradient(180deg, #103261, #0a2148); backdrop-filter: none; }
+.digital-human-stage { margin-inline: calc(0px - var(--digital-human-panel-padding)); overflow: hidden; background: #0e2e59; }
+.digital-human-video { position: absolute; inset: 0; width: 100%; height: 100%; max-height: none; object-fit: cover; filter: none; }
+.digital-human-stage .stage-orbit { display: none; }
 .digital-human-panel.speaking .digital-human-image { animation: none; }
 .digital-human-panel.speaking .mouth-sync { opacity: .78; animation: medical-mouth-talk .3s ease-in-out infinite alternate; }
 .digital-human-panel.speaking .stage-orbit { border-color: rgba(139, 204, 255, .72); box-shadow: inset 0 0 44px rgba(84, 158, 255, .28), 0 0 38px rgba(71, 151, 255, .4); }
@@ -869,5 +881,7 @@ onUnmounted(() => {
 @keyframes orbit { to { transform: rotate(360deg); } } @keyframes person-talk { to { transform: translateY(-3px) rotate(-.5deg); } } @keyframes mouth-talk { to { height: 4.2%; width: 9%; } } @keyframes medical-mouth-talk { from { transform: translateX(-50%) scale(.72, .58); } to { transform: translateX(-50%) scale(1, 1.7); } } @keyframes halo-pulse { to { transform: scale(1.1); opacity: .72; } } @keyframes halo-glow { to { opacity: .98; filter: brightness(1.35); } } @keyframes pulse { 50% { transform: translateY(-3px); opacity: .45; } }
 @media (max-width: 1100px) { .digital-human-panel { min-width: 205px; padding: 18px; }.digital-human-copy h2 { font-size: 20px; }.suggestion-list { grid-template-columns: 1fr; width: min(100%, 380px); }.suggestion-list button { min-height: 54px; }.header-toggle { display: none; } }
 @media (max-width: 900px) { .conversation-history { width: 200px; padding-right: 10px; padding-left: 10px; }.right-workspace { padding: 12px; gap: 12px; }.digital-human-panel { min-width: 185px; padding: 14px; }.digital-human-copy p { font-size: 12px; }.messages { padding-right: 20px; padding-left: 20px; } }
-@media (max-width: 760px) { .page-header { height: 62px; padding: 0 15px; }.brand { font-size: 16px; }.new-conversation span { display: none; }.new-conversation { width: 36px; justify-content: center; padding: 0; }.qa-workspace { height: calc(100dvh - 62px); min-height: 0; }.conversation-history { width: 72px; padding: 15px 8px; }.history-heading { justify-content: center; padding: 0 0 14px; }.history-heading > div, .history-new { display: none; }.history-list { padding: 0; }.history-item { display: block; padding: 10px 4px; text-align: center; }.history-item-title { display: block; font-size: 0; }.history-item-title::before { color: #b7d4ff; content: '问答'; font-size: 11px; }.history-item-time { display: none; }.right-workspace { padding: 9px; gap: 0; }.digital-human-panel { display: none; }.chat-panel { border-radius: 14px; }.welcome { padding: 20px; }.welcome h1 { font-size: 28px; }.messages { padding: 20px 15px; }.message-card { max-width: 86%; }.composer-wrap { padding: 9px 10px 12px; } }
+@media (max-width: 760px) { .page-header { height: 62px; padding: 0 15px; }.brand { font-size: 16px; }.new-conversation span { display: none; }.new-conversation { width: 36px; justify-content: center; padding: 0; }.qa-workspace { height: calc(100dvh - 62px); min-height: 0; }.conversation-history { width: 72px; padding: 15px 8px; }.history-heading { justify-content: center; padding: 0 0 14px; }.history-heading > div, .history-new { display: none; }.history-list { padding: 0; }.history-item { display: block; padding: 10px 4px; text-align: center; }.history-item-title { display: block; font-size: 0; }.history-item-title::before { color: #b7d4ff; content: '评分'; font-size: 11px; }.history-item-time { display: none; }.right-workspace { padding: 9px; gap: 0; }.digital-human-panel { display: none; }.chat-panel { border-radius: 14px; }.welcome { padding: 20px; }.welcome h1 { font-size: 28px; }.messages { padding: 20px 15px; }.message-card { max-width: 86%; }.composer-wrap { padding: 9px 10px 12px; } }
+@media (max-width: 1100px) { .digital-human-panel { --digital-human-panel-padding: 18px; } }
+@media (max-width: 900px) { .digital-human-panel { --digital-human-panel-padding: 14px; } }
 </style>

+ 0 - 0
src/views/qsfl/aiQA.vue → src/views/qsfl/qsflDemo1.vue


+ 4 - 4
src/views/qsfl/aiQANew.vue → src/views/qsfl/qsflDemo2.vue

@@ -1,5 +1,5 @@
 <template>
-  <AiQAChatNew
+  <AiQAChat2
     :config="qaConfig"
     :create-dialogue-api="createQsAiDialogue"
     :send-chat-stream-api="sendQsAiChatMessageStream"
@@ -7,7 +7,7 @@
 </template>
 
 <script setup>
-import AiQAChatNew from './components/AiQAChatNew.vue'
+import AiQAChat2 from './components/AiQAChat2.vue'
 import { createQsAiDialogue, sendQsAiChatMessageStream } from '@/api/qsAiQuestions.js'
 
 const qaConfig = Object.freeze({
@@ -22,7 +22,7 @@ const qaConfig = Object.freeze({
   referenceScriptMaxLength: 2000,
   maxInputLength: 1000,
   uploadHint: '支持图片、PDF、Word、Excel 文件;单次最多上传 3 个附件,单个文件不超过 10 MB,附件总大小不超过 15 MB。文档内容过长时,系统将仅分析前 20,000 个字符。',
-  inputHint: '单次提问最多输入 1,000 个字符。',
-  suggestions: ['请为学员评分']
+  inputHint: '请输入或粘贴学员拜访记录文字稿,最多 1,000 个字符。',
+  suggestions: ['请根据标准评分规则对以下学员拜访记录进行评分']
 })
 </script>

+ 28 - 0
src/views/qsfl/qsflDemo3.vue

@@ -0,0 +1,28 @@
+<template>
+  <AiQAChat2
+    :config="qaConfig"
+    :create-dialogue-api="createQsAiDialogue"
+    :send-chat-stream-api="sendQsAiChatMessageStream"
+  />
+</template>
+
+<script setup>
+import AiQAChat2 from './components/AiQAChat2.vue'
+import { createQsAiDialogue, sendQsAiChatMessageStream } from '@/api/qsAiQuestions.js'
+
+const qaConfig = Object.freeze({
+  roleId: 265,
+  enableFileUpload: true,
+  maxFiles: 3,
+  maxFileSizeMB: 10,
+  maxTotalFileSizeMB: 15,
+  invalidFileNamePattern: /[\\/:*?"<>|\s]/,
+  invalidFileNameMessage: '文件名不能包含空格或\\ / : * ? " < > | 等非法字符,请修改后重试',
+  enableReferenceScript: true,
+  referenceScriptMaxLength: 2000,
+  maxInputLength: 1000,
+  uploadHint: '支持图片、PDF、Word、Excel 文件;单次最多上传 3 个附件,单个文件不超过 10 MB,附件总大小不超过 15 MB。文档内容过长时,系统将仅分析前 20,000 个字符。',
+  inputHint: '请输入或粘贴学员拜访记录文字稿,最多 1,000 个字符。',
+  suggestions: ['请根据标准评分规则对以下学员拜访记录进行评分']
+})
+</script>