baidu-digital-human/src/components/SubtitlePanel.vue
2026-06-11 13:46:14 +08:00

908 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<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
v-if="currentMode === 'ai-only'"
class="subtitle-content ai-only-mode"
ref="scrollContainerRef"
>
<div v-if="aiSubtitle" class="ai-subtitle-bar" ref="aiSubtitleBarRef">
<div
class="ai-subtitle-text"
ref="aiSubtitleTextRef"
:style="{ fontSize: aiFontSize + 'px' }"
>
{{ aiSubtitle }}
</div>
<!-- 滚动指示器 -->
<div
v-if="aiTextOverflow"
class="scroll-hint"
@click="scrollAIToBottom"
>
<svg
viewBox="0 0 24 24"
width="14"
height="14"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</div>
</div>
<div v-else 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>
等待AI回复...
</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>
</template>
<script setup lang="ts">
import {
ref,
computed,
watch,
nextTick,
onMounted,
onBeforeUnmount,
} from "vue";
import type { ChatMessage, SubtitleMode } from "@/types";
const props = defineProps<{
mode: SubtitleMode;
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 aiSubtitleBarRef = ref<HTMLElement | null>(null);
const aiSubtitleTextRef = ref<HTMLElement | null>(null);
// ===== AI字幕自适应字号 =====
const aiFontSize = ref(18);
const aiTextOverflow = ref(false);
// 计算AI字幕字号根据文本长度动态调整
const computeAIFontSize = () => {
const text = props.aiSubtitle;
if (!text) {
aiFontSize.value = 18;
return;
}
const len = text.length;
if (len <= 50) aiFontSize.value = 18;
else if (len <= 100) aiFontSize.value = 16;
else if (len <= 200) aiFontSize.value = 14;
else if (len <= 400) aiFontSize.value = 13;
else aiFontSize.value = 12;
};
// 检测AI字幕是否溢出
const checkAIOverflow = () => {
nextTick(() => {
const bar = aiSubtitleBarRef.value;
const textEl = aiSubtitleTextRef.value;
if (bar && textEl) {
aiTextOverflow.value = textEl.scrollHeight > bar.clientHeight + 4;
}
});
};
const scrollAIToBottom = () => {
if (aiSubtitleBarRef.value) {
aiSubtitleBarRef.value.scrollTo({
top: aiSubtitleBarRef.value.scrollHeight,
behavior: "smooth",
});
}
};
// ===== 长文本折叠/展开 =====
const COLLAPSE_THRESHOLD = 120; // 超过120字符视为长文本
const collapsedIds = ref<Set<string>>(new Set());
const msgRefs = new Map<string, HTMLElement>();
const isLongText = (text: string): boolean => text.length > COLLAPSE_THRESHOLD;
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(
() => props.aiSubtitle,
() => {
computeAIFontSize();
checkAIOverflow();
// 自动滚动AI字幕到底部
nextTick(() => {
if (aiSubtitleBarRef.value) {
aiSubtitleBarRef.value.scrollTo({
top: aiSubtitleBarRef.value.scrollHeight,
behavior: "smooth",
});
}
});
},
);
// 消息变化时:自动滚动到底部 + 新消息默认折叠
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>
<style scoped lang="scss">
.subtitle-panel {
display: flex;
flex-direction: column;
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(24px);
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 {
flex: 1;
min-height: 0;
max-height: calc(30vh - 60px);
overflow-y: auto;
animation: contentFade 0.3s ease;
&::-webkit-scrollbar {
width: 2px;
}
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.12);
border-radius: 2px;
}
}
@keyframes contentFade {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.subtitle-placeholder {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 100%;
color: rgba(255, 255, 255, 0.3);
font-size: 14px;
}
/* ===== 模式1AI字幕 ===== */
.ai-only-mode {
display: flex;
align-items: flex-end;
padding-bottom: 4px;
}
.ai-subtitle-bar {
width: 100%;
max-height: 100%;
overflow-y: auto;
background: linear-gradient(
135deg,
rgba(108, 140, 255, 0.06) 0%,
rgba(79, 110, 247, 0.03) 100%
);
border: 1px solid rgba(108, 140, 255, 0.06);
border-radius: 8px;
padding: 8px 12px;
backdrop-filter: blur(4px);
transition: all 0.3s ease;
position: relative;
&::-webkit-scrollbar {
width: 2px;
}
&::-webkit-scrollbar-thumb {
background: rgba(108, 140, 255, 0.15);
border-radius: 2px;
}
}
.ai-subtitle-text {
line-height: 1.6;
color: #ffffff;
word-break: break-word;
font-weight: 400;
font-size: 14px;
letter-spacing: 0.2px;
transition: font-size 0.3s ease;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6);
}
.scroll-hint {
position: sticky;
bottom: 0;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(108, 140, 255, 0.3);
color: rgba(255, 255, 255, 0.8);
cursor: pointer;
margin: 8px auto 0;
transition: all 0.2s;
&:hover {
background: rgba(108, 140, 255, 0.5);
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>