feat: 样式升级
This commit is contained in:
@@ -115,13 +115,13 @@ onUnmounted(() => {
|
||||
|
||||
.spinner-ring:nth-child(1) {
|
||||
animation-delay: -0.45s;
|
||||
border-top-color: var(--color-primary, #3B82F6);
|
||||
border-top-color: var(--primary);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.spinner-ring:nth-child(2) {
|
||||
animation-delay: -0.3s;
|
||||
border-top-color: rgba(59, 130, 246, 0.8);
|
||||
border-top-color: oklch(from var(--primary) l c h / 0.8);
|
||||
opacity: 0.8;
|
||||
width: 80%;
|
||||
height: 80%;
|
||||
@@ -131,7 +131,7 @@ onUnmounted(() => {
|
||||
|
||||
.spinner-ring:nth-child(3) {
|
||||
animation-delay: -0.15s;
|
||||
border-top-color: rgba(59, 130, 246, 0.6);
|
||||
border-top-color: oklch(from var(--primary) l c h / 0.6);
|
||||
opacity: 0.6;
|
||||
width: 60%;
|
||||
height: 60%;
|
||||
@@ -141,7 +141,7 @@ onUnmounted(() => {
|
||||
|
||||
.spinner-ring:nth-child(4) {
|
||||
animation-delay: 0s;
|
||||
border-top-color: rgba(59, 130, 246, 0.4);
|
||||
border-top-color: oklch(from var(--primary) l c h / 0.4);
|
||||
opacity: 0.4;
|
||||
width: 40%;
|
||||
height: 40%;
|
||||
@@ -159,11 +159,11 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: var(--color-text, #F2F2F2);
|
||||
font-size: 14px;
|
||||
color: var(--foreground);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
margin-top: 8px;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
/* 淡入淡出动画 */
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
@click="handleClick"
|
||||
>
|
||||
<span v-if="loading" class="gradient-button__loading">
|
||||
<a-spin size="small" />
|
||||
<span class="gradient-button__spinner"></span>
|
||||
<span v-if="loadingText" class="gradient-button__loading-text">{{ loadingText }}</span>
|
||||
</span>
|
||||
<span v-else class="gradient-button__content">
|
||||
@@ -99,7 +99,7 @@ const buttonClass = computed(() => {
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 500;
|
||||
color: #ffffff;
|
||||
color: var(--primary-foreground);
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-fast);
|
||||
background: var(--color-gray-900);
|
||||
@@ -189,6 +189,22 @@ const buttonClass = computed(() => {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.gradient-button__spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid oklch(1 0 0 / 0.3);
|
||||
border-top-color: var(--primary-foreground);
|
||||
border-radius: 50%;
|
||||
animation: gradient-button-spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes gradient-button-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.gradient-button--large {
|
||||
|
||||
@@ -62,31 +62,31 @@ const displayText = computed(() => {
|
||||
.points-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
gap: var(--space-1);
|
||||
border-radius: var(--radius-tag);
|
||||
font-weight: 500;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
white-space: nowrap;
|
||||
|
||||
&--small {
|
||||
padding: 1px 6px;
|
||||
font-size: 11px;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
&--default {
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
padding: 2px var(--space-2);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
&--large {
|
||||
padding: 4px 12px;
|
||||
font-size: 14px;
|
||||
padding: var(--space-1) var(--space-3);
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
&--free {
|
||||
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
|
||||
background: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<h3>生成的数字人视频</h3>
|
||||
<video :src="previewVideoUrl" controls class="generated-video"></video>
|
||||
<div class="video-actions">
|
||||
<a-button type="primary" @click="downloadVideo">下载视频</a-button>
|
||||
<Button @click="downloadVideo">下载视频</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -18,7 +18,8 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getDigitalHumanTask } from '@/api/digitalHuman'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -52,7 +53,7 @@ const loadLastTask = async () => {
|
||||
}
|
||||
|
||||
const downloadVideo = () => {
|
||||
if (!previewVideoUrl.value) return message.warning('没有可下载的视频')
|
||||
if (!previewVideoUrl.value) return toast.warning('没有可下载的视频')
|
||||
const link = document.createElement('a')
|
||||
link.href = previewVideoUrl.value
|
||||
link.download = `数字人视频_${Date.now()}.mp4`
|
||||
@@ -71,9 +72,9 @@ onMounted(async () => {
|
||||
|
||||
<style scoped>
|
||||
.result-panel {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
background: var(--muted);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.result-placeholder {
|
||||
@@ -81,31 +82,31 @@ onMounted(async () => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #94a3b8;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.result-content {
|
||||
color: #fff;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.result-section {
|
||||
margin-bottom: 24px;
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.result-section h3 {
|
||||
margin-bottom: 12px;
|
||||
font-size: 18px;
|
||||
margin-bottom: var(--space-3);
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.generated-video {
|
||||
width: 100%;
|
||||
max-height: 400px;
|
||||
border-radius: 8px;
|
||||
margin-top: 12px;
|
||||
border-radius: var(--radius);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.video-actions {
|
||||
margin-top: 16px;
|
||||
margin-top: var(--space-4);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ defineExpose({
|
||||
.option-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.option-avatar {
|
||||
@@ -148,18 +148,18 @@ defineExpose({
|
||||
}
|
||||
|
||||
.option-tag {
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #3b82f6;
|
||||
font-size: var(--font-size-xs);
|
||||
padding: 1px var(--space-1-5);
|
||||
border-radius: var(--radius);
|
||||
background: oklch(from var(--primary) l c h / 0.1);
|
||||
color: var(--primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
padding: 8px 12px;
|
||||
color: var(--color-gray-400);
|
||||
font-size: 13px;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--font-size-sm);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -16,14 +16,15 @@ const shouldShowUser = computed(() => {
|
||||
<template>
|
||||
<header
|
||||
class="fixed top-0 left-0 right-0 z-[100]
|
||||
h-[70px] flex items-center px-[30px]
|
||||
bg-gray-900 text-white
|
||||
border-b border-border"
|
||||
flex items-center px-[30px]
|
||||
bg-sidebar border-b border-sidebar-border
|
||||
text-sidebar-foreground"
|
||||
:style="{ height: 'var(--header-height)' }"
|
||||
>
|
||||
<div class="flex items-center gap-3 flex-1">
|
||||
<div class="flex items-center gap-md flex-1">
|
||||
<BrandLogo :size="40" />
|
||||
</div>
|
||||
<div class="flex items-center gap-4 pr-1">
|
||||
<div class="flex items-center gap-md pr-1">
|
||||
<UserDropdown v-if="shouldShowUser" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -50,12 +50,12 @@ async function handleLogout() {
|
||||
<template>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
class="group flex items-center gap-3 rounded-full px-3 py-1.5 pl-1.5
|
||||
bg-white/5 border border-white/10
|
||||
class="group flex items-center gap-md rounded-full px-md py-1.5 pl-1.5
|
||||
bg-muted border border-border
|
||||
cursor-pointer outline-none
|
||||
transition-all duration-250 ease-out
|
||||
hover:bg-white/10 hover:border-white/20 hover:-translate-y-0.5
|
||||
data-[state=open]:bg-white/10 data-[state=open]:border-white/20"
|
||||
hover:bg-accent hover:border-border hover:-translate-y-0.5
|
||||
data-[state=open]:bg-accent data-[state=open]:border-border"
|
||||
>
|
||||
<!-- 头像容器 -->
|
||||
<div class="relative w-9 h-9">
|
||||
@@ -66,13 +66,13 @@ async function handleLogout() {
|
||||
style="background: conic-gradient(from 0deg, rgba(59, 130, 246, 0.8), rgba(99, 102, 241, 0.8), rgba(139, 92, 246, 0.8), rgba(59, 130, 246, 0.8))"
|
||||
/>
|
||||
<!-- 头像背景遮罩 -->
|
||||
<div class="absolute inset-0 rounded-full bg-gray-900 -z-5" />
|
||||
<div class="absolute inset-0 rounded-full bg-background -z-5" />
|
||||
|
||||
<!-- 头像 -->
|
||||
<Avatar class="w-9 h-9 relative z-10">
|
||||
<AvatarImage v-if="userStore.displayAvatar" :src="userStore.displayAvatar" alt="avatar" />
|
||||
<AvatarFallback
|
||||
class="flex items-center justify-center text-white font-bold text-[15px]"
|
||||
class="flex items-center justify-center text-primary-foreground font-bold text-[15px]"
|
||||
:style="{ background: avatarGradient }"
|
||||
>
|
||||
{{ userStore.displayName?.charAt(0)?.toUpperCase() || 'U' }}
|
||||
@@ -81,21 +81,21 @@ async function handleLogout() {
|
||||
|
||||
<!-- 在线状态点 -->
|
||||
<div
|
||||
class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-green-500
|
||||
border-2 border-gray-900 z-20
|
||||
shadow-[0_0_0_2px_rgba(34,197,94,0.3)]"
|
||||
class="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-success
|
||||
border-2 border-background z-20
|
||||
shadow-[0_0_0_2px_oklch(from_var(--success)_l_c_h_/_0.3)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 用户名 -->
|
||||
<span class="text-sm font-medium text-white truncate max-w-[100px]">
|
||||
<span class="text-sm font-medium text-foreground truncate max-w-[100px]">
|
||||
{{ userStore.displayName || '用户' }}
|
||||
</span>
|
||||
|
||||
<!-- 下拉箭头 -->
|
||||
<Icon
|
||||
icon="lucide:chevron-down"
|
||||
class="w-4 h-4 text-white/50 shrink-0 transition-transform duration-250
|
||||
class="w-4 h-4 text-muted-foreground shrink-0 transition-transform duration-250
|
||||
group-hover:rotate-180 group-data-[state=open]:rotate-180"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -61,12 +61,12 @@ const handleClose = () => {
|
||||
.video-preview-dialog {
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-container {
|
||||
background: #000;
|
||||
background: oklch(0 0 0);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -372,25 +372,25 @@ watch(() => props.open, (isOpen) => {
|
||||
|
||||
/* 视频卡片样式 */
|
||||
.video-card {
|
||||
background: white;
|
||||
background: var(--card);
|
||||
border: 2px solid transparent;
|
||||
border-radius: 8px;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
transition: all var(--duration-fast);
|
||||
box-shadow: var(--shadow-sm);
|
||||
text-align: left;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.video-card:hover {
|
||||
border-color: var(--color-primary-400);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
|
||||
border-color: var(--primary);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.video-card.selected {
|
||||
border-color: var(--color-primary-500);
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.25);
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 2px oklch(from var(--primary) l c h / 0.25);
|
||||
}
|
||||
|
||||
/* 缩略图样式 */
|
||||
@@ -410,24 +410,24 @@ watch(() => props.open, (isOpen) => {
|
||||
|
||||
.video-duration {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: #fff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
bottom: var(--space-2);
|
||||
right: var(--space-2);
|
||||
background: oklch(0 0 0 / 0.8);
|
||||
color: var(--primary-foreground);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.video-selected-mark {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
top: var(--space-2);
|
||||
right: var(--space-2);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: var(--color-primary-500);
|
||||
color: #fff;
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -441,16 +441,16 @@ watch(() => props.open, (isOpen) => {
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
transition: opacity var(--duration-fast);
|
||||
cursor: pointer;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
background: oklch(0 0 0 / 0.5);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.video-thumbnail:hover .video-play-btn {
|
||||
@@ -459,14 +459,14 @@ watch(() => props.open, (isOpen) => {
|
||||
|
||||
/* 信息区域样式 */
|
||||
.video-info {
|
||||
padding: 14px;
|
||||
padding: var(--space-3-5);
|
||||
}
|
||||
|
||||
.video-title {
|
||||
font-size: 14px;
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 600;
|
||||
color: var(--color-gray-900);
|
||||
margin-bottom: 10px;
|
||||
color: var(--foreground);
|
||||
margin-bottom: var(--space-2-5);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -474,15 +474,15 @@ watch(() => props.open, (isOpen) => {
|
||||
|
||||
.video-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--color-gray-500);
|
||||
gap: var(--space-3);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
/* 空状态样式 */
|
||||
@@ -533,20 +533,20 @@ watch(() => props.open, (isOpen) => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 0;
|
||||
border-top: 1px solid var(--color-gray-200);
|
||||
padding: var(--space-4) 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* 底部操作栏样式 */
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid var(--color-gray-200);
|
||||
background: var(--color-gray-50);
|
||||
border-radius: 8px;
|
||||
margin-top: 8px;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--muted);
|
||||
border-radius: var(--radius);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
/* 骨架屏样式 */
|
||||
|
||||
@@ -337,14 +337,14 @@ onBeforeUnmount(() => {
|
||||
|
||||
/* 空状态 */
|
||||
.empty-voices {
|
||||
padding: 40px 24px;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
border: 1px dashed #e2e8f0;
|
||||
border-radius: 16px;
|
||||
padding: var(--space-10) var(--space-6);
|
||||
background: var(--muted);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
text-align: center;
|
||||
|
||||
.empty-icon {
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,7 +352,7 @@ onBeforeUnmount(() => {
|
||||
.voice-selector-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
/* 标题栏 */
|
||||
@@ -360,49 +360,49 @@ onBeforeUnmount(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
padding-bottom: var(--space-3);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: var(--space-2-5);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 15px;
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.voice-count {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
background: #f1f5f9;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--muted-foreground);
|
||||
background: var(--muted);
|
||||
padding: var(--space-0-5) var(--space-2);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.synthesize-btn {
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
border-radius: 8px;
|
||||
padding: 0 var(--space-3-5);
|
||||
border-radius: var(--radius);
|
||||
border: none;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
color: white;
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.25);
|
||||
transition: all 0.2s ease;
|
||||
font-size: var(--font-size-sm);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: all var(--duration-fast);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.35);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #94a3b8;
|
||||
background: var(--muted-foreground);
|
||||
box-shadow: none;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
@@ -437,25 +437,25 @@ onBeforeUnmount(() => {
|
||||
/* 音色卡片 */
|
||||
.voice-card {
|
||||
position: relative;
|
||||
background: white;
|
||||
border: 2px solid #f1f5f9;
|
||||
border-radius: 14px;
|
||||
padding: 16px 12px;
|
||||
background: var(--card);
|
||||
border: 2px solid var(--muted);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4) var(--space-3);
|
||||
cursor: pointer;
|
||||
transition: all 0.25s ease;
|
||||
transition: all var(--duration-base);
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
border-color: #e2e8f0;
|
||||
border-color: var(--border);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.06);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: #3b82f6;
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
|
||||
box-shadow: 0 4px 16px rgba(59, 130, 246, 0.15);
|
||||
border-color: var(--primary);
|
||||
background: oklch(from var(--primary) l c h / 0.1);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,17 +464,17 @@ onBeforeUnmount(() => {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin: 0 auto 10px;
|
||||
margin: 0 auto var(--space-2-5);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.avatar-ring {
|
||||
background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
|
||||
background: oklch(from var(--primary) l c h / 0.2);
|
||||
}
|
||||
.avatar-icon {
|
||||
color: #3b82f6;
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -500,15 +500,15 @@ onBeforeUnmount(() => {
|
||||
right: -4px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: #3b82f6;
|
||||
background: var(--primary);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
box-shadow: 0 2px 6px rgba(59, 130, 246, 0.4);
|
||||
animation: scaleIn 0.2s ease;
|
||||
color: var(--primary-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
box-shadow: var(--shadow-sm);
|
||||
animation: scaleIn var(--duration-fast);
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
@@ -526,18 +526,18 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.voice-name {
|
||||
font-size: 13px;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin-bottom: 4px;
|
||||
color: var(--foreground);
|
||||
margin-bottom: var(--space-1);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.voice-desc {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--muted-foreground);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -545,29 +545,29 @@ onBeforeUnmount(() => {
|
||||
|
||||
/* 播放器区域 */
|
||||
.player-section {
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: var(--muted);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-3-5);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.player-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.player-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: var(--space-2-5);
|
||||
}
|
||||
|
||||
.player-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: #3b82f6;
|
||||
color: var(--primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -575,49 +575,49 @@ onBeforeUnmount(() => {
|
||||
|
||||
.player-meta {
|
||||
.player-title {
|
||||
font-size: 14px;
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
color: var(--foreground);
|
||||
}
|
||||
.player-label {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
padding: var(--space-1) var(--space-2-5);
|
||||
height: auto;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all var(--duration-fast);
|
||||
|
||||
&:hover {
|
||||
color: #3b82f6;
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
color: var(--primary);
|
||||
background: oklch(from var(--primary) l c h / 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.aplayer-container {
|
||||
:deep(.aplayer) {
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: none;
|
||||
border: 1px solid rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid var(--border);
|
||||
|
||||
.aplayer-body {
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 动画 */
|
||||
.slide-fade-enter-active {
|
||||
transition: all 0.3s ease-out;
|
||||
transition: all var(--duration-base) ease-out;
|
||||
}
|
||||
|
||||
.slide-fade-leave-active {
|
||||
transition: all 0.2s ease-in;
|
||||
transition: all var(--duration-fast) ease-in;
|
||||
}
|
||||
|
||||
.slide-fade-enter-from {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<div class="header-left">
|
||||
<div class="agent-avatar">
|
||||
<img v-if="agent?.avatar" :src="agent?.avatar" :alt="agent?.name" />
|
||||
<RobotOutlined v-else class="avatar-fallback" />
|
||||
<Icon v-else icon="lucide:bot" class="avatar-fallback" />
|
||||
</div>
|
||||
<div class="agent-info">
|
||||
<h3 class="agent-name">{{ agent?.name || 'AI 助手' }}</h3>
|
||||
@@ -20,10 +20,10 @@
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="history-btn" @click="openHistory" title="历史记录">
|
||||
<HistoryOutlined />
|
||||
<Icon icon="lucide:history" />
|
||||
</button>
|
||||
<button class="close-btn" @click="handleClose">
|
||||
<CloseOutlined />
|
||||
<Icon icon="lucide:x" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -37,18 +37,18 @@
|
||||
<div class="visual-ring delay-1"></div>
|
||||
<div class="visual-ring delay-2"></div>
|
||||
<div class="visual-core">
|
||||
<RobotOutlined class="core-icon" />
|
||||
<Icon icon="lucide:bot" class="core-icon" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="empty-title">准备好为你生成内容</h3>
|
||||
<p class="empty-desc">在下方输入框描述你的需求,AI 将立即开始创作</p>
|
||||
<div class="empty-features">
|
||||
<div class="feature-item">
|
||||
<ThunderboltFilled class="feature-icon" />
|
||||
<Icon icon="lucide:zap" class="feature-icon" />
|
||||
<span>深度模式支持复杂任务</span>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<EditOutlined class="feature-icon" />
|
||||
<Icon icon="lucide:pencil" class="feature-icon" />
|
||||
<span>可随时重新生成调整</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -60,7 +60,7 @@
|
||||
<div class="prompt-display">
|
||||
<p class="prompt-text">{{ currentInput }}</p>
|
||||
<button v-if="generatedContent" class="prompt-edit" @click="resetTask">
|
||||
<EditOutlined />
|
||||
<Icon icon="lucide:pencil" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -84,10 +84,10 @@
|
||||
<!-- Action Bar -->
|
||||
<div v-if="generatedContent && !isGenerating" class="action-bar">
|
||||
<button class="action-btn primary" @click="handleCopy(generatedContent)">
|
||||
<CopyOutlined /> 复制
|
||||
<Icon icon="lucide:copy" /> 复制
|
||||
</button>
|
||||
<button class="action-btn" @click="handleRegenerate">
|
||||
<ReloadOutlined /> 重新生成
|
||||
<Icon icon="lucide:refresh-cw" /> 重新生成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -111,7 +111,7 @@
|
||||
:class="{ active: modelMode === 'pro' }"
|
||||
@click="modelMode = 'pro'"
|
||||
>
|
||||
<ThunderboltFilled />
|
||||
<Icon icon="lucide:zap" />
|
||||
深度
|
||||
<PointsTag :points="50" size="small" />
|
||||
</button>
|
||||
@@ -136,8 +136,8 @@
|
||||
:disabled="!inputText.trim() || isGenerating"
|
||||
@click="handleGenerate"
|
||||
>
|
||||
<LoadingOutlined v-if="isGenerating" class="spin" />
|
||||
<SendOutlined v-else />
|
||||
<Icon v-if="isGenerating" icon="lucide:loader-2" class="spin" />
|
||||
<Icon v-else icon="lucide:send" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -151,6 +151,22 @@
|
||||
:agent-id="agent?.id"
|
||||
@close="closeHistory"
|
||||
/>
|
||||
|
||||
<!-- Close Confirm Dialog -->
|
||||
<AlertDialog :open="showCloseConfirm" @update:open="showCloseConfirm = $event">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>关闭将丢失生成的内容</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
建议先复制内容,确定要关闭吗?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel @click="showCloseConfirm = false">取消</AlertDialogCancel>
|
||||
<AlertDialogAction @click="confirmClose">关闭</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</transition>
|
||||
</teleport>
|
||||
@@ -158,18 +174,18 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, nextTick, onMounted } from 'vue'
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import {
|
||||
CloseOutlined,
|
||||
RobotOutlined,
|
||||
CopyOutlined,
|
||||
ReloadOutlined,
|
||||
ThunderboltFilled,
|
||||
EditOutlined,
|
||||
SendOutlined,
|
||||
LoadingOutlined,
|
||||
HistoryOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { sendChatStream } from '@/api/agent'
|
||||
import { copyToClipboard } from '@/utils/clipboard'
|
||||
import HistoryPanel from './HistoryPanel.vue'
|
||||
@@ -205,29 +221,29 @@ const abortController = ref(null)
|
||||
// History State
|
||||
const historyVisible = ref(false)
|
||||
|
||||
// Confirm Close Dialog State
|
||||
const showCloseConfirm = ref(false)
|
||||
|
||||
// Methods
|
||||
const handleClose = (force = false) => {
|
||||
// 正在生成时,不允许关闭
|
||||
if (isGenerating.value) {
|
||||
message.warning('正在生成内容,请稍候...')
|
||||
toast.warning('正在生成内容,请稍候...')
|
||||
return
|
||||
}
|
||||
// 有生成内容时,提示用户
|
||||
if (!force && generatedContent.value) {
|
||||
Modal.confirm({
|
||||
title: '关闭将丢失生成的内容',
|
||||
content: '建议先复制内容,确定要关闭吗?',
|
||||
okText: '关闭',
|
||||
cancelText: '取消',
|
||||
onOk: () => {
|
||||
doClose()
|
||||
}
|
||||
})
|
||||
showCloseConfirm.value = true
|
||||
return
|
||||
}
|
||||
doClose()
|
||||
}
|
||||
|
||||
const confirmClose = () => {
|
||||
showCloseConfirm.value = false
|
||||
doClose()
|
||||
}
|
||||
|
||||
const doClose = () => {
|
||||
if (abortController.value) {
|
||||
abortController.value.abort()
|
||||
@@ -273,7 +289,7 @@ const handleKeyDown = (e) => {
|
||||
|
||||
const handleCopy = async (content) => {
|
||||
const success = await copyToClipboard(content)
|
||||
success ? message.success('已复制') : message.error('复制失败')
|
||||
success ? toast.success('已复制') : toast.error('复制失败')
|
||||
}
|
||||
|
||||
// 执行流式请求
|
||||
@@ -291,11 +307,11 @@ const executeStreamRequest = async (prompt) => {
|
||||
generatedContent.value += result.content
|
||||
scrollToBottom()
|
||||
} else if (result.event === 'error') {
|
||||
message.error(result.errorMessage || '生成出错')
|
||||
toast.error(result.errorMessage || '生成出错')
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
message.error('生成失败')
|
||||
toast.error('生成失败')
|
||||
if (!generatedContent.value) isGenerating.value = false
|
||||
},
|
||||
onClose: () => {
|
||||
@@ -304,7 +320,7 @@ const executeStreamRequest = async (prompt) => {
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
if (error.name !== 'AbortError') message.error('生成失败')
|
||||
if (error.name !== 'AbortError') toast.error('生成失败')
|
||||
isGenerating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,149 +1,147 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:visible="visible"
|
||||
title="上传素材"
|
||||
:width="600"
|
||||
:footer="false"
|
||||
:closable="!uploading"
|
||||
:maskClosable="!uploading"
|
||||
@cancel="handleCancel"
|
||||
@update:visible="handleVisibleChange"
|
||||
>
|
||||
<div class="upload-modal-content">
|
||||
<!-- 文件上传区域 -->
|
||||
<div class="upload-area" v-if="!uploading">
|
||||
<a-upload-dragger
|
||||
v-model:file-list="fileList"
|
||||
name="file"
|
||||
:multiple="true"
|
||||
:accept="acceptTypes"
|
||||
action=""
|
||||
:before-upload="handleBeforeUpload"
|
||||
:show-upload-list="false"
|
||||
@change="handleFileChange"
|
||||
>
|
||||
<p class="ant-upload-drag-icon">
|
||||
<UploadOutlined />
|
||||
</p>
|
||||
<p class="ant-upload-text">点击或拖拽文件到此处上传</p>
|
||||
<p class="ant-upload-hint">
|
||||
支持多文件上传,单文件最大 1GB
|
||||
<br />
|
||||
支持格式:视频(MP4、MOV、AVI等)、图片(JPG、PNG、GIF等)、音频(MP3、WAV等)
|
||||
</p>
|
||||
</a-upload-dragger>
|
||||
</div>
|
||||
<Dialog :open="props.visible" @update:open="handleVisibleChange">
|
||||
<DialogContent class="max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>上传素材</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<!-- 已选文件列表 -->
|
||||
<div v-if="fileList.length > 0 && !uploading" class="upload-file-list">
|
||||
<div class="upload-file-list-title">
|
||||
已选择 {{ fileList.length }} 个文件,总大小:{{ getTotalSize() }}
|
||||
<span v-if="getTotalSizeBytes() > MAX_TOTAL_SIZE" style="color: #ff4d4f; margin-left: 8px;">
|
||||
(已超出 1GB 限制)
|
||||
</span>
|
||||
</div>
|
||||
<div class="upload-file-items">
|
||||
<div class="upload-modal-content">
|
||||
<!-- 文件上传区域 -->
|
||||
<div class="upload-area" v-if="!uploading">
|
||||
<div
|
||||
v-for="(fileItem, index) in fileList"
|
||||
:key="fileItem.uid || index"
|
||||
class="upload-file-item"
|
||||
class="upload-dragger"
|
||||
:class="{ 'drag-over': isDragOver }"
|
||||
@click="triggerFileInput"
|
||||
@drop.prevent="handleDrop"
|
||||
@dragover.prevent="isDragOver = true"
|
||||
@dragleave.prevent="isDragOver = false"
|
||||
>
|
||||
<FileOutlined class="file-icon" />
|
||||
<span class="file-name">{{ getFileName(fileItem) }}</span>
|
||||
<span class="file-size">{{ formatFileSize(getFileSize(fileItem)) }}</span>
|
||||
<a-button
|
||||
type="text"
|
||||
status="danger"
|
||||
size="small"
|
||||
@click="handleRemove(fileItem)"
|
||||
>
|
||||
删除
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 上传进度区域 -->
|
||||
<div v-if="uploading" class="upload-progress-area">
|
||||
<div class="upload-progress-header">
|
||||
<LoadingOutlined spin class="upload-spinner" />
|
||||
<span>正在上传 {{ currentUploadIndex }} / {{ totalUploadCount }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 总体进度 -->
|
||||
<div class="upload-total-progress">
|
||||
<div class="progress-info">
|
||||
<span>总体进度</span>
|
||||
<span>{{ totalProgress }}%</span>
|
||||
</div>
|
||||
<a-progress
|
||||
:percent="totalProgress"
|
||||
:show-info="false"
|
||||
:stroke-color="{
|
||||
'0%': '#3B82F6',
|
||||
'100%': '#10B981'
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 当前文件进度 -->
|
||||
<div class="upload-current-file">
|
||||
<div class="current-file-name">
|
||||
<FileOutlined />
|
||||
<span>{{ currentFileName }}</span>
|
||||
</div>
|
||||
<div class="current-file-progress">
|
||||
<div class="progress-info">
|
||||
<span>{{ formatFileSize(currentFileUploaded) }} / {{ formatFileSize(currentFileSize) }}</span>
|
||||
<span>{{ currentFileProgress }}%</span>
|
||||
</div>
|
||||
<a-progress
|
||||
:percent="currentFileProgress"
|
||||
:show-info="false"
|
||||
size="small"
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
multiple
|
||||
:accept="acceptTypes"
|
||||
class="hidden"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
<div class="upload-icon">
|
||||
<Icon icon="lucide:cloud-upload" class="text-4xl text-blue-500" />
|
||||
</div>
|
||||
<p class="upload-text">点击或拖拽文件到此处上传</p>
|
||||
<p class="upload-hint">
|
||||
支持多文件上传,单文件最大 1GB
|
||||
<br />
|
||||
支持格式:视频(MP4、MOV、AVI等)、图片(JPG、PNG、GIF等)、音频(MP3、WAV等)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 已完成文件列表 -->
|
||||
<div v-if="completedFiles.length > 0" class="completed-files">
|
||||
<div class="completed-header">
|
||||
<CheckCircleOutlined style="color: #10B981" />
|
||||
<span>已完成 {{ completedFiles.length }} 个文件</span>
|
||||
<!-- 已选文件列表 -->
|
||||
<div v-if="fileList.length > 0 && !uploading" class="upload-file-list">
|
||||
<div class="upload-file-list-title">
|
||||
已选择 {{ fileList.length }} 个文件,总大小:{{ getTotalSize() }}
|
||||
<span v-if="getTotalSizeBytes() > MAX_TOTAL_SIZE" class="text-red-500 ml-2">
|
||||
(已超出 1GB 限制)
|
||||
</span>
|
||||
</div>
|
||||
<div class="completed-list">
|
||||
<div v-for="file in completedFiles" :key="file.name" class="completed-item">
|
||||
<CheckOutlined style="color: #10B981; font-size: 12px" />
|
||||
<span>{{ file.name }}</span>
|
||||
<div class="upload-file-items">
|
||||
<div
|
||||
v-for="(fileItem, index) in fileList"
|
||||
:key="fileItem.uid || index"
|
||||
class="upload-file-item"
|
||||
>
|
||||
<Icon icon="lucide:file" class="file-icon" />
|
||||
<span class="file-name">{{ getFileName(fileItem) }}</span>
|
||||
<span class="file-size">{{ formatFileSize(getFileSize(fileItem)) }}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-red-500 hover:text-red-600"
|
||||
@click="handleRemove(fileItem)"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="upload-actions">
|
||||
<a-space>
|
||||
<a-button v-if="!uploading" @click="handleCancel">取消</a-button>
|
||||
<a-button
|
||||
v-if="!uploading"
|
||||
type="primary"
|
||||
:loading="uploading"
|
||||
:disabled="fileList.length === 0"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
{{ uploading ? '上传中...' : `上传 (${fileList.length})` }}
|
||||
</a-button>
|
||||
<span v-else class="upload-tip">上传中请勿关闭窗口...</span>
|
||||
</a-space>
|
||||
<!-- 上传进度区域 -->
|
||||
<div v-if="uploading" class="upload-progress-area">
|
||||
<div class="upload-progress-header">
|
||||
<span class="custom-spinner"></span>
|
||||
<span>正在上传 {{ currentUploadIndex }} / {{ totalUploadCount }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 总体进度 -->
|
||||
<div class="upload-total-progress">
|
||||
<div class="progress-info">
|
||||
<span>总体进度</span>
|
||||
<span>{{ totalProgress }}%</span>
|
||||
</div>
|
||||
<Progress :value="totalProgress" class="h-2" />
|
||||
</div>
|
||||
|
||||
<!-- 当前文件进度 -->
|
||||
<div class="upload-current-file">
|
||||
<div class="current-file-name">
|
||||
<Icon icon="lucide:file" />
|
||||
<span>{{ currentFileName }}</span>
|
||||
</div>
|
||||
<div class="current-file-progress">
|
||||
<div class="progress-info">
|
||||
<span>{{ formatFileSize(currentFileUploaded) }} / {{ formatFileSize(currentFileSize) }}</span>
|
||||
<span>{{ currentFileProgress }}%</span>
|
||||
</div>
|
||||
<Progress :value="currentFileProgress" class="h-1.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 已完成文件列表 -->
|
||||
<div v-if="completedFiles.length > 0" class="completed-files">
|
||||
<div class="completed-header">
|
||||
<Icon icon="lucide:check-circle" class="text-green-500" />
|
||||
<span>已完成 {{ completedFiles.length }} 个文件</span>
|
||||
</div>
|
||||
<div class="completed-list">
|
||||
<div v-for="file in completedFiles" :key="file.name" class="completed-item">
|
||||
<Icon icon="lucide:check" class="text-green-500 text-xs" />
|
||||
<span>{{ file.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="upload-actions">
|
||||
<div class="flex gap-2 justify-end">
|
||||
<Button v-if="!uploading" variant="outline" @click="handleCancel">取消</Button>
|
||||
<Button
|
||||
v-if="!uploading"
|
||||
:disabled="fileList.length === 0"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
{{ uploading ? '上传中...' : `上传 (${fileList.length})` }}
|
||||
</Button>
|
||||
<span v-if="uploading" class="upload-tip">上传中请勿关闭窗口...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { UploadOutlined, FileOutlined, LoadingOutlined, CheckCircleOutlined, CheckOutlined } from '@ant-design/icons-vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { Icon } from '@iconify/vue'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { getFileName, getFileSize, formatFileSize } from '@/utils/file'
|
||||
import { useUpload } from '@/composables/useUpload'
|
||||
|
||||
@@ -170,6 +168,8 @@ const { upload } = useUpload()
|
||||
// 数据
|
||||
const fileList = ref([])
|
||||
const acceptTypes = 'video/*,image/*,audio/*,.mp4,.mov,.avi,.mkv,.jpg,.jpeg,.png,.gif,.webp,.mp3,.wav,.aac'
|
||||
const isDragOver = ref(false)
|
||||
const fileInputRef = ref(null)
|
||||
|
||||
// 常量
|
||||
const MAX_TOTAL_SIZE = 1 * 1024 * 1024 * 1024 // 1GB
|
||||
@@ -205,6 +205,69 @@ const getTotalSize = () => {
|
||||
return formatFileSize(getTotalSizeBytes())
|
||||
}
|
||||
|
||||
// 触发文件选择
|
||||
const triggerFileInput = () => {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
// 处理文件选择
|
||||
const handleFileSelect = (e) => {
|
||||
const files = e.target.files
|
||||
if (files) {
|
||||
addFiles(Array.from(files))
|
||||
}
|
||||
// 重置 input 以便可以再次选择相同文件
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
// 处理拖放
|
||||
const handleDrop = (e) => {
|
||||
isDragOver.value = false
|
||||
const files = e.dataTransfer?.files
|
||||
if (files) {
|
||||
addFiles(Array.from(files))
|
||||
}
|
||||
}
|
||||
|
||||
// 添加文件
|
||||
const addFiles = (files) => {
|
||||
files.forEach(file => {
|
||||
// 检查单个文件大小
|
||||
if (file.size > MAX_SINGLE_FILE_SIZE) {
|
||||
toast.warning(`文件 ${file.name} 超过 1GB,已跳过`)
|
||||
return
|
||||
}
|
||||
|
||||
// 计算当前文件列表的总大小(包括新文件)
|
||||
const currentTotalSize = getTotalSizeBytes()
|
||||
const newTotalSize = currentTotalSize + file.size
|
||||
|
||||
// 检查总大小
|
||||
if (newTotalSize > MAX_TOTAL_SIZE) {
|
||||
toast.warning(`文件总大小超过 1GB(当前:${formatFileSize(currentTotalSize)},新增:${formatFileSize(file.size)}),已跳过`)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否已存在相同文件
|
||||
const exists = fileList.value.some(item => {
|
||||
const itemName = getFileName(item)
|
||||
const itemSize = getFileSize(item)
|
||||
return itemName === file.name && itemSize === file.size
|
||||
})
|
||||
if (exists) {
|
||||
toast.warning(`文件 ${file.name} 已存在,已跳过`)
|
||||
return
|
||||
}
|
||||
|
||||
fileList.value.push({
|
||||
uid: `${Date.now()}-${Math.random()}`,
|
||||
file,
|
||||
name: file.name,
|
||||
size: file.size
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 监听 visible 变化,重置文件列表
|
||||
watch(() => props.visible, (newVal) => {
|
||||
if (!newVal) {
|
||||
@@ -225,54 +288,6 @@ const resetUploadState = () => {
|
||||
completedFiles.value = []
|
||||
}
|
||||
|
||||
// 上传前处理
|
||||
const handleBeforeUpload = (file) => {
|
||||
// 检查单个文件大小
|
||||
if (file.size > MAX_SINGLE_FILE_SIZE) {
|
||||
message.warning(`文件 ${file.name} 超过 1GB,已跳过`)
|
||||
return false
|
||||
}
|
||||
|
||||
// 计算当前文件列表的总大小(包括新文件)
|
||||
const currentTotalSize = getTotalSizeBytes()
|
||||
const newTotalSize = currentTotalSize + file.size
|
||||
|
||||
// 检查总大小
|
||||
if (newTotalSize > MAX_TOTAL_SIZE) {
|
||||
message.warning(`文件总大小超过 1GB(当前:${formatFileSize(currentTotalSize)},新增:${formatFileSize(file.size)}),已跳过`)
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查是否已存在相同文件
|
||||
const exists = fileList.value.some(item => {
|
||||
const itemName = getFileName(item)
|
||||
const itemSize = getFileSize(item)
|
||||
return itemName === file.name && itemSize === file.size
|
||||
})
|
||||
if (exists) {
|
||||
message.warning(`文件 ${file.name} 已存在,已跳过`)
|
||||
return false
|
||||
}
|
||||
|
||||
return false // 阻止自动上传
|
||||
}
|
||||
|
||||
// 文件列表变化
|
||||
const handleFileChange = (info) => {
|
||||
const { file, fileList: newFileList } = info
|
||||
|
||||
if (file && file.status !== 'uploading') {
|
||||
fileList.value = newFileList
|
||||
.map(item => {
|
||||
if (!item.file && item.originFileObj) {
|
||||
item.file = item.originFileObj
|
||||
}
|
||||
return item
|
||||
})
|
||||
.filter(item => item.status !== 'removed')
|
||||
}
|
||||
}
|
||||
|
||||
// 移除文件
|
||||
const handleRemove = (fileItem) => {
|
||||
const index = fileList.value.findIndex(item =>
|
||||
@@ -287,13 +302,13 @@ const handleRemove = (fileItem) => {
|
||||
// 确认上传
|
||||
const handleConfirm = async () => {
|
||||
if (fileList.value.length === 0) {
|
||||
message.warning('请选择文件')
|
||||
toast.warning('请选择文件')
|
||||
return
|
||||
}
|
||||
|
||||
// 检查总大小
|
||||
if (getTotalSizeBytes() > MAX_TOTAL_SIZE) {
|
||||
message.warning(`文件总大小超过 1GB 限制,请移除部分文件`)
|
||||
toast.warning(`文件总大小超过 1GB 限制,请移除部分文件`)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -307,7 +322,7 @@ const handleConfirm = async () => {
|
||||
.filter(item => item !== null)
|
||||
|
||||
if (files.length === 0) {
|
||||
message.error('无法获取文件对象,请重新选择文件')
|
||||
toast.error('无法获取文件对象,请重新选择文件')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -374,11 +389,11 @@ const performUpload = async (files) => {
|
||||
|
||||
// 显示结果
|
||||
if (successCount > 0 && failCount === 0) {
|
||||
message.success(`成功上传 ${successCount} 个文件`)
|
||||
toast.success(`成功上传 ${successCount} 个文件`)
|
||||
} else if (successCount > 0 && failCount > 0) {
|
||||
message.warning(`上传完成:${successCount} 个成功,${failCount} 个失败`)
|
||||
toast.warning(`上传完成:${successCount} 个成功,${failCount} 个失败`)
|
||||
} else {
|
||||
message.error('上传失败,请重试')
|
||||
toast.error('上传失败,请重试')
|
||||
}
|
||||
|
||||
// 通知父组件上传完成
|
||||
@@ -390,6 +405,7 @@ const performUpload = async (files) => {
|
||||
|
||||
// 处理 visible 变化
|
||||
const handleVisibleChange = (value) => {
|
||||
if (!value && uploading.value) return
|
||||
emit('update:visible', value)
|
||||
}
|
||||
|
||||
@@ -401,7 +417,7 @@ const handleCancel = () => {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
<style scoped lang="less">
|
||||
.upload-modal-content {
|
||||
padding: 8px 0;
|
||||
}
|
||||
@@ -410,6 +426,46 @@ const handleCancel = () => {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.upload-dragger {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
border: 2px dashed var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover, &.drag-over {
|
||||
border-color: #3B82F6;
|
||||
background: rgba(59, 130, 246, 0.05);
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.upload-file-list {
|
||||
margin-bottom: 24px;
|
||||
padding: 16px;
|
||||
@@ -492,9 +548,17 @@ const handleCancel = () => {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.upload-spinner {
|
||||
font-size: 20px;
|
||||
color: #3B82F6;
|
||||
.custom-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid rgba(59, 130, 246, 0.2);
|
||||
border-top-color: #3B82F6;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.upload-total-progress {
|
||||
|
||||
Reference in New Issue
Block a user