feat(upload-ui): 优化附件卡片交互与上传图标展示

This commit is contained in:
肖应宇 2026-04-09 16:19:56 +08:00
parent 88e43c1763
commit 135f3ae5c8
5 changed files with 335 additions and 119 deletions

View File

@ -0,0 +1,40 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
>
<path
d="M7 13C10.3137 13 13 10.3137 13 7C13 3.68629 10.3137 1 7 1C3.68629 1 1 3.68629 1 7"
stroke="url(#paint0_linear_169_1705)"
stroke-width="2"
stroke-linecap="round"
/>
<defs>
<linearGradient
id="paint0_linear_169_1705"
x1="1"
y1="7"
x2="7"
y2="13"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="white" />
<stop offset="1" stop-color="white" />
</linearGradient>
</defs>
</svg>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
size?: number;
}>(),
{
size: 14,
},
);
</script>

View File

@ -1,7 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 13 13" fill="none">
<path d="M0.5 6.49457H12.5" stroke="#999999" stroke-linecap="round" />
<path d="M6.49414 12.5L6.49414 0.5" stroke="#999999" stroke-linecap="round" />
<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 11 11" fill="none">
<path d="M0.5 5.49469H10.5" stroke="#999999" stroke-linecap="round" />
<path d="M5.49512 10.5L5.49512 0.5" stroke="#999999" stroke-linecap="round" />
</svg>
</template>

View File

@ -0,0 +1,22 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
>
<rect width="12" height="12" rx="2" fill="white" />
</svg>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
size?: number;
}>(),
{
size: 12,
},
);
</script>

View File

@ -8,7 +8,14 @@
@remove="removeAttachment" @add-upload="triggerUploadInput" />
<!-- 隐藏的文件输入框 -->
<input ref="uploadInputRef" type="file" multiple hidden @change="handleUploadSelect" />
<input
ref="uploadInputRef"
type="file"
:accept="uploadAccept"
multiple
hidden
@change="handleUploadSelect"
/>
</div>
<!-- 文本输入框 -->
@ -58,36 +65,35 @@
<!-- 右侧功能按钮 -->
<div class="input-actions right">
<!-- 发送/停止按钮 -->
<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">
<Loader2 v-if="isProcessingAttachments" :size="20" class="animate-spin" />
<SendIcon v-else :size="20" />
</button>
<button v-if="isStreaming" class="action-btn stop" title="停止生成" @click="$emit('stop')">
<StopIcon />
</button>
<button v-else class="action-btn send" :class="{ active: canSend, loading: isProcessingAttachments }"
:disabled="!canSend" :title="isProcessingAttachments ? '附件处理中...' : '发送消息 (Ctrl+Enter)'" @click="handleSend">
<LoadingIcon v-if="isProcessingAttachments" class="animate-spin" />
<SendIcon v-else :size="20" />
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted } from "vue";
import {
StopCircle,
Sparkles,
Globe,
Brain,
Loader2,
Upload,
} from "@/components/icons";
import { ref, computed, watch, nextTick, onMounted } from "vue";
import {
Sparkles,
Globe,
Brain,
} from "@/components/icons";
import { generateId } from "@/utils/helpers";
import type { Attachment } from "@/types/chat";
import { chatApi } from "@/services/api";
import { useAuthStore } from "@/stores/auth";
import { useSettingsStore } from "@/stores/settings";
import StackedCards from "@/components/ui/StackedCards.vue";
import SendIcon from "../icons/custom/SendIcon.vue";
import { useAuthStore } from "@/stores/auth";
import { useSettingsStore } from "@/stores/settings";
import StackedCards from "@/components/ui/StackedCards.vue";
import SendIcon from "../icons/custom/SendIcon.vue";
import StopIcon from "../icons/custom/StopIcon.vue";
import LoadingIcon from "../icons/custom/LoadingIcon.vue";
interface AttachmentWithProgress extends Attachment {
uploading?: boolean;
@ -175,10 +181,44 @@ function showThrottledToast(message: string, type: "error" = "error") {
//
const charCount = computed(() => inputText.value.length);
const isUploading = computed(() => attachments.value.some((a) => a.uploading));
const isProcessingAttachments = computed(() =>
attachments.value.some((a) => a.uploading || a.deleting),
);
const isUploading = computed(() => attachments.value.some((a) => a.uploading));
const isProcessingAttachments = computed(() =>
attachments.value.some((a) => a.uploading || a.deleting),
);
const textFileAccept =
".txt,.md,.markdown,.pdf,.doc,.docx,.rtf,.csv,.tsv,.json,.xml,.html,.htm,.yaml,.yml,.log,.ini,.conf,.sql,.js,.ts,.jsx,.tsx,.py,.java,.c,.cpp,.h,.hpp,.go,.rs,.sh";
const uploadAccept = computed(() => {
if (props.supports_vision && props.supports_files) {
return `image/*,${textFileAccept}`;
}
if (props.supports_vision) {
return "image/*";
}
if (props.supports_files) {
return textFileAccept;
}
return "";
});
function getFileExt(fileName: string) {
const idx = fileName.lastIndexOf(".");
if (idx === -1) return "";
return fileName.slice(idx).toLowerCase();
}
function getUploadTypeByModel(file: File): "image" | "file" | null {
const isImage =
file.type.startsWith("image/") ||
[".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg", ".heic"].includes(
getFileExt(file.name),
);
if (isImage) {
return props.supports_vision ? "image" : null;
}
return props.supports_files ? "file" : null;
}
const canSend = computed(() => {
return (
(inputText.value.trim().length > 0 || attachments.value.length > 0) &&
@ -261,7 +301,7 @@ function handleSend() {
}
//
async function handlePaste(event: ClipboardEvent) {
async function handlePaste(event: ClipboardEvent) {
const items = event.clipboardData?.items;
if (!items) return;
@ -281,31 +321,49 @@ async function handlePaste(event: ClipboardEvent) {
}
}
for (const item of items) {
if (item.type.startsWith("image/")) {
event.preventDefault();
const file = item.getAsFile();
if (file) {
await addFileAsAttachment(file, "image");
}
}
}
}
function triggerUploadInput() {
uploadInputRef.value?.click();
}
for (const item of items) {
if (item.type.startsWith("image/")) {
const file = item.getAsFile();
if (file) {
const uploadType = getUploadTypeByModel(file);
if (uploadType === "image") {
event.preventDefault();
await addFileAsAttachment(file, uploadType);
} else {
event.preventDefault();
showThrottledToast("当前模型不支持上传图片");
}
}
}
}
}
function triggerUploadInput() {
if (!props.supports_vision && !props.supports_files) {
showThrottledToast("当前模型不支持上传附件");
return;
}
uploadInputRef.value?.click();
}
//
async function handleUploadSelect(event: Event) {
const input = event.target as HTMLInputElement;
const files = input.files;
if (!files) return;
for (const file of files) {
const type = file.type.startsWith("image/") ? "image" : "file";
await addFileAsAttachment(file, type);
}
if (!files) return;
for (const file of files) {
const uploadType = getUploadTypeByModel(file);
if (!uploadType) {
showThrottledToast(
file.type.startsWith("image/")
? "当前模型不支持上传图片"
: "当前模型不支持上传附件文件",
);
continue;
}
await addFileAsAttachment(file, uploadType);
}
input.value = "";
}
@ -568,7 +626,7 @@ onMounted(() => {
}
&.send {
background: #e5e7eb;
background: rgba(0, 15, 51, 0.20);
color: #9ca3af;
.dark & {
@ -586,26 +644,26 @@ onMounted(() => {
}
&.loading {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
background: #000F33;
color: white;
cursor: wait;
}
&:disabled {
background: rgba(0, 15, 51, 0.20);
cursor: not-allowed;
opacity: 0.6;
}
}
&.stop {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
color: white;
animation: pulse 2s infinite;
&:hover {
transform: scale(1.05);
}
}
&.stop {
background: #000F33;
color: white;
&:hover {
transform: scale(1.05);
}
}
}
.textarea-wrapper {
@ -703,15 +761,4 @@ onMounted(() => {
}
}
@keyframes pulse {
0%,
100% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
}
50% {
box-shadow: 0 0 0 8px rgba(239, 68, 68, 0);
}
}
</style>
</style>

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { NModal, NImage } from 'naive-ui'
import { NModal, NImage, NTooltip } from 'naive-ui'
import PlusIcon from '../icons/custom/PlusIcon.vue'
export interface CardItem {
id: string | number
@ -262,42 +262,85 @@ watch(
@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)"
@mouseenter="hoveredCardId = card.id" @mouseleave="hoveredCardId = null" @click="handleCardClick(card)">
<div class="card-glow" :style="{ background: getCardColor(card, index) }" />
<div v-if="card.type === 'image' && getCardImageUrl(card)" class="card-media">
<img :src="getCardImageUrl(card)" :alt="getCardTitle(card, index)" />
<div v-if="card.uploading" class="card-media-uploading">
<div class="card-media-uploading-spinner" aria-hidden="true" />
<span class="card-media-uploading-text">上传中</span>
<NTooltip
v-for="(card, index) in cards"
:key="card.id"
trigger="hover"
placement="top"
>
<template #trigger>
<div class="card" :style="cardStyle(index, cards.length)"
@mouseenter="hoveredCardId = card.id" @mouseleave="hoveredCardId = null" @click="handleCardClick(card)">
<div class="card-glow" :style="{ background: getCardColor(card, index) }" />
<div v-if="card.type === 'image' && getCardImageUrl(card)" class="card-media">
<img :src="getCardImageUrl(card)" :alt="getCardTitle(card, index)" />
<div v-if="card.uploading" class="card-media-uploading">
<div class="card-media-uploading-spinner" aria-hidden="true" />
<span class="card-media-uploading-text">上传中</span>
</div>
<div v-else-if="card.deleting" class="card-media-deleting">
<div class="card-media-deleting-spinner" aria-hidden="true" />
<span class="card-media-deleting-text">删除中</span>
</div>
</div>
<div v-else-if="card.type === 'image'" class="card-preview">
<div v-if="getCardImageUrl(card)" class="card-preview-image">
<img :src="getCardImageUrl(card)" :alt="getCardTitle(card, index)" />
</div>
<div class="card-preview-fallback">
<span class="card-icon">
{{ getCardIcon(card) }}
</span>
</div>
<div v-if="card.deleting" class="card-preview-deleting">
<div class="card-preview-deleting-spinner" aria-hidden="true" />
<span class="card-preview-deleting-text">删除中</span>
</div>
</div>
<div v-else class="card-file-preview">
<div class="card-file-icon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="2.5" y="1.5" width="11" height="13" rx="1.5" stroke="#666666" />
<path d="M5 5H11" stroke="#666666" stroke-linecap="round" />
<path d="M5 8H11" stroke="#666666" stroke-linecap="round" />
<path d="M5 11H11" stroke="#666666" stroke-linecap="round" />
</svg>
</div>
<div class="card-file-name" :title="getCardTitle(card, index)">
{{ getCardTitle(card, index) }}
</div>
<div v-if="card.deleting" class="card-preview-deleting">
<div class="card-preview-deleting-spinner" aria-hidden="true" />
<span class="card-preview-deleting-text">删除中</span>
</div>
</div>
<button v-if="!card.deleting && isWrapperHovered && (hoveredCardId === card.id || (!hoveredCardId && index === 0))"
type="button" class="card-delete-btn" title="删除 OSS 文件" @click.stop="removeCard(card)">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect width="16.0008" height="16" rx="8" fill="#999999" />
<path d="M5.49512 5.33374L10.6692 10.6624" stroke="white" stroke-linecap="round" />
<path d="M10.5068 5.33813L5.33271 10.6668" stroke="white" stroke-linecap="round" />
</svg>
</button>
</div>
<div v-else-if="card.deleting" class="card-media-deleting">
<div class="card-media-deleting-spinner" aria-hidden="true" />
<span class="card-media-deleting-text">删除中</span>
</div>
</div>
<div v-else class="card-preview">
<div v-if="getCardImageUrl(card)" class="card-preview-image">
<img :src="getCardImageUrl(card)" :alt="getCardTitle(card, index)" />
</div>
<div class="card-preview-fallback">
<span class="card-icon">
{{ getCardIcon(card) }}
</span>
</div>
<div v-if="card.deleting" class="card-preview-deleting">
<div class="card-preview-deleting-spinner" aria-hidden="true" />
<span class="card-preview-deleting-text">删除中</span>
</div>
</div>
<button v-if="!card.deleting && isWrapperHovered && (hoveredCardId === card.id || (!hoveredCardId && index === 0))"
type="button" class="card-delete-btn" title="删除 OSS 文件" @click.stop="removeCard(card)">
</button>
</div>
</template>
{{ getCardTitle(card, index) }}
</NTooltip>
</TransitionGroup>
</div>
<button
v-if="!isExpanded"
type="button"
class="cards-upload-fab"
:class="{ disabled: !canUpload }"
:disabled="!canUpload"
:title="canUpload ? '上传附件或图片' : '当前模型不支持上传'"
@click.stop="emit('add-upload')"
>
<PlusIcon :size="12" />
</button>
<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' }"
@ -329,6 +372,35 @@ watch(
perspective: 1000px;
}
.cards-upload-fab {
position: absolute;
right: 2px;
bottom: 2px;
z-index: 999;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: 1px solid #e5e7eb;
border-radius: 999px;
background: #ffffff;
color: #999999;
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.16);
cursor: pointer;
transition: transform 0.2s ease, opacity 0.2s ease, background 0.2s ease;
}
.cards-upload-fab:hover:not(:disabled) {
transform: scale(1.06);
}
.cards-upload-fab.disabled,
.cards-upload-fab:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.stacked-cards-empty {
display: flex;
align-items: center;
@ -358,7 +430,8 @@ watch(
}
.card-media,
.card-preview {
.card-preview,
.card-file-preview {
position: absolute;
inset: 0;
z-index: 0;
@ -456,29 +529,31 @@ watch(
.card-delete-btn {
position: absolute;
top: 0.35rem;
right: 0.35rem;
top: 0.3rem;
right: 0.3rem;
z-index: 4;
display: inline-flex;
align-items: center;
justify-content: center;
width: 0.95rem;
height: 0.95rem;
width: 16.001px;
height: 16px;
aspect-ratio: 1 / 1;
padding: 0;
border: 0;
border-radius: 999px;
background: rgba(239, 68, 68, 0.9);
color: #fff;
font-size: 0.55rem;
line-height: 1;
border-radius: 50%;
background: transparent;
cursor: pointer;
backdrop-filter: blur(10px);
transition: transform 0.2s ease, opacity 0.2s ease, background 0.2s ease;
transition: transform 0.2s ease, opacity 0.2s ease;
}
.card-delete-btn:hover {
transform: scale(1.08);
background: rgba(220, 38, 38, 0.98);
}
.card-delete-btn svg {
width: 16.001px;
height: 16px;
display: block;
}
.card-media img,
@ -555,6 +630,38 @@ watch(
font-size: 0.95rem;
}
.card-file-preview {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 6px;
background: #fff;
}
.card-file-icon {
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.card-file-name {
width: 100%;
color: var(--6-666666, #666);
font-family: "Microsoft YaHei";
font-size: 10px;
font-style: normal;
font-weight: 400;
line-height: normal;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
}
.card:hover .card-glow {
opacity: 0.15;