Compare commits
3 Commits
2c8664b41e
...
7f5d9d9f19
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f5d9d9f19 | |||
| aa06782953 | |||
| 7b32191987 |
@@ -36,6 +36,7 @@
|
||||
"tailwindcss": "^4.2.1",
|
||||
"vue": "^3.5.22",
|
||||
"vue-router": "^4.5.1",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"xlsx-js-style": "^1.2.0"
|
||||
},
|
||||
|
||||
246
frontend/app/web-gold/src/components/BrandLogo.vue
Normal file
246
frontend/app/web-gold/src/components/BrandLogo.vue
Normal file
@@ -0,0 +1,246 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 牟野品牌 Logo
|
||||
* 设计理念:Neo-Organic Tech(新有机科技)
|
||||
* 融合自然有机形态与科技几何,体现"野"的生机与"道"的智慧
|
||||
*/
|
||||
defineProps({
|
||||
size: {
|
||||
type: Number,
|
||||
default: 36
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="brand-logo" :style="{ '--logo-size': `${size}px` }">
|
||||
<svg
|
||||
class="logo-icon"
|
||||
viewBox="0 0 48 48"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<defs>
|
||||
<!-- 主色渐变:金色流光 -->
|
||||
<linearGradient id="brandGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#F59E0B" />
|
||||
<stop offset="50%" stop-color="#FBBF24" />
|
||||
<stop offset="100%" stop-color="#F59E0B" />
|
||||
</linearGradient>
|
||||
|
||||
<!-- 辅助渐变:深金 -->
|
||||
<linearGradient id="brandGradientDark" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#D97706" />
|
||||
<stop offset="100%" stop-color="#F59E0B" />
|
||||
</linearGradient>
|
||||
|
||||
<!-- 光晕效果 -->
|
||||
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="1.5" result="coloredBlur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
|
||||
<!-- 内阴影 -->
|
||||
<filter id="innerShadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feOffset dx="0" dy="1"/>
|
||||
<feGaussianBlur stdDeviation="0.5"/>
|
||||
<feComposite operator="out" in="SourceGraphic"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.3 0"/>
|
||||
<feBlend mode="normal" in2="SourceGraphic"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- 背景圆 - 深色基底 -->
|
||||
<circle cx="24" cy="24" r="22" fill="url(#brandGradientDark)" opacity="0.15"/>
|
||||
|
||||
<!-- 核心图形:抽象的"野"字 + 山野轮廓 -->
|
||||
<g class="logo-core" filter="url(#glow)">
|
||||
<!-- 左侧山峰 - 代表"野"的山野意象 -->
|
||||
<path
|
||||
class="peak peak-left"
|
||||
d="M12 32 L18 18 L24 28"
|
||||
stroke="url(#brandGradient)"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
|
||||
<!-- 右侧山峰 - 呼应平衡 -->
|
||||
<path
|
||||
class="peak peak-right"
|
||||
d="M24 28 L30 16 L36 32"
|
||||
stroke="url(#brandGradient)"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
|
||||
<!-- 中间连接 - 代表"道"的路径 -->
|
||||
<path
|
||||
class="path-line"
|
||||
d="M18 24 Q24 20 30 24"
|
||||
stroke="url(#brandGradient)"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
fill="none"
|
||||
/>
|
||||
|
||||
<!-- 底部流动线 - 数据流/AI智能 -->
|
||||
<path
|
||||
class="flow-line"
|
||||
d="M14 36 Q24 32 34 36"
|
||||
stroke="url(#brandGradient)"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
fill="none"
|
||||
opacity="0.7"
|
||||
/>
|
||||
|
||||
<!-- 数据节点 -->
|
||||
<circle class="node node-1" cx="18" cy="18" r="2" fill="url(#brandGradient)"/>
|
||||
<circle class="node node-2" cx="30" cy="16" r="2" fill="url(#brandGradient)"/>
|
||||
<circle class="node node-3" cx="24" cy="28" r="2.5" fill="url(#brandGradient)" filter="url(#innerShadow)"/>
|
||||
</g>
|
||||
|
||||
<!-- 能量环 - 科技感装饰 -->
|
||||
<circle
|
||||
class="energy-ring"
|
||||
cx="24"
|
||||
cy="24"
|
||||
r="20"
|
||||
stroke="url(#brandGradient)"
|
||||
stroke-width="0.5"
|
||||
fill="none"
|
||||
opacity="0.4"
|
||||
stroke-dasharray="4 6"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.brand-logo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
transition: transform 200ms ease;
|
||||
}
|
||||
|
||||
.brand-logo:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: var(--logo-size);
|
||||
height: var(--logo-size);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 能量环旋转动画 */
|
||||
@keyframes ringRotate {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.energy-ring {
|
||||
transform-origin: center;
|
||||
animation: ringRotate 20s linear infinite;
|
||||
}
|
||||
|
||||
/* 节点脉冲动画 */
|
||||
@keyframes nodePulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
.node {
|
||||
transform-origin: center;
|
||||
transition: all 300ms ease;
|
||||
}
|
||||
|
||||
.brand-logo:hover .node-1 {
|
||||
animation: nodePulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.brand-logo:hover .node-2 {
|
||||
animation: nodePulse 1.5s ease-in-out infinite 0.3s;
|
||||
}
|
||||
|
||||
.brand-logo:hover .node-3 {
|
||||
animation: nodePulse 1.5s ease-in-out infinite 0.6s;
|
||||
}
|
||||
|
||||
/* 山峰绘制动画 */
|
||||
@keyframes drawPeak {
|
||||
from { stroke-dashoffset: 30; }
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
|
||||
.peak {
|
||||
stroke-dasharray: 30;
|
||||
stroke-dashoffset: 0;
|
||||
transition: all 300ms ease;
|
||||
}
|
||||
|
||||
.brand-logo:hover .peak-left {
|
||||
animation: drawPeak 0.6s ease-out;
|
||||
}
|
||||
|
||||
.brand-logo:hover .peak-right {
|
||||
animation: drawPeak 0.6s ease-out 0.15s;
|
||||
}
|
||||
|
||||
/* 路径流动动画 */
|
||||
@keyframes pathFlow {
|
||||
0% { stroke-dashoffset: 20; }
|
||||
100% { stroke-dashoffset: 0; }
|
||||
}
|
||||
|
||||
.path-line {
|
||||
stroke-dasharray: 5 3;
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
|
||||
.brand-logo:hover .path-line {
|
||||
animation: pathFlow 1s linear infinite;
|
||||
}
|
||||
|
||||
/* 底部流线波动 */
|
||||
@keyframes flowWave {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-1px); }
|
||||
}
|
||||
|
||||
.flow-line {
|
||||
transition: all 300ms ease;
|
||||
}
|
||||
|
||||
.brand-logo:hover .flow-line {
|
||||
animation: flowWave 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Logo 整体光晕 */
|
||||
@keyframes logoGlow {
|
||||
0%, 100% {
|
||||
filter: drop-shadow(0 0 3px rgba(245, 158, 11, 0.4));
|
||||
}
|
||||
50% {
|
||||
filter: drop-shadow(0 0 8px rgba(245, 158, 11, 0.7));
|
||||
}
|
||||
}
|
||||
|
||||
.brand-logo:hover .logo-core {
|
||||
animation: logoGlow 2s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -66,7 +66,12 @@ const remainingPercent = computed(() => {
|
||||
>
|
||||
<div class="user-card">
|
||||
<div class="user-card__mobile">{{ maskedMobile }}</div>
|
||||
<div class="user-card__quota">剩余额度 {{ remainingPercent }}%</div>
|
||||
<div class="user-card__quota">
|
||||
<span>剩余额度 {{ userStore.remainingPoints }} 点</span>
|
||||
<div class="quota-progress">
|
||||
<div class="quota-progress__bar" :style="{ width: remainingPercent + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</aside>
|
||||
@@ -187,4 +192,33 @@ const remainingPercent = computed(() => {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.quota-progress {
|
||||
margin-top: 6px;
|
||||
height: 6px;
|
||||
background: var(--color-gray-100);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.quota-progress__bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #3b82f6, #60a5fa);
|
||||
border-radius: 3px;
|
||||
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
box-shadow: 0 1px 3px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.quota-progress__bar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 50%;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.3), transparent);
|
||||
border-radius: 3px 3px 0 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import UserDropdown from '@/components/UserDropdown.vue'
|
||||
import BrandLogo from '@/components/BrandLogo.vue'
|
||||
|
||||
const styles = {
|
||||
background: 'var(--color-gray-900)',
|
||||
@@ -22,7 +23,7 @@ const shouldShowUser = computed(() => {
|
||||
<div>
|
||||
<div class="h-[70px] flex items-center">
|
||||
<div class="flex items-center gap-3 flex-1 pl-[30px]">
|
||||
<!-- 左侧可放 logo 或其他内容 -->
|
||||
<BrandLogo :size="40" />
|
||||
</div>
|
||||
<div class="flex items-center gap-4 pr-[35px]">
|
||||
<template v-if="shouldShowUser">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { UserOutlined, LogoutOutlined } from '@ant-design/icons-vue'
|
||||
@@ -6,6 +7,25 @@ import { UserOutlined, LogoutOutlined } from '@ant-design/icons-vue'
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 根据用户名生成稳定的渐变色 - 使用更协调的蓝金配色
|
||||
const avatarGradient = computed(() => {
|
||||
const name = userStore.displayName || 'User'
|
||||
const gradients = [
|
||||
'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
|
||||
'linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%)',
|
||||
'linear-gradient(135deg, #6366f1 0%, #4f46e5 100%)',
|
||||
'linear-gradient(135deg, #14b8a6 0%, #0d9488 100%)',
|
||||
'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
|
||||
'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)'
|
||||
]
|
||||
// 根据用户名生成稳定的索引
|
||||
let hash = 0
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash)
|
||||
}
|
||||
return gradients[Math.abs(hash) % gradients.length]
|
||||
})
|
||||
|
||||
const handleMenuClick = ({ key }) => {
|
||||
if (key === 'profile') {
|
||||
router.push('/user/profile')
|
||||
@@ -26,20 +46,35 @@ async function handleLogout() {
|
||||
|
||||
<template>
|
||||
<a-dropdown placement="bottomRight" :trigger="['hover']">
|
||||
<div class="user-avatar-container">
|
||||
<img
|
||||
v-if="userStore.displayAvatar"
|
||||
class="user-avatar"
|
||||
:src="userStore.displayAvatar"
|
||||
alt="avatar"
|
||||
/>
|
||||
<div v-else class="user-avatar-placeholder">
|
||||
{{ userStore.displayName?.charAt(0) || 'U' }}
|
||||
<div class="user-trigger">
|
||||
<div class="user-avatar-ring">
|
||||
<div class="user-avatar-wrapper">
|
||||
<img
|
||||
v-if="userStore.displayAvatar"
|
||||
class="user-avatar"
|
||||
:src="userStore.displayAvatar"
|
||||
alt="avatar"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="user-avatar-placeholder"
|
||||
:style="{ background: avatarGradient }"
|
||||
>
|
||||
<span class="avatar-text">{{ userStore.displayName?.charAt(0)?.toUpperCase() || 'U' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-dot"></div>
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<span class="user-name">{{ userStore.displayName || '用户' }}</span>
|
||||
</div>
|
||||
<svg class="dropdown-arrow" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
|
||||
<template #overlay>
|
||||
<a-menu @click="handleMenuClick">
|
||||
<a-menu class="user-menu" @click="handleMenuClick">
|
||||
<a-menu-item key="profile">
|
||||
<template #icon>
|
||||
<UserOutlined />
|
||||
@@ -58,44 +93,130 @@ async function handleLogout() {
|
||||
</a-dropdown>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.user-avatar-container {
|
||||
cursor: pointer;
|
||||
<style scoped lang="less">
|
||||
.user-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
will-change: transform;
|
||||
gap: 12px;
|
||||
padding: 6px 12px 6px 6px;
|
||||
border-radius: 40px;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-1px);
|
||||
|
||||
.user-avatar-ring::before {
|
||||
opacity: 1;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-avatar-container:hover {
|
||||
transform: scale(1.05);
|
||||
.user-avatar-ring {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
border-radius: 50%;
|
||||
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)
|
||||
);
|
||||
opacity: 0;
|
||||
transition: all 0.4s ease;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--color-gray-900);
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.user-avatar-wrapper {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.user-avatar,
|
||||
.user-avatar-placeholder {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--color-border, #e5e7eb);
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.user-avatar-container:hover .user-avatar,
|
||||
.user-avatar-container:hover .user-avatar-placeholder {
|
||||
border-color: var(--color-primary, #1890ff);
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.user-avatar-placeholder {
|
||||
background: linear-gradient(135deg, var(--color-primary, #1890ff), var(--color-blue, #36cfc9));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.avatar-text {
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #22c55e;
|
||||
border: 2px solid var(--color-gray-900);
|
||||
border-radius: 50%;
|
||||
z-index: 3;
|
||||
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100px;
|
||||
}
|
||||
|
||||
.dropdown-arrow {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
transition: transform 0.25s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
title="上传素材"
|
||||
:width="600"
|
||||
:footer="false"
|
||||
:closable="!uploading"
|
||||
:maskClosable="!uploading"
|
||||
@cancel="handleCancel"
|
||||
@update:visible="handleVisibleChange"
|
||||
>
|
||||
<div class="upload-modal-content">
|
||||
<!-- 文件上传区域 -->
|
||||
<div class="upload-area">
|
||||
<div class="upload-area" v-if="!uploading">
|
||||
<a-upload-dragger
|
||||
v-model:file-list="fileList"
|
||||
name="file"
|
||||
@@ -33,7 +35,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 已选文件列表 -->
|
||||
<div v-if="fileList.length > 0" class="upload-file-list">
|
||||
<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;">
|
||||
@@ -61,11 +63,69 @@
|
||||
</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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 已完成文件列表 -->
|
||||
<div v-if="completedFiles.length > 0" class="completed-files">
|
||||
<div class="completed-header">
|
||||
<CheckCircleOutlined style="color: #10B981" />
|
||||
<span>已完成 {{ completedFiles.length }} 个文件</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="upload-actions">
|
||||
<a-space>
|
||||
<a-button @click="handleCancel">取消</a-button>
|
||||
<a-button v-if="!uploading" @click="handleCancel">取消</a-button>
|
||||
<a-button
|
||||
v-if="!uploading"
|
||||
type="primary"
|
||||
:loading="uploading"
|
||||
:disabled="fileList.length === 0"
|
||||
@@ -73,6 +133,7 @@
|
||||
>
|
||||
{{ uploading ? '上传中...' : `上传 (${fileList.length})` }}
|
||||
</a-button>
|
||||
<span v-else class="upload-tip">上传中请勿关闭窗口...</span>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,11 +141,12 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { UploadOutlined, FileOutlined } from '@ant-design/icons-vue'
|
||||
import { UploadOutlined, FileOutlined, LoadingOutlined, CheckCircleOutlined, CheckOutlined } from '@ant-design/icons-vue'
|
||||
import { isVideoFile, extractVideoCover } from '@/utils/video-cover'
|
||||
import { getFileName, getFileSize, formatFileSize } from '@/utils/file'
|
||||
import { useUpload } from '@/composables/useUpload'
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
@@ -105,7 +167,10 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible', 'confirm', 'cancel'])
|
||||
const emit = defineEmits(['update:visible', 'confirm', 'cancel', 'complete'])
|
||||
|
||||
// Hooks
|
||||
const { upload } = useUpload()
|
||||
|
||||
// 数据
|
||||
const fileList = ref([])
|
||||
@@ -117,6 +182,24 @@ const acceptTypes = 'video/*,image/*,audio/*,.mp4,.mov,.avi,.mkv,.jpg,.jpeg,.png
|
||||
const MAX_TOTAL_SIZE = 1 * 1024 * 1024 * 1024 // 1GB
|
||||
const MAX_SINGLE_FILE_SIZE = 1 * 1024 * 1024 * 1024 // 1GB (单个文件最大1GB)
|
||||
|
||||
// 上传状态
|
||||
const uploading = ref(false)
|
||||
const currentUploadIndex = ref(0)
|
||||
const totalUploadCount = ref(0)
|
||||
const currentFileName = ref('')
|
||||
const currentFileProgress = ref(0)
|
||||
const currentFileSize = ref(0)
|
||||
const currentFileUploaded = ref(0)
|
||||
const completedFiles = ref([])
|
||||
|
||||
// 计算总进度
|
||||
const totalProgress = computed(() => {
|
||||
if (totalUploadCount.value === 0) return 0
|
||||
const baseProgress = ((currentUploadIndex.value - 1) / totalUploadCount.value) * 100
|
||||
const currentContribution = (currentFileProgress.value / 100) / totalUploadCount.value * 100
|
||||
return Math.round(baseProgress + currentContribution)
|
||||
})
|
||||
|
||||
// 计算总大小(字节)
|
||||
const getTotalSizeBytes = () => {
|
||||
return fileList.value.reduce((total, item) => {
|
||||
@@ -134,9 +217,22 @@ watch(() => props.visible, (newVal) => {
|
||||
if (!newVal) {
|
||||
fileList.value = []
|
||||
fileCoverMap.value.clear()
|
||||
resetUploadState()
|
||||
}
|
||||
})
|
||||
|
||||
// 重置上传状态
|
||||
const resetUploadState = () => {
|
||||
uploading.value = false
|
||||
currentUploadIndex.value = 0
|
||||
totalUploadCount.value = 0
|
||||
currentFileName.value = ''
|
||||
currentFileProgress.value = 0
|
||||
currentFileSize.value = 0
|
||||
currentFileUploaded.value = 0
|
||||
completedFiles.value = []
|
||||
}
|
||||
|
||||
// 上传前处理
|
||||
const handleBeforeUpload = (file) => {
|
||||
// 检查单个文件大小
|
||||
@@ -172,7 +268,7 @@ const handleBeforeUpload = (file) => {
|
||||
// 文件列表变化
|
||||
const handleFileChange = async (info) => {
|
||||
const { file, fileList: newFileList } = info
|
||||
|
||||
|
||||
if (file && file.status !== 'uploading') {
|
||||
fileList.value = newFileList
|
||||
.map(item => {
|
||||
@@ -182,12 +278,12 @@ const handleFileChange = async (info) => {
|
||||
return item
|
||||
})
|
||||
.filter(item => item.status !== 'removed')
|
||||
|
||||
|
||||
// 如果是视频文件,自动提取封面
|
||||
const fileObj = file.file || file.originFileObj || file
|
||||
if (fileObj instanceof File && isVideoFile(fileObj)) {
|
||||
const fileKey = file.uid || fileObj.name
|
||||
|
||||
|
||||
if (!fileCoverMap.value.has(fileKey)) {
|
||||
try {
|
||||
const coverResult = await extractVideoCover(fileObj, {
|
||||
@@ -206,8 +302,8 @@ const handleFileChange = async (info) => {
|
||||
|
||||
// 移除文件
|
||||
const handleRemove = (fileItem) => {
|
||||
const index = fileList.value.findIndex(item =>
|
||||
(item.uid && item.uid === fileItem.uid) ||
|
||||
const index = fileList.value.findIndex(item =>
|
||||
(item.uid && item.uid === fileItem.uid) ||
|
||||
(getFileName(item) === getFileName(fileItem))
|
||||
)
|
||||
if (index > -1) {
|
||||
@@ -219,7 +315,7 @@ const handleRemove = (fileItem) => {
|
||||
}
|
||||
|
||||
// 确认上传
|
||||
const handleConfirm = () => {
|
||||
const handleConfirm = async () => {
|
||||
if (fileList.value.length === 0) {
|
||||
message.warning('请选择文件')
|
||||
return
|
||||
@@ -250,9 +346,82 @@ const handleConfirm = () => {
|
||||
return
|
||||
}
|
||||
|
||||
// 开始上传
|
||||
await performUpload(filesWithCover)
|
||||
}
|
||||
|
||||
// 执行上传
|
||||
const performUpload = async (filesWithCover) => {
|
||||
uploading.value = true
|
||||
totalUploadCount.value = filesWithCover.length
|
||||
currentUploadIndex.value = 0
|
||||
completedFiles.value = []
|
||||
|
||||
// 使用传入的fileCategory,如果没有则使用默认值
|
||||
const category = props.fileCategory || DEFAULT_FILE_CATEGORY
|
||||
emit('confirm', filesWithCover, category, props.groupId)
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
|
||||
for (let i = 0; i < filesWithCover.length; i++) {
|
||||
const fileWithCover = filesWithCover[i]
|
||||
currentUploadIndex.value = i + 1
|
||||
currentFileName.value = fileWithCover.file.name
|
||||
currentFileSize.value = fileWithCover.file.size
|
||||
currentFileUploaded.value = 0
|
||||
currentFileProgress.value = 0
|
||||
|
||||
try {
|
||||
await upload(fileWithCover.file, {
|
||||
fileCategory: category,
|
||||
groupId: props.groupId,
|
||||
coverBase64: fileWithCover.coverBase64,
|
||||
onProgress: (progress) => {
|
||||
currentFileProgress.value = progress
|
||||
currentFileUploaded.value = Math.round((progress / 100) * currentFileSize.value)
|
||||
},
|
||||
onSuccess: () => {
|
||||
console.log('文件上传成功:', fileWithCover.file.name)
|
||||
successCount++
|
||||
completedFiles.value.push({
|
||||
name: fileWithCover.file.name,
|
||||
success: true
|
||||
})
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('文件上传失败:', fileWithCover.file.name, error)
|
||||
failCount++
|
||||
completedFiles.value.push({
|
||||
name: fileWithCover.file.name,
|
||||
success: false
|
||||
})
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('上传异常:', error)
|
||||
failCount++
|
||||
completedFiles.value.push({
|
||||
name: fileWithCover.file.name,
|
||||
success: false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
uploading.value = false
|
||||
|
||||
// 显示结果
|
||||
if (successCount > 0 && failCount === 0) {
|
||||
message.success(`成功上传 ${successCount} 个文件`)
|
||||
} else if (successCount > 0 && failCount > 0) {
|
||||
message.warning(`上传完成:${successCount} 个成功,${failCount} 个失败`)
|
||||
} else {
|
||||
message.error('上传失败,请重试')
|
||||
}
|
||||
|
||||
// 通知父组件上传完成
|
||||
emit('complete', { successCount, failCount })
|
||||
|
||||
// 关闭弹窗
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
// 处理 visible 变化
|
||||
@@ -262,6 +431,7 @@ const handleVisibleChange = (value) => {
|
||||
|
||||
// 取消
|
||||
const handleCancel = () => {
|
||||
if (uploading.value) return
|
||||
emit('update:visible', false)
|
||||
emit('cancel')
|
||||
}
|
||||
@@ -335,13 +505,104 @@ const handleCancel = () => {
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.upload-tips {
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
.upload-tip {
|
||||
color: var(--color-text-3);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.upload-tips > div {
|
||||
margin-bottom: 4px;
|
||||
/* 上传进度区域 */
|
||||
.upload-progress-area {
|
||||
padding: 16px;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-card);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.upload-progress-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.upload-spinner {
|
||||
font-size: 20px;
|
||||
color: #3B82F6;
|
||||
}
|
||||
|
||||
.upload-total-progress {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.upload-current-file {
|
||||
padding: 12px;
|
||||
background: var(--color-bg-2);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.current-file-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.current-file-name span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.current-file-progress .progress-info {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.completed-files {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.completed-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.completed-list {
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.completed-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
.completed-item span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ const navConfig = [
|
||||
items: [
|
||||
{ name: '素材列表', path: 'material/list', icon: 'grid', component: () => import('../views/material/MaterialListNew.vue') },
|
||||
{ name: '智能混剪', path: 'material/mix', icon: 'scissors', component: () => import('../views/material/Mix.vue') },
|
||||
{ name: '任务中心', path: 'system/task-management/:type', icon: 'video', component: () => import('../views/system/task-management/layout/TaskLayout.vue'), requiresAuth: true, params: { type: 'mix-task' } },
|
||||
{ name: '任务中心', path: 'system/task-management', icon: 'video', component: () => import('../views/system/task-management/layout/TaskLayout.vue'), requiresAuth: true },
|
||||
]
|
||||
},
|
||||
// {
|
||||
@@ -173,6 +173,9 @@ router.beforeEach(async (to, from, next) => {
|
||||
await userStore.fetchUserInfo()
|
||||
}
|
||||
|
||||
// 4. 路由切换时更新用户额度
|
||||
userStore.fetchUserProfile()
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
|
||||
@@ -241,8 +241,8 @@
|
||||
<MaterialUploadModal
|
||||
v-model:visible="uploadModalVisible"
|
||||
:group-id="selectedGroupId"
|
||||
:uploading="uploadLoading"
|
||||
@confirm="handleFileUpload"
|
||||
:file-category="activeCategory === 'DIGITAL_HUMAN' ? 'digital_human' : 'video'"
|
||||
@complete="handleUploadComplete"
|
||||
/>
|
||||
|
||||
<!-- 新建分组弹窗 -->
|
||||
@@ -284,7 +284,6 @@ import {
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
import MaterialUploadModal from '@/components/material/MaterialUploadModal.vue';
|
||||
import MaterialService, { MaterialGroupService } from '@/api/material';
|
||||
import { useUpload } from '@/composables/useUpload';
|
||||
import FullWidthLayout from '@/layouts/components/FullWidthLayout.vue';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
|
||||
@@ -302,7 +301,6 @@ const searchKeyword = ref('')
|
||||
|
||||
// 模态框状态
|
||||
const uploadModalVisible = ref(false)
|
||||
const uploadLoading = ref(false)
|
||||
const createGroupModalVisible = ref(false)
|
||||
|
||||
// 表单数据
|
||||
@@ -329,9 +327,6 @@ const pagination = reactive({
|
||||
total: 0
|
||||
})
|
||||
|
||||
// Hooks
|
||||
const { upload } = useUpload()
|
||||
|
||||
// 方法
|
||||
const handleCategoryChange = async (category) => {
|
||||
activeCategory.value = category
|
||||
@@ -557,46 +552,15 @@ const handleOpenUploadModal = () => {
|
||||
uploadModalVisible.value = true
|
||||
}
|
||||
|
||||
const handleFileUpload = async (filesWithCover, category, groupId) => {
|
||||
try {
|
||||
uploadLoading.value = true
|
||||
|
||||
// 根据当前分类确定 fileCategory
|
||||
// 混剪素材用 video,数字人素材用 digital_human
|
||||
const fileCategory = activeCategory.value === 'DIGITAL_HUMAN' ? 'digital_human' : 'video'
|
||||
// 数字人素材不关联分组
|
||||
const uploadGroupId = activeCategory.value === 'DIGITAL_HUMAN' ? null : groupId
|
||||
|
||||
for (const fileWithCover of filesWithCover) {
|
||||
await upload(fileWithCover.file, {
|
||||
fileCategory,
|
||||
groupId: uploadGroupId,
|
||||
coverBase64: fileWithCover.coverBase64,
|
||||
onStart: () => {},
|
||||
onProgress: () => {},
|
||||
onSuccess: (id) => {
|
||||
console.log('文件上传成功:', id)
|
||||
},
|
||||
onError: (error) => {
|
||||
message.error(error.message || '上传失败')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
message.success(`成功上传 ${filesWithCover.length} 个文件`)
|
||||
uploadModalVisible.value = false
|
||||
await loadFileList()
|
||||
// 刷新存储配额
|
||||
await userStore.fetchUserProfile()
|
||||
// 混剪素材才刷新分组列表
|
||||
if (activeCategory.value === 'MIX') {
|
||||
await loadGroupList()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("文件上传失败:", error)
|
||||
message.error("文件上传失败: " + (error.message || "未知错误"))
|
||||
} finally {
|
||||
uploadLoading.value = false
|
||||
// 上传完成后的回调
|
||||
const handleUploadComplete = async ({ successCount, failCount }) => {
|
||||
// 刷新文件列表
|
||||
await loadFileList()
|
||||
// 刷新存储配额
|
||||
await userStore.fetchUserProfile()
|
||||
// 混剪素材才刷新分组列表
|
||||
if (activeCategory.value === 'MIX') {
|
||||
await loadGroupList()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:open="visible"
|
||||
title="选择素材分组"
|
||||
:width="400"
|
||||
:footer="null"
|
||||
centered
|
||||
@update:open="$emit('update:visible', $event)"
|
||||
>
|
||||
<div class="group-select-content">
|
||||
<p class="group-select-hint">请选择用于填充的素材分组:</p>
|
||||
<div class="group-select-list">
|
||||
<div
|
||||
v-for="group in groupList"
|
||||
:key="group.id"
|
||||
class="group-select-item"
|
||||
:class="{ 'group-select-item--active': tempSelectedId === group.id }"
|
||||
@click="tempSelectedId = group.id"
|
||||
>
|
||||
<FolderOutlined class="group-icon" />
|
||||
<span class="group-name">{{ group.name }}</span>
|
||||
<CheckOutlined v-if="tempSelectedId === group.id" class="group-check" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="group-select-footer">
|
||||
<a-button @click="$emit('update:visible', false)">取消</a-button>
|
||||
<a-button
|
||||
type="primary"
|
||||
:disabled="!tempSelectedId"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
确认填充
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { FolderOutlined, CheckOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
groupList: { type: Array, default: () => [] },
|
||||
defaultGroupId: { type: [Number, String], default: null }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible', 'confirm'])
|
||||
|
||||
const tempSelectedId = ref(null)
|
||||
|
||||
watch(() => props.visible, (visible) => {
|
||||
if (visible) {
|
||||
tempSelectedId.value = props.defaultGroupId || (props.groupList[0]?.id ?? null)
|
||||
}
|
||||
})
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!tempSelectedId.value) return
|
||||
emit('confirm', tempSelectedId.value)
|
||||
emit('update:visible', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.group-select-content {
|
||||
.group-select-hint {
|
||||
margin: 0 0 16px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.group-select-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.group-select-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #1890ff;
|
||||
background: #f0f7ff;
|
||||
}
|
||||
|
||||
&--active {
|
||||
border-color: #1890ff;
|
||||
background: #e6f7ff;
|
||||
|
||||
.group-icon {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.group-name {
|
||||
color: #1890ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.group-icon {
|
||||
font-size: 18px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.group-name {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.group-check {
|
||||
font-size: 14px;
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
.group-select-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,578 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:open="visible"
|
||||
:footer="null"
|
||||
:closable="false"
|
||||
width="800px"
|
||||
centered
|
||||
class="scene-selector-modal"
|
||||
@update:open="$emit('update:visible', $event)"
|
||||
>
|
||||
<!-- 顶部 -->
|
||||
<div class="modal-header">
|
||||
<div class="header-left">
|
||||
<div class="scene-badge">场景 {{ sceneIndex + 1 }}</div>
|
||||
<div class="counter">
|
||||
<span class="counter-current">{{ tempSelectedFiles.length }}</span>
|
||||
<span class="counter-sep">/</span>
|
||||
<span class="counter-max">{{ maxCandidates }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分组切换 -->
|
||||
<div v-if="groupList.length > 1" class="group-tabs">
|
||||
<a-tag
|
||||
v-for="g in groupList"
|
||||
:key="g.id"
|
||||
:color="selectorGroupId === g.id ? 'blue' : 'default'"
|
||||
style="cursor: pointer;"
|
||||
@click="handleSelectorGroupChange(g.id)"
|
||||
>
|
||||
{{ g.name }}
|
||||
</a-tag>
|
||||
</div>
|
||||
|
||||
<a-button type="text" size="small" @click="$emit('update:visible', false)">
|
||||
<CloseOutlined />
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 工具栏 -->
|
||||
<div class="toolbar">
|
||||
<a-space size="small">
|
||||
<a-button size="small" @click="handleSelectAll">
|
||||
<template #icon><CheckSquareOutlined /></template>
|
||||
全选
|
||||
</a-button>
|
||||
<a-button size="small" @click="handleClearSelection">
|
||||
<template #icon><CloseSquareOutlined /></template>
|
||||
清空
|
||||
</a-button>
|
||||
<a-button size="small" type="primary" ghost @click="handleAutoFill">
|
||||
<template #icon><ThunderboltOutlined /></template>
|
||||
随机
|
||||
</a-button>
|
||||
</a-space>
|
||||
<span class="toolbar-hint">点击选择 · 双击确认 · Enter 提交</span>
|
||||
</div>
|
||||
|
||||
<!-- 素材网格 -->
|
||||
<div class="material-grid">
|
||||
<template v-if="selectorFiles.length > 0">
|
||||
<div
|
||||
v-for="(file, index) in selectorFiles"
|
||||
:key="file.id"
|
||||
class="material-card"
|
||||
:class="{ 'material-card--selected': isSelected(file.id) }"
|
||||
:style="{ animationDelay: `${index * 20}ms` }"
|
||||
@click="toggleSelection(file)"
|
||||
@dblclick="handleQuickConfirm(file)"
|
||||
>
|
||||
<div class="card-cover">
|
||||
<img v-if="file.coverBase64" :src="file.coverBase64" />
|
||||
<div v-else class="cover-placeholder">
|
||||
<VideoCameraOutlined />
|
||||
</div>
|
||||
|
||||
<!-- 选中标记 -->
|
||||
<div v-if="isSelected(file.id)" class="card-check">
|
||||
<CheckOutlined />
|
||||
</div>
|
||||
|
||||
<!-- 序号 -->
|
||||
<div v-if="isSelected(file.id)" class="card-order">
|
||||
{{ getSelectionOrder(file.id) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-name">{{ file.displayName || file.fileName }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<a-empty v-else description="暂无素材" />
|
||||
</div>
|
||||
|
||||
<!-- 底部 -->
|
||||
<div class="modal-footer">
|
||||
<a-space>
|
||||
<a-button @click="$emit('update:visible', false)">取消</a-button>
|
||||
<a-button
|
||||
type="primary"
|
||||
:disabled="tempSelectedFiles.length === 0"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
<template #icon><CheckOutlined /></template>
|
||||
确认选择
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
VideoCameraOutlined,
|
||||
CheckOutlined,
|
||||
CloseOutlined,
|
||||
CheckSquareOutlined,
|
||||
CloseSquareOutlined,
|
||||
ThunderboltOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
sceneIndex: { type: Number, default: -1 },
|
||||
sceneGroupId: { type: [Number, String], default: null },
|
||||
globalGroupId: { type: [Number, String], default: null },
|
||||
groupList: { type: Array, default: () => [] },
|
||||
allGroupFiles: { type: Object, default: () => ({}) },
|
||||
groupFiles: { type: Array, default: () => [] },
|
||||
initialCandidates: { type: Array, default: () => [] },
|
||||
maxCandidates: { type: Number, default: 10 }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible', 'confirm', 'load-group-files'])
|
||||
|
||||
const selectorGroupId = ref(null)
|
||||
const tempSelectedFiles = ref([])
|
||||
|
||||
const selectorFiles = computed(() => {
|
||||
const groupId = selectorGroupId.value
|
||||
if (groupId) {
|
||||
return props.allGroupFiles[groupId] || []
|
||||
}
|
||||
const effectiveGroupId = props.sceneGroupId || props.globalGroupId
|
||||
if (effectiveGroupId) {
|
||||
return props.allGroupFiles[effectiveGroupId] || props.groupFiles
|
||||
}
|
||||
return props.groupFiles
|
||||
})
|
||||
|
||||
watch(() => props.visible, (visible) => {
|
||||
if (visible) {
|
||||
tempSelectedFiles.value = [...props.initialCandidates]
|
||||
selectorGroupId.value = props.sceneGroupId || props.globalGroupId || null
|
||||
if (selectorGroupId.value && !props.allGroupFiles[selectorGroupId.value]) {
|
||||
emit('load-group-files', selectorGroupId.value)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 键盘快捷键
|
||||
const handleKeydown = (e) => {
|
||||
if (!props.visible) return
|
||||
if (e.key === 'Enter' && tempSelectedFiles.value.length > 0) {
|
||||
e.preventDefault()
|
||||
handleConfirm()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('keydown', handleKeydown))
|
||||
onUnmounted(() => window.removeEventListener('keydown', handleKeydown))
|
||||
|
||||
const isSelected = (fileId) => tempSelectedFiles.value.includes(fileId)
|
||||
const getSelectionOrder = (fileId) => tempSelectedFiles.value.indexOf(fileId) + 1
|
||||
|
||||
const getFileName = (fileId) => {
|
||||
const file = selectorFiles.value.find(f => f.id === fileId)
|
||||
return file ? (file.displayName || file.fileName) : '未知'
|
||||
}
|
||||
|
||||
const toggleSelection = (file) => {
|
||||
const index = tempSelectedFiles.value.indexOf(file.id)
|
||||
if (index > -1) {
|
||||
tempSelectedFiles.value.splice(index, 1)
|
||||
} else {
|
||||
if (tempSelectedFiles.value.length < props.maxCandidates) {
|
||||
tempSelectedFiles.value.push(file.id)
|
||||
} else {
|
||||
message.warning(`最多选择 ${props.maxCandidates} 个素材`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const removeSelection = (fileId) => {
|
||||
const index = tempSelectedFiles.value.indexOf(fileId)
|
||||
if (index > -1) tempSelectedFiles.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const handleQuickConfirm = (file) => {
|
||||
if (!isSelected(file.id)) toggleSelection(file)
|
||||
handleConfirm()
|
||||
}
|
||||
|
||||
const handleSelectAll = () => {
|
||||
tempSelectedFiles.value = selectorFiles.value.slice(0, props.maxCandidates).map(f => f.id)
|
||||
}
|
||||
|
||||
const handleClearSelection = () => {
|
||||
tempSelectedFiles.value = []
|
||||
}
|
||||
|
||||
const handleAutoFill = () => {
|
||||
const availableMaterials = selectorFiles.value.filter(
|
||||
material => !tempSelectedFiles.value.includes(material.id)
|
||||
)
|
||||
|
||||
if (availableMaterials.length === 0) {
|
||||
message.warning('没有更多可用素材')
|
||||
return
|
||||
}
|
||||
|
||||
const maxAddCount = props.maxCandidates - tempSelectedFiles.value.length
|
||||
const targetCount = Math.min(3, maxAddCount)
|
||||
|
||||
if (targetCount <= 0) {
|
||||
message.warning('已达到上限')
|
||||
return
|
||||
}
|
||||
|
||||
const shuffled = [...availableMaterials].sort(() => Math.random() - 0.5)
|
||||
const selected = shuffled.slice(0, targetCount)
|
||||
tempSelectedFiles.value.push(...selected.map(m => m.id))
|
||||
message.success(`已随机选择 ${selected.length} 个素材`)
|
||||
}
|
||||
|
||||
const handleSelectorGroupChange = (groupId) => {
|
||||
selectorGroupId.value = groupId
|
||||
tempSelectedFiles.value = []
|
||||
if (groupId && !props.allGroupFiles[groupId]) {
|
||||
emit('load-group-files', groupId)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (tempSelectedFiles.value.length === 0) {
|
||||
message.warning('请至少选择一个素材')
|
||||
return
|
||||
}
|
||||
emit('confirm', {
|
||||
sceneIndex: props.sceneIndex,
|
||||
fileIds: [...tempSelectedFiles.value],
|
||||
setGroupId: selectorGroupId.value
|
||||
})
|
||||
emit('update:visible', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 弹窗样式 - 使用项目设计系统
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// 🎨 设计令牌引用
|
||||
@primary: var(--color-primary-500);
|
||||
@primary-light: var(--color-primary-50);
|
||||
@primary-hover: var(--color-primary-400);
|
||||
|
||||
@success: var(--color-success-500);
|
||||
@success-light: var(--color-success-50);
|
||||
|
||||
@warning: var(--color-warning-500);
|
||||
|
||||
@text: var(--color-text);
|
||||
@text-secondary: var(--color-text-secondary);
|
||||
@text-muted: var(--color-text-muted);
|
||||
@text-disabled: var(--color-text-disabled);
|
||||
|
||||
@bg-card: var(--color-bg-card);
|
||||
@bg-elevated: var(--color-gray-100);
|
||||
|
||||
@border: var(--color-border);
|
||||
@border-light: var(--color-gray-100);
|
||||
|
||||
.scene-selector-modal {
|
||||
:deep(.ant-modal-content) {
|
||||
padding: 0;
|
||||
border-radius: var(--radius-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.ant-modal-body) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 顶部标题栏
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-4) var(--space-4);
|
||||
background: @bg-elevated;
|
||||
border-bottom: 1px solid @border-light;
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.scene-badge {
|
||||
padding: var(--space-1) var(--space-3);
|
||||
background: @primary-light;
|
||||
border: 1px solid var(--color-primary-200);
|
||||
border-radius: var(--radius-tag);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
color: @primary;
|
||||
}
|
||||
|
||||
.counter {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
|
||||
.counter-current {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: @primary;
|
||||
}
|
||||
|
||||
.counter-sep {
|
||||
color: @text-disabled;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.counter-max {
|
||||
font-size: var(--font-size-base);
|
||||
color: @text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.group-tabs {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 工具栏
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: @bg-card;
|
||||
border-bottom: 1px solid @border-light;
|
||||
|
||||
.toolbar-hint {
|
||||
font-size: var(--font-size-xs);
|
||||
color: @text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 素材网格
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
.material-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
min-height: 360px;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
background: @bg-card;
|
||||
align-content: start;
|
||||
|
||||
&:deep(.ant-empty) {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 320px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--color-gray-300);
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.material-card {
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-button);
|
||||
overflow: hidden;
|
||||
background: @bg-elevated;
|
||||
border: 2px solid transparent;
|
||||
transition: all var(--duration-base) ease;
|
||||
animation: fadeIn var(--duration-slow) ease backwards;
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: @primary;
|
||||
box-shadow: var(--shadow-blue);
|
||||
}
|
||||
|
||||
// ✅ 选中状态 - 青色主题
|
||||
&--selected {
|
||||
border-color: var(--color-primary-400);
|
||||
background: var(--color-primary-50);
|
||||
|
||||
.card-check {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.card-order {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.card-cover {
|
||||
position: relative;
|
||||
aspect-ratio: 16/9;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.cover-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-gray-100);
|
||||
color: @text-disabled;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
// ✓ 选中勾选
|
||||
.card-check {
|
||||
position: absolute;
|
||||
top: var(--space-1);
|
||||
left: var(--space-1);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: @primary;
|
||||
color: var(--color-text-inverse);
|
||||
border-radius: 50%;
|
||||
font-size: 11px;
|
||||
opacity: 0;
|
||||
transition: opacity var(--duration-base) ease;
|
||||
}
|
||||
|
||||
// 🏷️ 选中序号
|
||||
.card-order {
|
||||
position: absolute;
|
||||
top: var(--space-1);
|
||||
right: var(--space-1);
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 var(--space-1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: @warning;
|
||||
color: var(--color-text-inverse);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
border-radius: 10px;
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
transition: all var(--duration-base) ease;
|
||||
}
|
||||
}
|
||||
|
||||
.card-name {
|
||||
padding: var(--space-2);
|
||||
font-size: 11px;
|
||||
color: @text-secondary;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
background: @bg-card;
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 底部操作栏
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: @bg-elevated;
|
||||
border-top: 1px solid @border-light;
|
||||
|
||||
.selected-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.more-tag {
|
||||
padding: 2px var(--space-2);
|
||||
background: var(--color-gray-100);
|
||||
border-radius: var(--radius-tag);
|
||||
font-size: var(--font-size-xs);
|
||||
color: @text-muted;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.no-selection {
|
||||
font-size: var(--font-size-sm);
|
||||
color: @text-disabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 响应式适配
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
@media (max-width: 800px) {
|
||||
.material-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.material-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.group-tabs {
|
||||
order: 3;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,47 +1,41 @@
|
||||
<template>
|
||||
<div class="task-layout">
|
||||
<!-- 左侧导航 -->
|
||||
<aside class="task-layout__sidebar">
|
||||
<nav class="task-layout__nav">
|
||||
<div class="task-layout__nav-header">
|
||||
<h2 class="task-layout__nav-title">任务管理</h2>
|
||||
<!-- 顶部Tab栏 -->
|
||||
<div class="task-layout__header">
|
||||
<div class="task-tabs">
|
||||
<div
|
||||
v-for="item in NAV_ITEMS"
|
||||
:key="item.type"
|
||||
class="task-tab"
|
||||
:class="{ 'is-active': currentType === item.type }"
|
||||
@click="currentType = item.type"
|
||||
>
|
||||
<component :is="item.icon" class="tab-icon" />
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
<ul class="task-layout__nav-list">
|
||||
<li
|
||||
v-for="item in NAV_ITEMS"
|
||||
:key="item.type"
|
||||
class="task-layout__nav-item"
|
||||
:class="{ 'is-active': currentType === item.type }"
|
||||
>
|
||||
<a class="task-layout__nav-link" @click="navigateTo(item.type)">
|
||||
<component :is="item.icon" class="nav-icon" />
|
||||
<span class="nav-text">{{ item.label }}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧内容 -->
|
||||
<main class="task-layout__content">
|
||||
<!-- 内容区 -->
|
||||
<div class="task-layout__content">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="currentComponent" :key="currentType" />
|
||||
</transition>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, defineAsyncComponent, markRaw } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ref, computed, defineAsyncComponent, markRaw, onMounted, watch } from 'vue'
|
||||
import { VideoCameraOutlined, UserOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const STORAGE_KEY = 'task-management-active-tab'
|
||||
|
||||
const currentType = computed(() => {
|
||||
const { type } = route.params
|
||||
return !type || type === 'task-management' ? 'mix-task' : type
|
||||
const currentType = ref(localStorage.getItem(STORAGE_KEY) || 'mix-task')
|
||||
|
||||
// 监听变化,同步到localStorage
|
||||
watch(currentType, (val) => {
|
||||
localStorage.setItem(STORAGE_KEY, val)
|
||||
})
|
||||
|
||||
const NAV_ITEMS = [
|
||||
@@ -62,95 +56,64 @@ const NAV_ITEMS = [
|
||||
const currentComponent = computed(() => {
|
||||
return NAV_ITEMS.find(item => item.type === currentType.value)?.component ?? NAV_ITEMS[0].component
|
||||
})
|
||||
|
||||
const navigateTo = (type) => {
|
||||
router.push(`/system/task-management/${type}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.task-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--color-bg-card);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.task-layout__sidebar {
|
||||
width: 220px;
|
||||
background: transparent;
|
||||
border-right: 1px solid var(--color-gray-200);
|
||||
.task-layout__header {
|
||||
flex-shrink: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.task-layout__nav-header {
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 var(--space-6);
|
||||
padding: 0 var(--space-4);
|
||||
border-bottom: 1px solid var(--color-gray-200);
|
||||
background: var(--color-bg-card);
|
||||
}
|
||||
|
||||
.task-layout__nav-title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-gray-800);
|
||||
.task-tabs {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.task-layout__nav-list {
|
||||
list-style: none;
|
||||
padding: var(--space-2) 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.task-layout__nav-item {
|
||||
margin: var(--space-1) var(--space-3);
|
||||
|
||||
&.is-active .task-layout__nav-link {
|
||||
background: var(--color-primary-500);
|
||||
color: #fff;
|
||||
box-shadow: var(--shadow-sm);
|
||||
|
||||
.nav-icon {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.task-layout__nav-link {
|
||||
.task-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-gray-600);
|
||||
gap: var(--space-2);
|
||||
padding: 0 var(--space-4);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-gray-500);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all var(--duration-fast) var(--ease-out);
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-gray-100);
|
||||
color: var(--color-primary-500);
|
||||
}
|
||||
|
||||
.is-active &:hover {
|
||||
background: var(--color-primary-600);
|
||||
color: #fff;
|
||||
&.is-active {
|
||||
color: var(--color-primary-500);
|
||||
border-bottom-color: var(--color-primary-500);
|
||||
|
||||
.tab-icon {
|
||||
color: var(--color-primary-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: var(--space-2);
|
||||
color: var(--color-gray-500);
|
||||
}
|
||||
|
||||
.nav-text {
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
.tab-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--color-gray-400);
|
||||
transition: color var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
}
|
||||
|
||||
.task-layout__content {
|
||||
|
||||
@@ -416,4 +416,10 @@ onMounted(fetchList)
|
||||
:deep(.ant-btn .anticon) {
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
/* 修复 popconfirm 按钮对齐 */
|
||||
:deep(.ant-popover .ant-popover-buttons) {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -52,4 +52,11 @@ public interface FileApi {
|
||||
String presignGetUrl(@NotEmpty(message = "URL 不能为空") String url,
|
||||
Integer expirationSeconds);
|
||||
|
||||
/**
|
||||
* 获取主文件配置的自定义域名(CDN域名)
|
||||
*
|
||||
* @return 自定义域名,如 https://oss.muyetools.cn
|
||||
*/
|
||||
String getMasterFileDomain();
|
||||
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package cn.iocoder.yudao.module.infra.api.file;
|
||||
|
||||
import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClient;
|
||||
import cn.iocoder.yudao.module.infra.framework.file.core.client.s3.S3FileClient;
|
||||
import cn.iocoder.yudao.module.infra.framework.file.core.client.s3.S3FileClientConfig;
|
||||
import cn.iocoder.yudao.module.infra.service.file.FileConfigService;
|
||||
import cn.iocoder.yudao.module.infra.service.file.FileService;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -17,6 +21,9 @@ public class FileApiImpl implements FileApi {
|
||||
@Resource
|
||||
private FileService fileService;
|
||||
|
||||
@Resource
|
||||
private FileConfigService fileConfigService;
|
||||
|
||||
@Override
|
||||
public String createFile(byte[] content, String name, String directory, String type) {
|
||||
return fileService.createFile(content, name, directory, type);
|
||||
@@ -27,4 +34,14 @@ public class FileApiImpl implements FileApi {
|
||||
return fileService.presignGetUrl(url, expirationSeconds);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getMasterFileDomain() {
|
||||
FileClient client = fileConfigService.getMasterFileClient();
|
||||
if (client instanceof S3FileClient) {
|
||||
S3FileClient s3Client = (S3FileClient) client;
|
||||
return s3Client.getConfig().getDomain();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -54,6 +54,11 @@ public abstract class AbstractFileClient<Config extends FileClientConfig> implem
|
||||
return id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Config getConfig() {
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件的 URL 访问地址
|
||||
* 使用场景:local、ftp、db,通过 FileController 的 getFile 来获取文件内容
|
||||
|
||||
@@ -74,4 +74,11 @@ public interface FileClient {
|
||||
throw new UnsupportedOperationException("不支持的操作");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件配置
|
||||
*
|
||||
* @return 文件配置
|
||||
*/
|
||||
FileClientConfig getConfig();
|
||||
|
||||
}
|
||||
|
||||
@@ -119,11 +119,9 @@ public class BatchProduceAlignment {
|
||||
for (int i = 0; i < videoArray.length; i++) {
|
||||
String videoUrl = videoArray[i];
|
||||
|
||||
// 验证视频URL必须是阿里云OSS地址
|
||||
if (!videoUrl.contains(".aliyuncs.com")) {
|
||||
log.error("[ICE][视频URL不是阿里云OSS地址][视频{}: {}]", i + 1, videoUrl);
|
||||
throw new IllegalArgumentException("视频URL必须是阿里云OSS地址,当前URL: " + videoUrl);
|
||||
}
|
||||
// 将CDN URL转换为OSS原始URL(ICE需要原始域名)
|
||||
videoUrl = properties.convertToOssUrl(videoUrl);
|
||||
videoArray[i] = videoUrl; // 更新数组中的URL
|
||||
|
||||
log.debug("[ICE][添加视频片段][{}: {}]", i + 1, videoUrl);
|
||||
// 使用标准的 MediaURL 参数(符合 ICE API 文档规范)
|
||||
@@ -275,11 +273,9 @@ public class BatchProduceAlignment {
|
||||
String videoUrl = material.getFileUrl();
|
||||
int duration = material.getDuration();
|
||||
|
||||
// 验证视频URL必须是阿里云OSS地址
|
||||
if (!videoUrl.contains(".aliyuncs.com")) {
|
||||
log.error("[ICE][视频URL不是阿里云OSS地址][视频{}: {}]", i + 1, videoUrl);
|
||||
throw new IllegalArgumentException("视频URL必须是阿里云OSS地址,当前URL: " + videoUrl);
|
||||
}
|
||||
// 将CDN URL转换为OSS原始URL(ICE需要原始域名)
|
||||
videoUrl = properties.convertToOssUrl(videoUrl);
|
||||
material.setFileUrl(videoUrl); // 更新material中的URL
|
||||
|
||||
// 计算随机截取起点
|
||||
// 优先使用前端传入的素材实际时长,无则从0开始截取(兜底)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package cn.iocoder.yudao.module.tik.mix.config;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.iocoder.yudao.module.infra.api.file.FileApi;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -13,8 +15,11 @@ import java.time.Duration;
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "yudao.ice")
|
||||
@RequiredArgsConstructor
|
||||
public class IceProperties {
|
||||
|
||||
private final FileApi fileApi;
|
||||
|
||||
/**
|
||||
* AccessKey ID
|
||||
*/
|
||||
@@ -60,4 +65,26 @@ public class IceProperties {
|
||||
public String getOssWriteUrl(String path) {
|
||||
return "https://" + bucket + ".oss-" + regionId + ".aliyuncs.com/" + path;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将CDN URL转换为OSS原始URL(ICE需要原始域名才能访问)
|
||||
* CDN域名从数据库的文件配置中获取
|
||||
*/
|
||||
public String convertToOssUrl(String url) {
|
||||
if (StrUtil.isBlank(url)) {
|
||||
return url;
|
||||
}
|
||||
// 如果已经是OSS原始域名,直接返回
|
||||
if (url.contains(".aliyuncs.com")) {
|
||||
return url;
|
||||
}
|
||||
// 从数据库获取CDN域名
|
||||
String cdnDomain = fileApi.getMasterFileDomain();
|
||||
if (StrUtil.isBlank(cdnDomain)) {
|
||||
return url;
|
||||
}
|
||||
// 将CDN域名替换为OSS原始域名
|
||||
String ossDomain = "https://" + bucket + ".oss-" + regionId + ".aliyuncs.com";
|
||||
return url.replace(cdnDomain, ossDomain);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,7 +206,6 @@ yudao:
|
||||
region-id: cn-hangzhou
|
||||
bucket: muye-ai-chat
|
||||
enabled: true
|
||||
oss-domain: https://oss.muyetools.cn # CDN加速域名(用于文件访问)
|
||||
captcha:
|
||||
enable: false # 关闭图片验证码,方便登录等接口的测试
|
||||
security:
|
||||
|
||||
@@ -268,7 +268,6 @@ yudao:
|
||||
region-id: cn-hangzhou
|
||||
bucket: muye-ai-chat
|
||||
enabled: true
|
||||
oss-domain: https://oss.muyetools.cn # CDN加速域名(用于文件访问)
|
||||
dify:
|
||||
api-url: http://127.0.0.1:8088 # Dify API 地址,请根据实际情况修改
|
||||
timeout: 240 # 请求超时时间(秒)
|
||||
|
||||
Reference in New Issue
Block a user