feat(stacked-cards): 重做 StackedCards 组件并完善附件交互

This commit is contained in:
肖应宇 2026-03-27 17:34:48 +08:00
parent ecdb0db49e
commit 9b75000841
8 changed files with 889 additions and 651 deletions

View File

@ -28,13 +28,6 @@
<!-- 输入区域 -->
<div class="input-wrapper">
<!-- 附件预览区 -->
<div v-if="hasAttachments" class="attachments-preview-container">
<AttachmentPreview
:attachments="currentAttachments"
@remove="handleRemoveAttachment"
/>
</div>
<div class="input-container" :class="{ wide: isWideMode }">
<ChatInput
ref="chatInputRef"
@ -63,7 +56,6 @@ import { useAuthStore } from "@/stores/auth";
import ChatHeader from "./ChatHeader.vue";
import MessageList from "./MessageList.vue";
import ChatInput from "@/components/input/ChatInput.vue";
import AttachmentPreview from "@/components/input/AttachmentPreview.vue";
import { MessageType, MessageRole } from "@/types/chat";
import type { Attachment, Suggestion } from "@/types/chat";
import { chatApi, type ModelInfo } from "@/services/api";
@ -127,14 +119,6 @@ const inputPlaceholder = computed(() => {
return "输入你的问题,按 Ctrl+Enter 发送";
});
//
const currentAttachments = computed(() => chatInputRef.value?.attachments || []);
const hasAttachments = computed(() => currentAttachments.value.length > 0);
function handleRemoveAttachment(id: string) {
chatInputRef.value?.removeAttachment(id);
}
function toggleWideMode() {
isWideMode.value = !isWideMode.value;
}
@ -553,17 +537,6 @@ watch(
}
}
.attachments-preview-container {
margin-bottom: 12px;
background: #f3f4f5;
border-radius: 16px;
overflow: hidden;
.dark & {
background: #1e1e2e;
}
}
.input-container {
margin: auto;
width: 55%;

View File

@ -0,0 +1,17 @@
<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>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
size?: number;
}>(),
{
size: 18,
},
);
</script>

View File

@ -1,269 +0,0 @@
<template>
<div class="attachment-preview">
<TransitionGroup name="attachment">
<div
v-for="attachment in attachments"
:key="attachment.id"
class="attachment-item"
:class="attachment.type"
>
<!-- 图片预览 -->
<template v-if="attachment.type === 'image'">
<img
:src="attachment.url"
:alt="attachment.name"
class="preview-image"
/>
</template>
<!-- 视频预览 -->
<template v-else-if="attachment.type === 'video'">
<div class="preview-video">
<img
v-if="attachment.thumbnail"
:src="attachment.thumbnail"
:alt="attachment.name"
/>
<div v-else class="video-placeholder">
<Video :size="24" />
</div>
<div class="video-badge">
<Play :size="12" />
</div>
</div>
</template>
<!-- 文件预览 -->
<template v-else>
<div class="preview-file">
<span class="file-emoji">{{
getFileEmoji(attachment.mimeType)
}}</span>
<div class="file-details">
<span class="file-name">{{ truncateName(attachment.name) }}</span>
<span class="file-size">{{ formatSize(attachment.size) }}</span>
</div>
</div>
</template>
<!-- 删除按钮 -->
<button class="remove-btn" @click="$emit('remove', attachment.id)">
<X :size="14" />
</button>
<!-- 上传进度 -->
<div v-if="attachment.uploading" class="upload-progress">
<div
class="progress-bar"
:style="{ width: `${attachment.progress || 0}%` }"
/>
</div>
</div>
</TransitionGroup>
</div>
</template>
<script setup lang="ts">
import { X, Video, Play } from "@/components/icons";
import { formatFileSize, getFileIcon, truncateText } from "@/utils/helpers";
interface AttachmentWithProgress {
id: string;
name: string;
type: "image" | "file" | "video";
url: string;
size?: number;
mimeType?: string;
thumbnail?: string;
uploading?: boolean;
progress?: number;
}
defineProps<{
attachments: AttachmentWithProgress[];
}>();
defineEmits<{
remove: [id: string];
}>();
function getFileEmoji(mimeType?: string) {
return getFileIcon(mimeType || "");
}
function formatSize(size?: number) {
return size ? formatFileSize(size) : "";
}
function truncateName(name: string) {
return truncateText(name, 20);
}
</script>
<style lang="scss" scoped>
.attachment-preview {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid #e2e8f0;
.dark & {
border-bottom-color: #374151;
}
}
.attachment-item {
position: relative;
border-radius: 12px;
overflow: hidden;
background: #f3f4f6;
.dark & {
background: #374151;
}
&.image,
&.video {
width: 80px;
height: 80px;
}
&.file {
padding: 10px 40px 10px 12px;
}
}
.preview-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-video {
position: relative;
width: 100%;
height: 100%;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.video-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #e5e7eb;
color: #9ca3af;
.dark & {
background: #4b5563;
}
}
.video-badge {
position: absolute;
bottom: 6px;
left: 6px;
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
background: rgba(0, 0, 0, 0.7);
border-radius: 50%;
color: white;
}
}
.preview-file {
display: flex;
align-items: center;
gap: 10px;
}
.file-emoji {
font-size: 24px;
}
.file-details {
display: flex;
flex-direction: column;
}
.file-name {
font-size: 13px;
font-weight: 500;
color: #374151;
.dark & {
color: #e5e7eb;
}
}
.file-size {
font-size: 11px;
color: #9ca3af;
}
.remove-btn {
position: absolute;
top: 4px;
right: 4px;
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
border-radius: 50%;
background: rgba(0, 0, 0, 0.6);
color: white;
cursor: pointer;
opacity: 0;
transition: all 0.2s ease;
.attachment-item:hover & {
opacity: 1;
}
&:hover {
background: rgba(239, 68, 68, 0.9);
}
}
.upload-progress {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: rgba(0, 0, 0, 0.2);
.progress-bar {
height: 100%;
background: #3b82f6;
transition: width 0.3s ease;
}
}
//
.attachment-enter-active,
.attachment-leave-active {
transition: all 0.3s ease;
}
.attachment-enter-from {
opacity: 0;
transform: scale(0.8);
}
.attachment-leave-to {
opacity: 0;
transform: scale(0.8);
}
</style>

View File

@ -4,21 +4,18 @@
<div class="input-area">
<!-- 左侧功能按钮 -->
<div class="input-actions left">
<!-- 附件按钮 -->
<button class="action-btn" :class="{ disabled: !supports_files }" :disabled="!supports_files"
:title="supports_files ? '添加附件' : '当前模型不支持文件附件'" @click="supports_files && triggerFileInput()">
<Paperclip :size="20" />
</button>
<!-- 图片按钮 -->
<button class="action-btn" :class="{ disabled: !supports_vision }" :disabled="!supports_vision"
:title="supports_vision ? '添加图片' : '当前模型不支持图片识别'" @click="supports_vision && triggerImageInput()">
<Image :size="20" />
<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"
@remove="removeAttachment" @add-upload="triggerUploadInput" />
<!-- 隐藏的文件输入框 -->
<input ref="fileInputRef" type="file" multiple hidden @change="handleFileSelect" />
<input ref="imageInputRef" type="file" accept="image/*" multiple hidden @change="handleImageSelect" />
<input ref="uploadInputRef" type="file" multiple hidden @change="handleUploadSelect" />
</div>
<!-- 文本输入框 -->
@ -71,11 +68,11 @@
<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: isUploading }" :disabled="!canSend"
:title="isUploading ? '上传中...' : '发送消息 (Ctrl+Enter)'" @click="handleSend">
<Loader2 v-if="isUploading" :size="20" class="animate-spin" />
<SendIcon v-else :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>
</div>
</div>
</div>
@ -84,28 +81,27 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted } from "vue";
import {
Paperclip,
Image,
Send,
StopCircle,
Sparkles,
Globe,
Maximize2,
Minimize2,
Brain,
Loader2,
Upload,
} 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 PlusIcon from "../icons/custom/PlusIcon.vue";
import SendIcon from "../icons/custom/SendIcon.vue";
interface AttachmentWithProgress extends Attachment {
uploading?: boolean;
progress?: number;
}
interface AttachmentWithProgress extends Attachment {
uploading?: boolean;
progress?: number;
deleting?: boolean;
}
const props = withDefaults(
defineProps<{
@ -171,8 +167,7 @@ const isWebSearch = ref(
);
// DOM
const textareaRef = ref<HTMLTextAreaElement | null>(null);
const fileInputRef = ref<HTMLInputElement | null>(null);
const imageInputRef = ref<HTMLInputElement | null>(null);
const uploadInputRef = ref<HTMLInputElement | null>(null);
// toast
let lastToastTime = 0;
@ -187,16 +182,19 @@ function showThrottledToast(message: string, type: "error" = "error") {
}
//
const charCount = computed(() => inputText.value.length);
const isUploading = computed(() => attachments.value.some((a) => a.uploading));
const canSend = computed(() => {
return (
(inputText.value.trim().length > 0 || attachments.value.length > 0) &&
!props.disabled &&
charCount.value <= props.maxChars &&
!isUploading.value
);
});
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 canSend = computed(() => {
return (
(inputText.value.trim().length > 0 || attachments.value.length > 0) &&
!props.disabled &&
charCount.value <= props.maxChars &&
!isProcessingAttachments.value
);
});
//
function autoResize() {
@ -302,35 +300,19 @@ async function handlePaste(event: ClipboardEvent) {
}
}
//
function triggerFileInput() {
fileInputRef.value?.click();
function triggerUploadInput() {
uploadInputRef.value?.click();
}
function triggerImageInput() {
imageInputRef.value?.click();
}
//
async function handleFileSelect(event: Event) {
//
async function handleUploadSelect(event: Event) {
const input = event.target as HTMLInputElement;
const files = input.files;
if (!files) return;
for (const file of files) {
await addFileAsAttachment(file, "file");
}
input.value = "";
}
async function handleImageSelect(event: Event) {
const input = event.target as HTMLInputElement;
const files = input.files;
if (!files) return;
for (const file of files) {
await addFileAsAttachment(file, "image");
const type = file.type.startsWith("image/") ? "image" : "file";
await addFileAsAttachment(file, type);
}
input.value = "";
@ -400,21 +382,28 @@ async function uploadFileToServer(id: string, file: File) {
}
//
async function removeAttachment(id: string) {
const index = attachments.value.findIndex((a) => a.id === id);
if (index === -1) return;
const attachment = attachments.value[index];
// OSS blob URL OSS
if (attachment.url && !attachment.url.startsWith("blob:")) {
try {
await chatApi.deleteAttachment(attachment.url);
} catch (error) {
console.error("删除 OSS 文件失败:", error);
// 使
}
}
async function removeAttachment(id: string | number) {
const targetId = String(id);
const index = attachments.value.findIndex((a) => a.id === targetId);
if (index === -1) return;
const attachment = attachments.value[index];
let deletedFromOss = false;
// OSS blob URL OSS
if (attachment.url && !attachment.url.startsWith("blob:")) {
try {
attachment.deleting = true;
await nextTick();
await chatApi.deleteAttachment(attachment.url);
deletedFromOss = true;
} catch (error) {
console.error("删除 OSS 文件失败:", error);
// 使
} finally {
attachment.deleting = false;
}
}
// blob URL
if (attachment.url.startsWith("blob:")) {
@ -422,6 +411,10 @@ async function removeAttachment(id: string) {
}
attachments.value.splice(index, 1);
if (deletedFromOss) {
window.$toast?.("OSS 文件删除成功", "success");
}
}
//
@ -448,13 +441,6 @@ function toggleWebSearch() {
localStorage.setItem("isWebSearch", String(isWebSearch.value));
}
function toggleExpand() {
isExpanded.value = !isExpanded.value;
nextTick(() => {
autoResize();
});
}
//
function focus() {
textareaRef.value?.focus();
@ -510,7 +496,12 @@ onMounted(() => {
.chat-input-container {
background: #F8F9FA;
// 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;
@ -532,11 +523,11 @@ onMounted(() => {
}
.input-area {
position: relative;
display: flex;
align-items: flex-start;
justify-content: flex-start;
gap: 8px;
padding: 12px 16px;
}
.input-actions {
@ -544,6 +535,7 @@ onMounted(() => {
align-items: center;
gap: 4px;
padding-bottom: 4px;
min-width: 0;
&.left {
flex-shrink: 0;
@ -661,7 +653,6 @@ onMounted(() => {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
border-top: 1px solid #f3f4f6;
// background: #fafbfc;
@ -723,6 +714,27 @@ onMounted(() => {
}
}
.upload-action-div {
display: grid;
place-items: center;
border-radius: 0;
width: 88px;
height: 118px;
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

@ -0,0 +1,194 @@
<script setup lang="ts">
import { computed } from "vue";
type UploadAction = "file" | "image";
interface ActionCard {
id: UploadAction;
title: string;
description: string;
icon: string;
color: string;
disabled: boolean;
}
const props = withDefaults(
defineProps<{
supportsFiles?: boolean;
supportsVision?: boolean;
}>(),
{
supportsFiles: true,
supportsVision: true,
},
);
const emit = defineEmits<{
file: [];
image: [];
}>();
const cards = computed<ActionCard[]>(() => [
{
id: "file",
title: "附件",
description: props.supportsFiles ? "上传文档 / 压缩包" : "当前模型不支持附件",
icon: "📎",
color: "#06b6d4",
disabled: !props.supportsFiles,
},
{
id: "image",
title: "图片",
description: props.supportsVision ? "上传图片 / 截图" : "当前模型不支持图片",
icon: "🖼️",
color: "#8b5cf6",
disabled: !props.supportsVision,
},
]);
function handleClick(action: UploadAction) {
if (action === "file" && props.supportsFiles) {
emit("file");
}
if (action === "image" && props.supportsVision) {
emit("image");
}
}
</script>
<template>
<div class="upload-card-group" aria-label="上传入口">
<button
type="button"
v-for="(card, index) in cards"
:key="card.id"
class="upload-card"
:class="{ disabled: card.disabled, [`card-${card.id}`]: true }"
:style="{
'--card-color': card.color,
'--card-border': `${card.color}33`,
'--card-glow': `${card.color}40`,
'--card-glow-fade': `${card.color}14`,
'--card-offset': `${index * 14}px`,
zIndex: cards.length - index,
}"
:disabled="card.disabled"
:title="card.description"
@click="handleClick(card.id)"
>
<span class="card-glow" />
<span class="card-icon">{{ card.icon }}</span>
<span class="card-copy">
<span class="card-title">{{ card.title }}</span>
<span class="card-desc">{{ card.description }}</span>
</span>
</button>
</div>
</template>
<style scoped lang="scss">
.upload-card-group {
position: relative;
display: flex;
align-items: center;
justify-content: flex-start;
min-width: 164px;
height: 44px;
}
.upload-card {
position: absolute;
left: var(--card-offset);
top: 0;
display: flex;
align-items: center;
gap: 10px;
width: 138px;
height: 44px;
padding: 0 12px;
border: 1px solid var(--card-border);
border-radius: 999px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.96), rgba(245, 247, 250, 0.96));
box-shadow: 0 10px 20px rgba(15, 23, 42, 0.08);
color: #1f2937;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease, border-color 0.2s ease;
overflow: hidden;
}
.upload-card:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 14px 24px rgba(15, 23, 42, 0.12);
}
.upload-card.disabled,
.upload-card:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.card-glow {
position: absolute;
inset: -20%;
background: radial-gradient(circle at left center, var(--card-glow), transparent 58%);
opacity: 0.45;
pointer-events: none;
}
.card-icon {
position: relative;
z-index: 1;
flex-shrink: 0;
font-size: 18px;
}
.card-copy {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
min-width: 0;
text-align: left;
}
.card-title {
font-size: 13px;
font-weight: 700;
line-height: 1.1;
}
.card-desc {
margin-top: 2px;
font-size: 11px;
line-height: 1.2;
color: #6b7280;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dark {
.upload-card {
background: linear-gradient(135deg, rgba(30, 30, 46, 0.98), rgba(24, 24, 37, 0.98));
color: #f3f4f6;
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.22);
}
.card-desc {
color: #9ca3af;
}
}
@media (max-width: 640px) {
.upload-card-group {
min-width: 0;
height: 40px;
}
.upload-card {
width: 126px;
height: 40px;
}
}
</style>

View File

@ -1,28 +1,56 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { NModal, NImage } from 'naive-ui'
export interface CardItem {
id: string | number
title: string
title?: string
name?: string
description?: string
icon?: string
color?: string
url?: string
thumbnail?: string
type?: 'image' | 'file' | 'video' | string
size?: number
mimeType?: string
uploading?: boolean
deleting?: boolean
}
interface Props {
cards: CardItem[]
maxVisible?: number
spreadGap?: number
supportsFiles?: boolean
supportsVision?: boolean
}
const props = withDefaults(defineProps<Props>(), {
maxVisible: 5,
spreadGap: 190
spreadGap: 190,
supportsFiles: true,
supportsVision: true,
})
const emit = defineEmits<{
remove: [id: string | number]
'add-upload': []
}>()
const isExpanded = ref(false)
const isWrapperHovered = ref(false)
const hoveredCardId = ref<string | number | null>(null)
const containerRef = ref<HTMLElement | null>(null)
const previewCard = ref<CardItem | null>(null)
const isPreviewVisible = computed({
get: () => !!previewCard.value,
set: (value: boolean) => {
if (!value) {
closePreview()
}
},
})
const CARD_SCALE = 0.5
// -
const defaultColors = [
@ -34,12 +62,126 @@ const defaultColors = [
'#ec4899', // pink
]
function formatFileSize(size?: number) {
if (!size || size <= 0) return ''
const units = ['B', 'KB', 'MB', 'GB']
let value = size
let unitIndex = 0
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024
unitIndex += 1
}
const display = value >= 10 || unitIndex === 0 ? Math.round(value) : value.toFixed(1)
return `${display} ${units[unitIndex]}`
}
function getCardTitle(card: CardItem, index: number) {
return card.title || card.name || `Card ${index + 1}`
}
function getCardDescription(card: CardItem) {
if (card.description) return card.description
const labels: Record<string, string> = {
image: '图片',
file: '文件',
video: '视频',
}
const kind = labels[card.type || ''] || '附件'
const size = formatFileSize(card.size)
if (card.uploading) {
return size ? `${kind} · 上传中 · ${size}` : `${kind} · 上传中`
}
if (card.deleting) {
return size ? `${kind} · 删除中 · ${size}` : `${kind} · 删除中`
}
return size ? `${kind} · ${size}` : kind
}
function getCardIcon(card: CardItem) {
if (card.icon) return card.icon
const icons: Record<string, string> = {
image: '🖼️',
file: '📎',
video: '🎬',
}
return icons[card.type || ''] || '📎'
}
function getCardColor(card: CardItem, index: number) {
if (card.color) return card.color
const colors: Record<string, string> = {
image: 'white',
file: 'white',
video: 'white',
}
return colors[card.type || ''] || defaultColors[index % defaultColors.length]
}
function getCardImageUrl(card: CardItem) {
if (card.type === 'image') {
return card.url || card.thumbnail || ''
}
return card.thumbnail || ''
}
function openPreview(card: CardItem) {
if (card.type !== 'image') return
const imageUrl = getCardImageUrl(card)
if (!imageUrl) return
previewCard.value = card
}
function closePreview() {
previewCard.value = null
}
function removePreviewCard() {
closePreview()
}
function removeCard(card: CardItem) {
emit('remove', card.id)
}
function expandCards() {
if (!isExpanded.value) {
isExpanded.value = true
}
}
function handleCardClick(card: CardItem) {
if (card.deleting) return
if (!isExpanded.value) {
expandCards()
return
}
if (card.type === 'image') {
openPreview(card)
}
}
const cardStyle = computed(() => (index: number, total: number) => {
const color = props.cards[index]?.color || defaultColors[index % defaultColors.length]
const color = getCardColor(props.cards[index], index)
const isCardHovered = hoveredCardId.value === props.cards[index]?.id
const isCardDeleting = !!props.cards[index]?.deleting
const hoverBorderColor = isCardHovered ? '#000F33' : color
// - index 0
const stackOffset = 16 //
const stackOffset = 16 * CARD_SCALE //
const stackX = -index * stackOffset
const stackZIndex = total - index
@ -48,23 +190,23 @@ const cardStyle = computed(() => (index: number, total: number) => {
transform: `translateX(${stackX}px)`,
zIndex: stackZIndex,
opacity: index < props.maxVisible ? 1 : 0,
borderColor: color,
boxShadow: `0 4px 20px rgba(0, 0, 0, 0.3), 0 0 0 1px ${color}20`
'--card-border-color': color,
'--card-hover-border-color': hoverBorderColor,
cursor: isCardDeleting ? 'wait' : 'pointer',
}
}
// -
const spreadX = (total - 1 - index) * props.spreadGap
const spreadX = (total - 1 - index) * props.spreadGap * CARD_SCALE
const zIndexValue = isCardHovered ? 100 : total - index
return {
transform: `translateX(${spreadX}px) scale(${isCardHovered ? 1.05 : 1})`,
zIndex: zIndexValue,
zIndex: isCardDeleting ? 101 : zIndexValue,
opacity: 1,
borderColor: color,
boxShadow: isCardHovered
? `0 20px 40px rgba(0, 0, 0, 0.4), 0 0 30px ${color}40, 0 0 0 1px ${color}`
: `0 8px 30px rgba(0, 0, 0, 0.3), 0 0 0 1px ${color}30`
'--card-border-color': color,
'--card-hover-border-color': hoverBorderColor,
cursor: isCardDeleting ? 'wait' : 'pointer',
}
})
@ -95,51 +237,85 @@ onUnmounted(() => {
</script>
<template>
<div ref="containerRef" class="stacked-cards-container">
<div class="cards-wrapper">
<div 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)"
@mouseenter="hoveredCardId = card.id"
@mouseleave="hoveredCardId = null"
>
<div class="card-glow" :style="{ background: card.color || defaultColors[index % defaultColors.length] }" />
<div class="card-content">
<div v-if="card.icon" class="card-icon">
{{ card.icon }}
<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>
</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>
<h3 class="card-title">{{ card.title }}</h3>
<p v-if="card.description" class="card-description">
{{ card.description }}
</p>
</div>
<div class="card-accent" :style="{ background: card.color || defaultColors[index % defaultColors.length] }" />
<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>
</TransitionGroup>
</div>
<!-- 提示文字 -->
<Transition name="hint-fade">
<div v-if="!isExpanded" class="hover-hint">
<span class="hint-icon"></span>
<span>点击展开</span>
<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' }"
:style="{ width: '100vw', height: '100vh', maxWidth: '100vw', margin: '0', padding: '0', borderRadius: '0', background: 'rgba(5, 7, 12, 0.96)' }">
<div class="image-preview-shell">
<NImage v-if="previewCard" :src="getCardImageUrl(previewCard)" :alt="getCardTitle(previewCard, 0)"
object-fit="contain" preview-disabled :img-props="{
style: {
width: '100%',
height: '100%',
maxWidth: '100%',
maxHeight: '100%',
},
}" class="image-preview-content" />
</div>
</NModal>
</div>
</template>
<style scoped>
/* TODO: 等待优化边框样式 */
.stacked-cards-container {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 280px;
padding: 2rem;
perspective: 1000px;
}
@ -148,87 +324,247 @@ onUnmounted(() => {
display: flex;
align-items: center;
justify-content: center;
min-width: 180px;
height: 260px;
min-width: 90px;
height: 130px;
}
.card {
position: absolute;
width: 180px;
height: 240px;
background: linear-gradient(145deg, #1a1a2e 0%, #16162a 100%);
border-radius: 16px;
border: 1px solid transparent;
width: 90px;
height: 120px;
border-radius: 5px;
border: 1px solid var(--card-border-color, var(--ffffff, #FFF));
background: url(<path-to-image>) lightgray 50% / cover no-repeat;
cursor: pointer;
overflow: hidden;
transition: transform 0.35s ease-out, box-shadow 0.35s ease-out, opacity 0.35s ease-out;
will-change: transform, box-shadow;
transition: transform 0.35s ease-out, opacity 0.35s ease-out;
will-change: transform, opacity;
}
/* 卡片发光效果 */
.card-glow {
.card-media,
.card-preview {
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
opacity: 0;
filter: blur(60px);
transition: opacity 0.35s ease-out;
pointer-events: none;
inset: 0;
z-index: 0;
}
.card-media {
cursor: default;
}
.card-media-uploading {
position: absolute;
inset: 0;
z-index: 3;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.3rem;
background: rgba(2, 6, 23, 0.48);
backdrop-filter: blur(2px);
}
.card-media-deleting {
position: absolute;
inset: 0;
z-index: 3;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.3rem;
background: rgba(127, 29, 29, 0.42);
backdrop-filter: blur(2px);
}
.card-media-uploading-spinner {
width: 14px;
height: 14px;
border-radius: 999px;
border: 1.5px solid rgba(255, 255, 255, 0.22);
border-top-color: #ffffff;
animation: card-spin 0.8s linear infinite;
}
.card-media-deleting-spinner {
width: 14px;
height: 14px;
border-radius: 999px;
border: 1.5px solid rgba(255, 255, 255, 0.22);
border-top-color: #fecaca;
animation: card-spin 0.8s linear infinite;
}
.card-media-uploading-text {
color: #fff;
font-size: 0.42rem;
letter-spacing: 0.05em;
font-family: 'JetBrains Mono', monospace;
}
.card-media-deleting-text {
color: #fff;
font-size: 0.42rem;
letter-spacing: 0.05em;
font-family: 'JetBrains Mono', monospace;
}
.card-media-overlay {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
align-items: flex-end;
justify-content: flex-end;
padding: 0.4rem;
border: 0;
background: linear-gradient(180deg, rgba(10, 10, 15, 0.02) 0%, rgba(10, 10, 15, 0.15) 40%, rgba(10, 10, 15, 0.68) 100%);
cursor: zoom-in;
}
.card-media-overlay-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 14px;
padding: 0 4px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.38);
color: #f8fafc;
font-family: 'JetBrains Mono', monospace;
font-size: 0.35rem;
letter-spacing: 0.04em;
}
.card-delete-btn {
position: absolute;
top: 0.35rem;
right: 0.35rem;
z-index: 4;
display: inline-flex;
align-items: center;
justify-content: center;
width: 0.95rem;
height: 0.95rem;
padding: 0;
border: 0;
border-radius: 999px;
background: rgba(239, 68, 68, 0.9);
color: #fff;
font-size: 0.55rem;
line-height: 1;
cursor: pointer;
backdrop-filter: blur(10px);
transition: transform 0.2s ease, opacity 0.2s ease, background 0.2s ease;
}
.card-delete-btn:hover {
transform: scale(1.08);
background: rgba(220, 38, 38, 0.98);
}
.card-media img,
.card-preview-image img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.card-preview {
display: flex;
align-items: flex-start;
justify-content: center;
padding: 0.9rem 0.9rem 0;
}
.card-preview-image {
position: relative;
width: 100%;
height: 44px;
border-radius: 6px;
overflow: hidden;
}
.card-preview-fallback {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -42%);
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 9px;
background: linear-gradient(145deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.02));
border: 1px solid rgba(255, 255, 255, 0.06);
}
.card-preview-deleting {
position: absolute;
inset: 0;
z-index: 3;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.3rem;
background: rgba(127, 29, 29, 0.38);
backdrop-filter: blur(2px);
border-radius: 9px;
}
.card-preview-deleting-spinner {
width: 14px;
height: 14px;
border-radius: 999px;
border: 1.5px solid rgba(255, 255, 255, 0.22);
border-top-color: #fecaca;
animation: card-spin 0.8s linear infinite;
}
.card-preview-deleting-text {
color: #fff;
font-size: 0.42rem;
letter-spacing: 0.05em;
font-family: 'JetBrains Mono', monospace;
}
.card-preview-fallback .card-icon {
margin: 0;
font-size: 0.95rem;
}
.card:hover .card-glow {
opacity: 0.15;
}
.stacked-cards-container.is-expanded .card:hover {
border-color: var(--card-hover-border-color, "#000F33");
}
.stacked-cards-container.is-expanded .card:hover .card-glow {
opacity: 0.26;
}
/* 卡片底部强调线 */
.card-accent {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
height: 2px;
opacity: 0.8;
}
.card-content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
height: 100%;
padding: 1.25rem;
color: #e4e4e7;
}
.card-icon {
font-size: 2rem;
margin-bottom: 0.75rem;
}
.card-title {
font-family: 'Space Grotesk', 'JetBrains Mono', system-ui, sans-serif;
font-size: 1.1rem;
font-weight: 600;
letter-spacing: 0.02em;
margin: 0 0 0.5rem;
background: linear-gradient(135deg, #fff 0%, #a1a1aa 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.card-description {
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
line-height: 1.6;
color: #71717a;
margin: 0;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
font-size: 1rem;
margin-bottom: 0.375rem;
}
/* 展开动画 */
@ -248,13 +584,13 @@ onUnmounted(() => {
.hover-hint {
position: absolute;
left: 50%;
bottom: -1.5rem;
bottom: -0.75rem;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 0.5rem;
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
font-size: 0.4rem;
color: #52525b;
letter-spacing: 0.05em;
}
@ -265,16 +601,29 @@ onUnmounted(() => {
}
@keyframes pulse-glow {
0%, 100% {
0%,
100% {
opacity: 0.6;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.2);
}
}
@keyframes card-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.hint-fade-enter-active,
.hint-fade-leave-active {
transition: opacity 0.25s ease-out;
@ -285,29 +634,153 @@ 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 {
display: inline-flex;
align-items: center;
height: 19px;
padding: 0 7px;
border-radius: 999px;
background: rgba(255, 255, 255);
color: #e4e4e7;
cursor: pointer;
transition: transform 0.2s ease, opacity 0.2s ease, border-color 0.2s ease;
}
.upload-action-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 {
opacity: 0.42;
cursor: not-allowed;
}
.upload-action-icon {
font-size: 8px;
line-height: 1;
}
.upload-action-text {
font-family: 'JetBrains Mono', monospace;
font-size: 0.4rem;
letter-spacing: 0.04em;
}
.upload-actions-fade-enter-active,
.upload-actions-fade-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.upload-actions-fade-enter-from,
.upload-actions-fade-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(6px);
}
.image-preview-modal :deep(.n-card) {
background: transparent;
width: 100vw;
height: 100vh;
margin: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.image-preview-modal :deep(.n-card__content) {
flex: 1;
min-width: 0;
min-height: 0;
width: 100%;
height: 100%;
padding: 0;
overflow: hidden;
display: flex;
}
.image-preview-shell {
position: relative;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.image-preview-content {
flex: 1;
width: 100%;
height: 100%;
max-width: calc(100vw - 48px);
max-height: calc(100vh - 48px);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
z-index: 1;
}
.image-preview-content :deep(img) {
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
object-fit: contain;
display: block;
}
/* 响应式 */
@media (max-width: 640px) {
.stacked-cards-container {
min-height: 240px;
padding: 1.5rem;
min-height: 120px;
padding: 0.75rem;
}
.cards-wrapper {
width: 160px;
height: 220px;
width: 80px;
height: 110px;
}
.card {
width: 150px;
height: 200px;
width: 75px;
height: 100px;
}
.card-content {
padding: 1rem;
.card-preview-image {
height: 36px;
}
.card-title {
font-size: 1rem;
.upload-actions {
bottom: 0.75rem;
gap: 0.25rem;
}
.upload-action-btn {
height: 18px;
padding: 0 6px;
}
}
</style>
</style>

View File

@ -1,157 +0,0 @@
<script setup lang="ts">
import StackedCards from '@/components/ui/StackedCards.vue'
import { ref } from 'vue'
const demoCards = ref([
{
id: 1,
title: '设计系统',
description: '构建一致性的视觉语言和组件库',
icon: '🎨',
color: '#06b6d4'
},
{
id: 2,
title: '开发工具',
description: '高效的开发工作流和自动化',
icon: '⚡',
color: '#8b5cf6'
},
{
id: 3,
title: '性能优化',
description: '极速加载和流畅交互体验',
icon: '🚀',
color: '#22c55e'
},
{
id: 4,
title: '安全防护',
description: '企业级安全保障和数据保护',
icon: '🛡️',
color: '#f59e0b'
},
{
id: 5,
title: '数据分析',
description: '深度洞察和智能决策支持',
icon: '📊',
color: '#ef4444'
}
])
</script>
<template>
<div class="demo-page">
<div class="demo-container">
<h1 class="page-title">层叠卡片组</h1>
<p class="page-subtitle">悬停展开 · 视觉发现 · 触感交互</p>
<div class="demo-section">
<StackedCards :cards="demoCards" />
</div>
<div class="info-section">
<div class="info-card">
<h3>使用方式</h3>
<pre><code>&lt;StackedCards
:cards="cardList"
:max-visible="5"
:spread-gap="190"
/&gt;</code></pre>
</div>
<div class="info-card">
<h3>卡片数据结构</h3>
<pre><code>interface CardItem {
id: string | number
title: string
description?: string
icon?: string
color?: string
}</code></pre>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.demo-page {
min-height: 100vh;
background: linear-gradient(180deg, #0a0a0f 0%, #0d0d14 50%, #0a0a0f 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.demo-container {
max-width: 800px;
width: 100%;
text-align: center;
}
.page-title {
font-family: 'Space Grotesk', 'JetBrains Mono', system-ui, sans-serif;
font-size: clamp(2rem, 5vw, 3rem);
font-weight: 700;
letter-spacing: -0.02em;
background: linear-gradient(135deg, #fff 0%, #a1a1aa 50%, #06b6d4 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin: 0 0 0.5rem;
}
.page-subtitle {
font-family: 'JetBrains Mono', monospace;
font-size: 0.875rem;
color: #52525b;
letter-spacing: 0.1em;
margin: 0 0 3rem;
}
.demo-section {
display: flex;
justify-content: center;
margin-bottom: 4rem;
}
.info-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
text-align: left;
}
.info-card {
background: linear-gradient(145deg, #16162a 0%, #12121f 100%);
border: 1px solid #27272a;
border-radius: 12px;
padding: 1.5rem;
}
.info-card h3 {
font-family: 'Space Grotesk', system-ui, sans-serif;
font-size: 1rem;
font-weight: 600;
color: #e4e4e7;
margin: 0 0 1rem;
}
.info-card pre {
background: #0a0a0f;
border-radius: 8px;
padding: 1rem;
margin: 0;
overflow-x: auto;
}
.info-card code {
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
line-height: 1.6;
color: #a1a1aa;
}
</style>

View File

@ -15,11 +15,6 @@ const router = createRouter({
component: () => import('@/views/ShareView.vue'),
alias: ['/chat-ui/share/:id'],
},
{
path: '/demo/cards',
name: 'stacked-cards-demo',
component: () => import('@/components/ui/StackedCardsDemo.vue'),
},
],
})