This commit is contained in:
libingxiang 2026-06-16 10:16:38 +08:00
parent 006aa04a7f
commit bb7fea71f1
3 changed files with 13 additions and 689 deletions

View File

@ -69,12 +69,7 @@
<!-- 字幕区域底部不遮挡数字人主体 --> <!-- 字幕区域底部不遮挡数字人主体 -->
<div class="subtitle-overlay"> <div class="subtitle-overlay">
<div class="subtitle-container"> <div class="subtitle-container">
<SubtitlePanel <SubtitlePanel :ai-subtitle="currentAISubtitle" />
v-model:mode="subtitleMode"
:ai-subtitle="currentAISubtitle"
:user-subtitle="currentUserSubtitle"
:messages="chatMessages"
/>
</div> </div>
</div> </div>

View File

@ -1,57 +1,6 @@
<template> <template>
<div class="subtitle-panel"> <div class="subtitle-panel">
<!-- 字幕模式下拉框 -->
<div class="subtitle-header">
<div class="dropdown-wrapper" :class="{ open: dropdownOpen }">
<button class="dropdown-trigger" @click="dropdownOpen = !dropdownOpen">
<span class="dropdown-label">{{ modeLabel }}</span>
<svg
class="dropdown-arrow"
viewBox="0 0 24 24"
width="14"
height="14"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
<Transition name="dropdown">
<div v-if="dropdownOpen" class="dropdown-menu">
<button
v-for="opt in modeOptions"
:key="opt.value"
class="dropdown-item"
:class="{ active: currentMode === opt.value }"
@click="selectMode(opt.value)"
>
<span class="item-icon">{{ opt.icon }}</span>
<span class="item-label">{{ opt.label }}</span>
<svg
v-if="currentMode === opt.value"
viewBox="0 0 24 24"
width="14"
height="14"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
</button>
</div>
</Transition>
</div>
</div>
<!-- 模式1仅AI字幕 -->
<div <div
v-if="currentMode === 'ai-only'"
class="subtitle-content ai-only-mode" class="subtitle-content ai-only-mode"
ref="scrollContainerRef" ref="scrollContainerRef"
> >
@ -99,224 +48,23 @@
等待AI回复... 等待AI回复...
</div> </div>
</div> </div>
<!-- 模式2对话气泡 -->
<div
v-if="currentMode === 'bubble'"
class="subtitle-content bubble-mode"
ref="scrollContainerRef"
>
<div v-if="messages.length === 0" class="subtitle-placeholder">
<svg
viewBox="0 0 24 24"
width="20"
height="20"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
>
<path
d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"
/>
</svg>
等待对话开始...
</div>
<div class="chat-list" ref="chatListRef">
<div
v-for="msg in messages"
:key="msg.id"
:class="[
'chat-bubble',
msg.role === 'user' ? 'bubble-user' : 'bubble-ai',
]"
>
<div class="bubble-body">
<div
class="bubble-text"
:class="{
collapsed: isCollapsed(msg.id) && isLongText(msg.content),
'text-user': msg.role === 'user',
'text-ai': msg.role === 'ai',
}"
:ref="(el) => setMsgRef(msg.id, el)"
>
{{ msg.content }}
</div>
<button
v-if="isLongText(msg.content)"
class="expand-btn"
@click="toggleCollapse(msg.id)"
>
{{ isCollapsed(msg.id) ? "展开全部" : "收起" }}
<svg
viewBox="0 0 24 24"
width="12"
height="12"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
:style="{
transform: isCollapsed(msg.id)
? 'rotate(0deg)'
: 'rotate(180deg)',
transition: 'transform 0.2s',
}"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
</div>
</div>
</div>
</div>
<!-- 模式3合并气泡 -->
<div
v-if="currentMode === 'merged'"
class="subtitle-content merged-mode"
ref="scrollContainerRef"
>
<div v-if="messages.length === 0" class="subtitle-placeholder">
<svg
viewBox="0 0 24 24"
width="20"
height="20"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
>
<path
d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"
/>
</svg>
等待对话开始...
</div>
<div class="merged-list" ref="mergedListRef">
<div
v-for="(pair, idx) in pairedMessages"
:key="idx"
class="merged-bubble"
>
<div v-if="pair.user" class="merged-user-section">
<div
class="merged-text text-user"
:class="{
collapsed:
isCollapsed(pair.user.id) && isLongText(pair.user.content),
}"
>
{{ pair.user.content }}
</div>
<button
v-if="isLongText(pair.user.content)"
class="expand-btn"
@click="toggleCollapse(pair.user.id)"
>
{{ isCollapsed(pair.user.id) ? "展开全部" : "收起" }}
<svg
viewBox="0 0 24 24"
width="12"
height="12"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
:style="{
transform: isCollapsed(pair.user.id)
? 'rotate(0deg)'
: 'rotate(180deg)',
transition: 'transform 0.2s',
}"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
</div>
<div v-if="pair.ai" class="merged-ai-section">
<div
class="merged-text text-ai"
:class="{
collapsed:
isCollapsed(pair.ai.id) && isLongText(pair.ai.content),
}"
>
{{ pair.ai.content }}
</div>
<button
v-if="isLongText(pair.ai.content)"
class="expand-btn"
@click="toggleCollapse(pair.ai.id)"
>
{{ isCollapsed(pair.ai.id) ? "展开全部" : "收起" }}
<svg
viewBox="0 0 24 24"
width="12"
height="12"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
:style="{
transform: isCollapsed(pair.ai.id)
? 'rotate(0deg)'
: 'rotate(180deg)',
transition: 'transform 0.2s',
}"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
</div>
</div>
</div>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { import { ref, watch, nextTick } from "vue";
ref,
computed,
watch,
nextTick,
onMounted,
onBeforeUnmount,
} from "vue";
import type { ChatMessage, SubtitleMode } from "@/types";
const props = defineProps<{ const props = defineProps<{
mode: SubtitleMode;
aiSubtitle: string; aiSubtitle: string;
userSubtitle: string;
messages: ChatMessage[];
}>(); }>();
const emit = defineEmits<{
"update:mode": [value: SubtitleMode];
}>();
const currentMode = computed({
get: () => props.mode,
set: (val: SubtitleMode) => emit("update:mode", val),
});
const dropdownOpen = ref(false);
// ref .subtitle-content
const scrollContainerRef = ref<HTMLElement | null>(null); const scrollContainerRef = ref<HTMLElement | null>(null);
const aiSubtitleBarRef = ref<HTMLElement | null>(null); const aiSubtitleBarRef = ref<HTMLElement | null>(null);
const aiSubtitleTextRef = ref<HTMLElement | null>(null); const aiSubtitleTextRef = ref<HTMLElement | null>(null);
// ===== AI =====
const aiFontSize = ref(18); const aiFontSize = ref(18);
const aiTextOverflow = ref(false); const aiTextOverflow = ref(false);
// AI
const computeAIFontSize = () => { const computeAIFontSize = () => {
const text = props.aiSubtitle; const text = props.aiSubtitle;
if (!text) { if (!text) {
@ -332,7 +80,6 @@ const computeAIFontSize = () => {
else aiFontSize.value = 12; else aiFontSize.value = 12;
}; };
// AI
const checkAIOverflow = () => { const checkAIOverflow = () => {
nextTick(() => { nextTick(() => {
const bar = aiSubtitleBarRef.value; const bar = aiSubtitleBarRef.value;
@ -352,118 +99,20 @@ const scrollAIToBottom = () => {
} }
}; };
// ===== / ===== const scrollToBottom = () => {
const COLLAPSE_THRESHOLD = 120; // 120 const el = scrollContainerRef.value;
const collapsedIds = ref<Set<string>>(new Set()); if (!el) return;
const msgRefs = new Map<string, HTMLElement>(); el.scrollTo({
top: el.scrollHeight,
const isLongText = (text: string): boolean => text.length > COLLAPSE_THRESHOLD; behavior: "smooth",
const isCollapsed = (id: string): boolean => collapsedIds.value.has(id);
const toggleCollapse = (id: string) => {
if (collapsedIds.value.has(id)) {
collapsedIds.value.delete(id);
} else {
collapsedIds.value.add(id);
}
//
collapsedIds.value = new Set(collapsedIds.value);
};
const setMsgRef = (id: string, el: any) => {
if (el) msgRefs.set(id, el as HTMLElement);
};
// ===== =====
const modeOptions = [
{ value: "ai-only" as SubtitleMode, label: "AI字幕", icon: "💬" },
{ value: "bubble" as SubtitleMode, label: "对话气泡", icon: "💭" },
{ value: "merged" as SubtitleMode, label: "合并气泡", icon: "📋" },
];
const modeLabel = computed(() => {
return (
modeOptions.find((o) => o.value === currentMode.value)?.label || "AI字幕"
);
});
const selectMode = (val: SubtitleMode) => {
currentMode.value = val;
dropdownOpen.value = false;
};
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.closest(".dropdown-wrapper")) {
dropdownOpen.value = false;
}
};
onMounted(() => {
document.addEventListener("click", handleClickOutside);
});
onBeforeUnmount(() => {
document.removeEventListener("click", handleClickOutside);
});
// ===== =====
const pairedMessages = computed(() => {
const pairs: { user: ChatMessage | null; ai: ChatMessage | null }[] = [];
let currentPair: { user: ChatMessage | null; ai: ChatMessage | null } = {
user: null,
ai: null,
};
for (const msg of props.messages) {
if (msg.role === "user") {
if (currentPair.user) {
pairs.push(currentPair);
currentPair = { user: msg, ai: null };
} else {
currentPair.user = msg;
}
} else {
currentPair.ai = msg;
pairs.push(currentPair);
currentPair = { user: null, ai: null };
}
}
if (currentPair.user || currentPair.ai) {
pairs.push(currentPair);
}
return pairs;
});
// ===== =====
//
watch(
() => props.mode,
() => {
computeAIFontSize();
nextTick(() => {
checkAIOverflow();
scrollToBottom();
// AI
if (props.mode === "ai-only" && aiSubtitleBarRef.value) {
aiSubtitleBarRef.value.scrollTo({
top: aiSubtitleBarRef.value.scrollHeight,
behavior: "instant" as ScrollBehavior,
}); });
} };
});
},
);
// AI + +
watch( watch(
() => props.aiSubtitle, () => props.aiSubtitle,
() => { () => {
computeAIFontSize(); computeAIFontSize();
checkAIOverflow(); checkAIOverflow();
// AI
nextTick(() => { nextTick(() => {
if (aiSubtitleBarRef.value) { if (aiSubtitleBarRef.value) {
aiSubtitleBarRef.value.scrollTo({ aiSubtitleBarRef.value.scrollTo({
@ -474,55 +123,6 @@ watch(
}); });
}, },
); );
// +
watch(
() => props.messages.length,
(newLen, oldLen) => {
//
if (newLen > (oldLen ?? 0)) {
for (let i = oldLen ?? 0; i < newLen; i++) {
const msg = props.messages[i];
if (msg && isLongText(msg.content)) {
collapsedIds.value.add(msg.id);
}
}
collapsedIds.value = new Set(collapsedIds.value);
}
nextTick(() => {
scrollToBottom();
});
},
);
//
watch(
() => {
const msgs = props.messages;
if (msgs.length === 0) return "";
//
return msgs[msgs.length - 1].content;
},
() => {
nextTick(() => {
scrollToBottom();
});
},
);
/**
* 将对话列表滚动到底部
* 兼容不同内容量内容少时无需滚动内容超出可见区域时平滑滚动到底部
*/
const scrollToBottom = () => {
const el = scrollContainerRef.value;
if (!el) return;
el.scrollTo({
top: el.scrollHeight,
behavior: "smooth",
});
};
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@ -532,109 +132,6 @@ const scrollToBottom = () => {
height: 100%; height: 100%;
} }
/* ===== 下拉框 ===== */
.subtitle-header {
display: flex;
align-items: center;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
margin-bottom: 8px;
flex-shrink: 0;
}
.dropdown-wrapper {
position: relative;
}
.dropdown-trigger {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
color: rgba(255, 255, 255, 0.75);
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all 0.2s;
&:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.18);
}
.dropdown-wrapper.open & {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(108, 140, 255, 0.4);
}
}
.dropdown-arrow {
transition: transform 0.2s ease;
.dropdown-wrapper.open & {
transform: rotate(180deg);
}
}
.dropdown-menu {
position: absolute;
bottom: calc(100% + 8px);
left: 0;
min-width: 180px;
background: rgba(20, 25, 50, 0.97);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
padding: 6px;
z-index: 100;
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.5);
}
.dropdown-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 14px;
border-radius: 8px;
border: none;
background: none;
color: rgba(255, 255, 255, 0.65);
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all 0.15s;
&:hover {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
&.active {
background: rgba(108, 140, 255, 0.15);
color: #6c8cff;
}
.item-icon {
font-size: 14px;
}
.item-label {
flex: 1;
text-align: left;
}
}
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.2s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(6px) scale(0.96);
}
/* ===== 字幕内容 ===== */
.subtitle-content { .subtitle-content {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
@ -672,7 +169,6 @@ const scrollToBottom = () => {
font-size: 14px; font-size: 14px;
} }
/* ===== 模式1AI字幕 ===== */
.ai-only-mode { .ai-only-mode {
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
@ -737,171 +233,4 @@ const scrollToBottom = () => {
transform: translateX(-50%) scale(1.1); transform: translateX(-50%) scale(1.1);
} }
} }
/* ===== 模式2对话气泡 ===== */
.bubble-mode {
.chat-list {
display: flex;
flex-direction: column;
gap: 8px;
padding: 2px 0;
}
}
.chat-bubble {
animation: bubbleSlide 0.35s ease;
&.bubble-user {
.bubble-body {
background: rgba(240, 242, 255, 0.92);
border-color: rgba(79, 110, 247, 0.12);
border-radius: 14px 14px 4px 14px;
margin-left: 24px;
}
}
&.bubble-ai {
.bubble-body {
background: rgba(20, 30, 50, 0.85);
border-color: rgba(52, 211, 153, 0.12);
border-radius: 14px 14px 14px 4px;
margin-right: 24px;
}
}
}
@keyframes bubbleSlide {
from {
opacity: 0;
transform: translateY(10px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.bubble-body {
padding: 8px 12px;
border: 1px solid;
min-width: 0;
}
.bubble-text {
font-size: 14px;
line-height: 1.6;
word-break: break-word;
overflow: hidden;
transition: max-height 0.35s ease;
&.text-user {
color: #1a1a1a;
text-shadow: none;
}
&.text-ai {
color: #ffffff;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
}
&.collapsed {
max-height: 4.8em;
position: relative;
&::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2em;
background: linear-gradient(transparent, rgba(10, 14, 30, 0.5));
pointer-events: none;
border-radius: 0 0 8px 8px;
}
}
}
/* ===== 展开/收起按钮 ===== */
.expand-btn {
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 6px;
padding: 3px 10px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
color: rgba(255, 255, 255, 0.55);
font-size: 11px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.8);
border-color: rgba(255, 255, 255, 0.2);
}
}
/* ===== 模式3合并气泡 ===== */
.merged-mode {
.merged-list {
display: flex;
flex-direction: column;
gap: 8px;
padding: 2px 0;
}
}
.merged-bubble {
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.04);
border-radius: 12px;
overflow: hidden;
animation: bubbleSlide 0.35s ease;
}
.merged-user-section {
padding: 8px 12px;
border-bottom: 1px solid rgba(79, 110, 247, 0.08);
}
.merged-ai-section {
padding: 8px 12px;
}
.merged-text {
font-size: 14px;
line-height: 1.6;
word-break: break-word;
overflow: hidden;
transition: max-height 0.35s ease;
&.text-user {
color: #303030ab;
text-shadow: none;
}
&.text-ai {
color: #ffffff;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
}
&.collapsed {
max-height: 4.8em;
position: relative;
&::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2em;
background: linear-gradient(transparent, rgba(10, 14, 30, 0.5));
pointer-events: none;
}
}
}
</style> </style>

View File

@ -14,8 +14,8 @@ const SILENCE_TIMEOUT_MS = 5000;
const MIN_VALID_DURATION_SEC = 0.5; const MIN_VALID_DURATION_SEC = 0.5;
// 播报超时保护毫秒如果FINISHED回调未触发自动恢复状态 // 播报超时保护毫秒如果FINISHED回调未触发自动恢复状态
const BROADCAST_TIMEOUT_MS = 60000; const BROADCAST_TIMEOUT_MS = 60000;
const APPID = "i-sfkmd7h8ex6nd"; const APPID = "i-sfqpfp8qayj8e";
const APP_KEY = "hs6b9nu9b080ap9hswgp"; const APP_KEY = "rdjqhnp5q50e010wvw0z";
/** /**
* *
@ -496,8 +496,8 @@ export function useDigitalHuman(options?: {
token: token.value, token: token.value,
wrapperId: "human-wrapper", wrapperId: "human-wrapper",
connectParams: { connectParams: {
ttsPer: "CAP_4189", ttsPer: "5135",
figureId: "3999826", figureId: "4000288",
resolutionHeight: 1920, resolutionHeight: 1920,
resolutionWidth: 1080, resolutionWidth: 1080,
inactiveDisconnectSec: 300, inactiveDisconnectSec: 300,