feat(input): 将 ChatInput 容器改为 grid 布局,修复 textarea 溢出滚动抖动

This commit is contained in:
肖应宇 2026-03-31 17:35:05 +08:00
parent 64c441ba36
commit 8261415b40
4 changed files with 176 additions and 223 deletions

View File

@ -4,14 +4,7 @@
<div class="input-area">
<!-- 左侧功能按钮 -->
<div class="input-actions left">
<button v-if="attachments.length === 0" type="button" class="action-btn upload" :style="{height: 'auto',width: 'auto',borderRadius: '0'}"
:class="{ disabled: !supports_files && !supports_vision }" :disabled="!supports_files && !supports_vision"
:title="supports_files || supports_vision ? '上传附件或图片' : '当前模型不支持上传'" @click="triggerUploadInput">
<div class="upload-action-div" :class="{ disabled: !supports_files && !supports_vision }">
<PlusIcon :size="13" />
</div>
</button>
<StackedCards v-else :cards="attachments" :supports-files="supports_files" :supports-vision="supports_vision"
<StackedCards :cards="attachments" :supports-files="supports_files" :supports-vision="supports_vision"
@remove="removeAttachment" @add-upload="triggerUploadInput" />
<!-- 隐藏的文件输入框 -->
@ -68,8 +61,8 @@
<button v-if="isStreaming" class="action-btn stop" title="停止生成" @click="$emit('stop')">
<StopCircle :size="20" />
</button>
<button v-else class="action-btn send" :class="{ active: canSend, loading: isProcessingAttachments }" :disabled="!canSend"
:title="isProcessingAttachments ? '附件处理中...' : '发送消息 (Ctrl+Enter)'" @click="handleSend">
<button v-else class="action-btn send" :class="{ active: canSend, loading: isProcessingAttachments }"
:disabled="!canSend" :title="isProcessingAttachments ? '附件处理中...' : '发送消息 (Ctrl+Enter)'" @click="handleSend">
<Loader2 v-if="isProcessingAttachments" :size="20" class="animate-spin" />
<SendIcon v-else :size="20" />
</button>
@ -94,7 +87,6 @@ import { chatApi } from "@/services/api";
import { useAuthStore } from "@/stores/auth";
import { useSettingsStore } from "@/stores/settings";
import StackedCards from "@/components/ui/StackedCards.vue";
import PlusIcon from "../icons/custom/PlusIcon.vue";
import SendIcon from "../icons/custom/SendIcon.vue";
interface AttachmentWithProgress extends Attachment {
@ -202,7 +194,7 @@ function autoResize() {
if (!textarea) return;
textarea.style.height = "auto";
const maxHeight = isExpanded.value ? 400 : 160;
const maxHeight = isExpanded.value ? 400 : 116;
// 1px
textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight) + 1}px`;
}
@ -498,17 +490,11 @@ onMounted(() => {
// border: 2px solid #e2e8f0;
height: 200px;
border-radius: 20px;
display: flex;
flex-direction: column;
padding: 20px;
justify-content: space-between;
overflow: hidden;
transition: all 0.2s ease;
.dark & {
background: #1e1e2e;
border-color: #374151;
}
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
gap: 12px;
border-color: #374151;
// &.is-focused {
// border-color: #3b82f6;
@ -527,6 +513,7 @@ onMounted(() => {
display: flex;
align-items: flex-start;
justify-content: flex-start;
min-height: 0;
gap: 8px;
}
@ -624,11 +611,14 @@ onMounted(() => {
.textarea-wrapper {
flex: 1;
min-width: 0;
min-height: 0;
textarea {
width: 100%;
min-height: 25px;
max-height: 160px;
max-height: 100%;
overflow-y: auto;
scrollbar-gutter: stable;
padding: 8px 0;
border: none;
outline: none;
@ -653,7 +643,6 @@ onMounted(() => {
display: flex;
align-items: center;
justify-content: space-between;
border-top: 1px solid #f3f4f6;
// background: #fafbfc;
.dark & {
@ -714,27 +703,6 @@ onMounted(() => {
}
}
.upload-action-div {
display: grid;
place-items: center;
border-radius: 0;
width: 50px;
height: 70px;
background-color: rgb(255, 255, 255);
}
.upload-action-div.disabled {
cursor: not-allowed;
opacity: 0.42;
background: #f3f4f6;
border: 1px dashed #cbd5e1;
filter: grayscale(1);
}
.upload-action-div.disabled :deep(svg) {
opacity: 0.5;
}
@keyframes pulse {
0%,

View File

@ -1,21 +1,15 @@
<template>
<div
class="message-bubble"
:class="[
`role-${message.role}`,
{
'is-streaming': message.isStreaming,
'is-end': !message.isEnd && message.role !== 'user',
'is-error': message.isError,
compact: compact,
'message-select-mode': isMessageSelectMode,
'message-selected': isSelected,
},
]"
@click="handleBubbleClick"
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
>
<div class="message-bubble" :class="[
`role-${message.role}`,
{
'is-streaming': message.isStreaming,
'is-end': !message.isEnd && message.role !== 'user',
'is-error': message.isError,
compact: compact,
'message-select-mode': isMessageSelectMode,
'message-selected': isSelected,
},
]" @click="handleBubbleClick" @mouseenter="isHovered = true" @mouseleave="isHovered = false">
<!-- 消息选择模式复选框 -->
<div v-if="isMessageSelectMode" class="message-checkbox" @click.stop="handleToggleSelect">
<div class="checkbox" :class="{ checked: isSelected }">
@ -24,12 +18,12 @@
</div>
<!-- 头像 -->
<div class="avatar">
<!-- <div class="avatar">
<div class="avatar-inner" :class="message.role">
<Bot v-if="message.role === 'assistant'" :size="20" />
<User v-else :size="20" />
</div>
</div>
</div> -->
<!-- 消息内容区域 -->
<div class="message-content-wrapper">
@ -44,7 +38,7 @@
</div> -->
<!-- 消息主体 -->
<div class="message-body">
<div :class="message.role === 'assistant' ? 'message-body assistant' : 'message-body user'">
<!-- 错误状态 -->
<div v-if="message.isError" class="error-content">
<AlertCircle :size="18" />
@ -59,30 +53,17 @@
<template v-else>
<!-- 文本内容 - 使用 markstream-vue -->
<div v-if="message.content.text" class="text-content markstream-vue">
<MarkdownRender
v-if="message.role !== 'user'"
:content="message.content.text"
:custom-html-tags="['think']"
custom-id="playground-demo"
:escape-html-tags="['question', 'answer']"
@copy="textCopy"
/>
<MarkdownRender v-if="message.role !== 'user'" :content="message.content.text" :custom-html-tags="['think']"
custom-id="playground-demo" :escape-html-tags="['question', 'answer']" @copy="textCopy" />
<div v-else style="white-space: pre-wrap">
{{ message.content.text }}
</div>
</div>
<!-- 推荐选项 -->
<div
v-if="message.content.suggestions?.length && isNew"
class="suggestions"
>
<button
v-for="suggestion in message.content.suggestions"
:key="suggestion.id"
class="suggestion-btn"
@click="$emit('select-suggestion', suggestion)"
>
<div v-if="message.content.suggestions?.length && isNew" class="suggestions">
<button v-for="suggestion in message.content.suggestions" :key="suggestion.id" class="suggestion-btn"
@click="$emit('select-suggestion', suggestion)">
<Zap :size="14" />
{{ suggestion.text }}
</button>
@ -90,12 +71,8 @@
<!-- 图片展示 -->
<div v-if="message.content.images?.length" class="images-grid">
<div
v-for="(image, index) in message.content.images"
:key="image.id"
class="image-item"
@click="$emit('preview-image', image, index)"
>
<div v-for="(image, index) in message.content.images" :key="image.id" class="image-item"
@click="$emit('preview-image', image, index)">
<img :src="image.url" :alt="image.name" loading="lazy" />
<div class="image-overlay">
<Maximize2 :size="18" />
@ -105,25 +82,14 @@
<!-- 单个视频 -->
<div v-if="message.content.videos?.length === 1" class="single-video">
<video
:src="message.content.videos[0].url"
:poster="message.content.videos[0].poster"
controls
preload="metadata"
/>
<video :src="message.content.videos[0].url" :poster="message.content.videos[0].poster" controls
preload="metadata" />
</div>
<!-- 多个视频 -->
<div
v-if="message.content.videos && message.content.videos.length > 1"
class="videos-grid"
>
<div
v-for="video in message.content.videos"
:key="video.id"
class="video-item"
@click="$emit('play-video', video)"
>
<div v-if="message.content.videos && message.content.videos.length > 1" class="videos-grid">
<div v-for="video in message.content.videos" :key="video.id" class="video-item"
@click="$emit('play-video', video)">
<img :src="video.poster" :alt="video.title" />
<div class="video-overlay">
<Play :size="32" />
@ -136,11 +102,7 @@
<!-- 附件列表 -->
<div v-if="message.content.files?.length" class="files-list">
<div
v-for="file in message.content.files"
:key="file.id"
class="file-item"
>
<div v-for="file in message.content.files" :key="file.id" class="file-item">
<div class="file-icon">
{{ getFileEmoji(file.mimeType) }}
</div>
@ -159,37 +121,23 @@
</template>
<!-- 加载动画 -->
<div
v-if="message.isStreaming && !message.content.text"
class="loading-dots"
>
<!-- <div v-if="message.isStreaming && !message.content.text" class="loading-dots">
<span></span>
<span></span>
<span></span>
</div>
</div> -->
</div>
<!-- 操作栏 -->
<MessageActions
v-if="
message.role === 'assistant' &&
!message.isStreaming &&
!message.isError &&
!readonly &&
!isMessageSelectMode
"
:content="message.content.text || ''"
:feedback="message.feedback"
:show-regenerate="true"
:is-hovered="isHovered"
:is-new="isNew"
:is-break="message.isBreak"
@copy="handleCopy"
@like="handleLike"
@dislike="handleDislike"
@regenerate="$emit('regenerate')"
@share="handleShareClick"
/>
<MessageActions v-if="
message.role === 'assistant' &&
!message.isStreaming &&
!message.isError &&
!readonly &&
!isMessageSelectMode
" :content="message.content.text || ''" :feedback="message.feedback" :show-regenerate="true"
:is-hovered="isHovered" :is-new="isNew" :is-break="message.isBreak" @copy="handleCopy" @like="handleLike"
@dislike="handleDislike" @regenerate="$emit('regenerate')" @share="handleShareClick" />
</div>
</div>
</template>
@ -310,7 +258,7 @@ setCustomComponents("playground-demo", {
.message-bubble {
display: flex;
gap: 16px;
padding: 20px 10%;
padding: 20px 22%;
animation: fadeIn 0.3s ease;
//
@ -347,9 +295,9 @@ setCustomComponents("playground-demo", {
}
.message-body {
background: #f8fafc;
color: #1f2937;
border-radius: 20px 20px 4px 20px;
border-radius: 10px 2px 10px 10px;
background: #EEEFF0;
}
.text-content {
@ -366,8 +314,10 @@ setCustomComponents("playground-demo", {
&.role-assistant {
.message-body {
background: #f8fafc;
border-radius: 20px 20px 20px 4px;
padding: 15px 20px;
gap: 15px;
border-radius: 2px 10px 10px 10px;
background: var(---F8F9FA, #F8F9FA);
.dark & {
background: #2d2d3d;
@ -454,7 +404,6 @@ setCustomComponents("playground-demo", {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 75%;
min-width: 0;
}
@ -491,7 +440,7 @@ setCustomComponents("playground-demo", {
// markstream-vue
.text-content {
:deep(p) {
:deep(p) {
margin: 0 0 12px;
&:last-child {
@ -550,6 +499,7 @@ setCustomComponents("playground-demo", {
}
.dark & {
th,
td {
border-color: #374151;
@ -589,12 +539,15 @@ setCustomComponents("playground-demo", {
:deep(h1) {
font-size: 1.5em;
}
:deep(h2) {
font-size: 1.3em;
}
:deep(h3) {
font-size: 1.15em;
}
:deep(h4) {
font-size: 1em;
}
@ -851,6 +804,7 @@ setCustomComponents("playground-demo", {
&:nth-child(1) {
animation-delay: -0.32s;
}
&:nth-child(2) {
animation-delay: -0.16s;
}
@ -862,6 +816,7 @@ setCustomComponents("playground-demo", {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
@ -869,11 +824,13 @@ setCustomComponents("playground-demo", {
}
@keyframes pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(0.8);
@ -881,12 +838,14 @@ setCustomComponents("playground-demo", {
}
@keyframes pulseDot {
0%,
80%,
100% {
transform: scale(0.6);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;

View File

@ -37,13 +37,11 @@ async function textCopy(data: any) {
</script>
<template>
<div
class="thinking-node p-4 my-4 bg-blue-50 dark:bg-blue-900/40 rounded-md border-l-4 border-blue-400"
>
<div class="thinking-node ">
<!-- 可点击的标题栏 -->
<div class="thinking-header" @click="toggleCollapse">
<div class="flex-shrink-0">
<!-- 思考图标 -->
<!-- 思考图标 -->
<!-- <div class="flex-shrink-0">
<div
class="w-8 h-8 rounded-full bg-blue-200 dark:bg-blue-700 flex items-center justify-center text-blue-700 dark:text-blue-100"
>
@ -63,38 +61,24 @@ async function textCopy(data: any) {
/>
</svg>
</div>
</div>
</div> -->
<div class="thinking-title">
<strong class="text-sm">💭 深度思考</strong>
<!-- TODO: 深度思考样式 -->
<span class="text-lg"> 深度思考</span>
<!-- 加载动画 -->
<span
v-if="node.loading"
class="thinking-dots visible"
aria-hidden="true"
>
<span v-if="node.loading" class="thinking-dots visible" aria-hidden="true">
<span class="dot dot-1" />
<span class="dot dot-2" />
<span class="dot dot-3" />
</span>
<span
v-else
class="thinking-status text-xs text-slate-500 dark:text-slate-300"
>
<span v-else class="thinking-status ">
已完成
</span>
</div>
<!-- 折叠/展开箭头 -->
<div class="collapse-arrow" :class="{ collapsed }">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg>
</div>
@ -102,9 +86,7 @@ async function textCopy(data: any) {
<!-- 可折叠的内容区域 -->
<div class="thinking-content" :class="{ collapsed }">
<div
class="mt-3 text-sm leading-relaxed text-slate-800 dark:text-slate-100"
>
<div class="mt-3 text-sm leading-relaxed dark:text-slate-100">
<MarkdownRender :content="node.content" @copy="textCopy" />
</div>
</div>
@ -114,7 +96,9 @@ async function textCopy(data: any) {
<style scoped>
.thinking-node {
color: #0f172a;
margin-bottom: 15px;
}
.dark .thinking-node {
color: #e6f0ff;
}
@ -123,6 +107,8 @@ async function textCopy(data: any) {
.thinking-header {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #e2e8f0;
gap: 12px;
cursor: pointer;
user-select: none;
@ -136,7 +122,13 @@ async function textCopy(data: any) {
}
.thinking-status {
font-style: italic;
/* font-style: italic; */
color: var(--6B-BBBBBB, #BBB);
font-family: "Microsoft YaHei";
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 21px;
}
/* 折叠箭头 */
@ -150,18 +142,28 @@ async function textCopy(data: any) {
transition: transform 0.25s ease;
border-radius: 4px;
}
.collapse-arrow:hover {
background: rgba(0, 0, 0, 0.06);
}
.dark .collapse-arrow:hover {
background: rgba(255, 255, 255, 0.08);
}
.collapse-arrow.collapsed {
transform: rotate(-90deg);
}
/* 可折叠内容 */
.thinking-content {
color: var(--9-999999, #999);
font-family: "Microsoft YaHei";
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 20px;
/* 153.846% */
max-height: 2000px;
overflow: auto;
transition:
@ -169,6 +171,7 @@ async function textCopy(data: any) {
opacity 0.25s ease;
opacity: 1;
}
.thinking-content.collapsed {
max-height: 0;
opacity: 0;
@ -181,6 +184,7 @@ async function textCopy(data: any) {
gap: 6px;
height: 12px;
}
.thinking-dots .dot {
width: 6px;
height: 6px;
@ -188,30 +192,36 @@ async function textCopy(data: any) {
background: #1e3a8a;
opacity: 0.25;
}
.thinking-dots.visible .dot-1 {
animation: think-bounce 1s infinite ease-in-out;
animation-delay: 0s;
}
.thinking-dots.visible .dot-2 {
animation: think-bounce 1s infinite ease-in-out;
animation-delay: 0.12s;
}
.thinking-dots.visible .dot-3 {
animation: think-bounce 1s infinite ease-in-out;
animation-delay: 0.24s;
}
.dark .thinking-dots .dot {
background: #bfdbfe;
opacity: 0.28;
}
@keyframes think-bounce {
0%,
80%,
100% {
transform: translateY(0);
opacity: 0.25;
}
40% {
transform: translateY(-6px);
opacity: 1;

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { NModal, NImage } from 'naive-ui'
import PlusIcon from '../icons/custom/PlusIcon.vue'
export interface CardItem {
@ -43,6 +43,7 @@ const isWrapperHovered = ref(false)
const hoveredCardId = ref<string | number | null>(null)
const containerRef = ref<HTMLElement | null>(null)
const previewCard = ref<CardItem | null>(null)
const canUpload = computed(() => props.supportsFiles || props.supportsVision)
const isPreviewVisible = computed({
get: () => !!previewCard.value,
set: (value: boolean) => {
@ -235,10 +236,30 @@ onMounted(() => {
onUnmounted(() => {
document.removeEventListener('click', handleDocumentClick)
})
watch(
() => props.cards.length,
(length) => {
if (length === 0) {
isExpanded.value = false
hoveredCardId.value = null
}
},
)
</script>
<template>
<div ref="containerRef" class="stacked-cards-container" :class="{ 'is-expanded': isExpanded }" @click="expandCards">
<div v-if="cards.length === 0" class="stacked-cards-empty" aria-label="上传附件操作">
<button type="button" class="stacked-upload-btn" :class="{ disabled: !canUpload }" :disabled="!canUpload"
:title="canUpload ? '上传附件或图片' : '当前模型不支持上传'" @click.stop="emit('add-upload')">
<div class="upload-action-div" :class="{ disabled: !canUpload }">
<PlusIcon :size="13" />
</div>
</button>
</div>
<div v-else ref="containerRef" class="stacked-cards-container" :class="{ 'is-expanded': isExpanded }"
@click="expandCards">
<div class="cards-wrapper" @mouseenter="isWrapperHovered = true" @mouseleave="isWrapperHovered = false">
<TransitionGroup name="card-spread">
<div v-for="(card, index) in cards" :key="card.id" class="card" :style="cardStyle(index, cards.length)"
@ -277,18 +298,6 @@ onUnmounted(() => {
</TransitionGroup>
</div>
<Transition name="upload-actions-fade">
<div v-if="!isExpanded" class="upload-actions" aria-label="上传附件操作" @click.stop>
<button type="button" class="upload-action-btn upload"
:class="{ disabled: !(props.supportsFiles || props.supportsVision) }"
:disabled="!(props.supportsFiles || props.supportsVision)" title="上传附件或图片" @click.stop="emit('add-upload')">
<span class="upload-action-icon">
<PlusIcon />
</span>
</button>
</div>
</Transition>
<NModal v-model:show="isPreviewVisible" preset="card" :mask-closable="true" :close-on-esc="true"
:on-close="removePreviewCard" class="image-preview-modal"
:content-style="{ width: '100%', height: '100%', padding: '0', overflow: 'hidden' }"
@ -319,6 +328,12 @@ onUnmounted(() => {
justify-content: center;
perspective: 1000px;
}
.stacked-cards-empty {
display: flex;
align-items: center;
justify-content: center;
}
/* 卡片大小 */
.cards-wrapper {
position: relative;
@ -331,7 +346,7 @@ onUnmounted(() => {
.card {
position: absolute;
width: 90%;
width: 74%;
height: 100%;
border-radius: 5px;
border: 1px solid var(--card-border-color, var(--ffffff, #FFF));
@ -635,50 +650,46 @@ onUnmounted(() => {
opacity: 0;
}
.upload-actions {
position: absolute;
right: -2rem;
bottom: 0;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 0.375rem;
z-index: 30;
}
.upload-action-btn {
.stacked-upload-btn {
display: inline-flex;
align-items: center;
height: 19px;
padding: 0 7px;
border-radius: 999px;
background: rgba(255, 255, 255);
color: #e4e4e7;
justify-content: center;
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
transition: transform 0.2s ease, opacity 0.2s ease, border-color 0.2s ease;
transition: transform 0.2s ease, opacity 0.2s ease;
}
.upload-action-btn:hover:not(:disabled) {
.stacked-upload-btn:hover:not(:disabled) {
transform: translateY(-2px);
}
.upload-action-btn.file {
border-color: rgba(6, 182, 212, 0.22);
}
.upload-action-btn.image {
border-color: rgba(139, 92, 246, 0.22);
}
.upload-action-btn.disabled,
.upload-action-btn:disabled {
.stacked-upload-btn.disabled,
.stacked-upload-btn:disabled {
opacity: 0.42;
cursor: not-allowed;
}
.upload-action-icon {
font-size: 8px;
line-height: 1;
.upload-action-div {
display: grid;
place-items: center;
border-radius: 0;
width: 50px;
height: 70px;
background-color: rgb(255, 255, 255);
}
.upload-action-div.disabled {
cursor: not-allowed;
opacity: 0.42;
background: #f3f4f6;
border: 1px dashed #cbd5e1;
filter: grayscale(1);
}
.upload-action-div.disabled :deep(svg) {
opacity: 0.5;
}
.upload-action-text {
@ -774,9 +785,14 @@ onUnmounted(() => {
height: 36px;
}
.upload-actions {
bottom: 0.75rem;
gap: 0.25rem;
.upload-action-div {
width: 75px;
height: 100px;
}
.stacked-cards-empty {
width: 75px;
height: 100px;
}
.upload-action-btn {