feat(stacked-cards): 重做 StackedCards 组件并完善附件交互
This commit is contained in:
parent
ecdb0db49e
commit
9b75000841
|
|
@ -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%;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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%,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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><StackedCards
|
||||
:cards="cardList"
|
||||
:max-visible="5"
|
||||
:spread-gap="190"
|
||||
/></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>
|
||||
|
|
@ -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'),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue