908 lines
21 KiB
Vue
908 lines
21 KiB
Vue
<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;
|
||
}
|
||
|
||
/* ===== 模式1:AI字幕 ===== */
|
||
.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>
|