feat: 样式升级

This commit is contained in:
2026-03-16 23:54:01 +08:00
parent 110fe62404
commit 4a5fdd3961
42 changed files with 1931 additions and 1404 deletions

View File

@@ -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);
}
/* 淡入淡出动画 */

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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);
}
/* 骨架屏样式 */

View File

@@ -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 {

View File

@@ -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
}
}

View File

@@ -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 />
支持格式视频MP4MOVAVI等图片JPGPNGGIF等音频MP3WAV等
</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 />
支持格式视频MP4MOVAVI等图片JPGPNGGIF等音频MP3WAV等
</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 {