Compare commits

..

3 Commits

Author SHA1 Message Date
7f5d9d9f19 feat: 优化 2026-03-04 02:13:16 +08:00
aa06782953 混剪优化 2026-03-04 00:37:56 +08:00
7b32191987 feat: enhance sidebar quota display with progress bar and improve upload modal UX
- Replace percentage-based quota with point-based display in sidebar
- Add visual progress bar for remaining quota with gradient styling
- Implement upload progress tracking in material upload modal
- Add loading indicators and progress information during file uploads
- Prevent modal interaction while uploading by disabling close controls
- Show current upload status including file index and completion percentage
2026-03-03 22:15:06 +08:00
21 changed files with 2497 additions and 1349 deletions

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -52,4 +52,11 @@ public interface FileApi {
String presignGetUrl(@NotEmpty(message = "URL 不能为空") String url,
Integer expirationSeconds);
/**
* 获取主文件配置的自定义域名CDN域名
*
* @return 自定义域名,如 https://oss.muyetools.cn
*/
String getMasterFileDomain();
}

View File

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

View File

@@ -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 来获取文件内容

View File

@@ -74,4 +74,11 @@ public interface FileClient {
throw new UnsupportedOperationException("不支持的操作");
}
/**
* 获取文件配置
*
* @return 文件配置
*/
FileClientConfig getConfig();
}

View File

@@ -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原始URLICE需要原始域名
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原始URLICE需要原始域名
videoUrl = properties.convertToOssUrl(videoUrl);
material.setFileUrl(videoUrl); // 更新material中的URL
// 计算随机截取起点
// 优先使用前端传入的素材实际时长无则从0开始截取兜底

View File

@@ -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原始URLICE需要原始域名才能访问
* 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);
}
}

View File

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

View File

@@ -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 # 请求超时时间(秒)