feat: 优化
This commit is contained in:
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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
]
|
||||
},
|
||||
// {
|
||||
|
||||
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>
|
||||
@@ -1,146 +1,147 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:open="visible"
|
||||
:title="modalTitle"
|
||||
:footer="null"
|
||||
:closable="false"
|
||||
width="800px"
|
||||
class="selector-modal"
|
||||
centered
|
||||
class="scene-selector-modal"
|
||||
@update:open="$emit('update:visible', $event)"
|
||||
>
|
||||
<div class="selector-container">
|
||||
<!-- 分组选择器 + 批量操作按钮 -->
|
||||
<div class="selector-actions">
|
||||
<a-space>
|
||||
<!-- 分组选择器 -->
|
||||
<a-select
|
||||
:value="selectorGroupId"
|
||||
placeholder="使用场景默认分组"
|
||||
size="small"
|
||||
style="width: 160px;"
|
||||
allow-clear
|
||||
@change="handleSelectorGroupChange"
|
||||
>
|
||||
<a-select-option v-for="g in groupList" :key="g.id" :value="g.id">
|
||||
{{ g.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<a-divider type="vertical" />
|
||||
<a-button size="small" @click="handleSelectAll">
|
||||
<template #icon><SelectOutlined /></template>
|
||||
全选
|
||||
</a-button>
|
||||
<a-button size="small" @click="handleClearSelection">
|
||||
<template #icon><StopOutlined /></template>
|
||||
清空
|
||||
</a-button>
|
||||
<a-button size="small" @click="handleAutoFill">
|
||||
<template #icon><ThunderboltOutlined /></template>
|
||||
智能填充
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- 候选网格 -->
|
||||
<div class="selector-grid">
|
||||
<div
|
||||
v-for="file in selectorFiles"
|
||||
:key="file.id"
|
||||
class="selector-item"
|
||||
:class="{ 'selector-item--selected': isSelected(file.id) }"
|
||||
@click="toggleSelection(file)"
|
||||
>
|
||||
<div class="selector-thumb">
|
||||
<img v-if="file.coverBase64" :src="file.coverBase64" />
|
||||
<VideoCameraOutlined v-else />
|
||||
</div>
|
||||
<div class="selector-name">{{ file.displayName || file.fileName }}</div>
|
||||
<div v-if="isSelected(file.id)" class="selector-checkmark">
|
||||
<CheckOutlined />
|
||||
</div>
|
||||
<!-- 顶部 -->
|
||||
<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 class="selector-actions-footer">
|
||||
<a-space>
|
||||
<a-button @click="$emit('update:visible', false)">取消</a-button>
|
||||
<a-button type="primary" @click="handleConfirm">确定</a-button>
|
||||
</a-space>
|
||||
<!-- 分组切换 -->
|
||||
<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 } from 'vue'
|
||||
import { computed, ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
VideoCameraOutlined,
|
||||
ThunderboltOutlined,
|
||||
CheckOutlined,
|
||||
SelectOutlined,
|
||||
StopOutlined
|
||||
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
|
||||
}
|
||||
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)
|
||||
|
||||
// 临时选中的文件ID列表
|
||||
const tempSelectedFiles = ref([])
|
||||
|
||||
// 弹窗标题
|
||||
const modalTitle = computed(() =>
|
||||
`场景${props.sceneIndex + 1} - 已选择 ${tempSelectedFiles.value.length}/${props.maxCandidates} 个候选`
|
||||
)
|
||||
|
||||
// 弹窗中显示的素材列表
|
||||
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
|
||||
@@ -148,24 +149,36 @@ const selectorFiles = computed(() => {
|
||||
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 isSelected = (fileId) => tempSelectedFiles.value.includes(fileId)
|
||||
// 键盘快捷键
|
||||
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) {
|
||||
@@ -174,26 +187,31 @@ const toggleSelection = (file) => {
|
||||
if (tempSelectedFiles.value.length < props.maxCandidates) {
|
||||
tempSelectedFiles.value.push(file.id)
|
||||
} else {
|
||||
message.warning(`每个场景最多只能选择${props.maxCandidates}个候选`)
|
||||
message.warning(`最多选择 ${props.maxCandidates} 个素材`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 全选
|
||||
const handleSelectAll = () => {
|
||||
tempSelectedFiles.value = selectorFiles.value.map(f => f.id)
|
||||
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 = () => {
|
||||
if (props.sceneIndex < 0) return
|
||||
|
||||
const availableMaterials = props.groupFiles.filter(
|
||||
const availableMaterials = selectorFiles.value.filter(
|
||||
material => !tempSelectedFiles.value.includes(material.id)
|
||||
)
|
||||
|
||||
@@ -206,18 +224,16 @@ const handleAutoFill = () => {
|
||||
const targetCount = Math.min(3, maxAddCount)
|
||||
|
||||
if (targetCount <= 0) {
|
||||
message.warning(`候选数量已达上限(${props.maxCandidates}个)`)
|
||||
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}个候选素材`)
|
||||
message.success(`已随机选择 ${selected.length} 个素材`)
|
||||
}
|
||||
|
||||
// 分组切换
|
||||
const handleSelectorGroupChange = (groupId) => {
|
||||
selectorGroupId.value = groupId
|
||||
tempSelectedFiles.value = []
|
||||
@@ -226,123 +242,337 @@ const handleSelectorGroupChange = (groupId) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 确认选择
|
||||
const handleConfirm = () => {
|
||||
if (tempSelectedFiles.value.length === 0) {
|
||||
message.warning('请至少选择一个素材')
|
||||
return
|
||||
}
|
||||
emit('confirm', {
|
||||
sceneIndex: props.sceneIndex,
|
||||
fileIds: [...tempSelectedFiles.value]
|
||||
fileIds: [...tempSelectedFiles.value],
|
||||
setGroupId: selectorGroupId.value
|
||||
})
|
||||
emit('update:visible', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.selector-container {
|
||||
.selector-actions {
|
||||
margin-bottom: var(--space-2);
|
||||
padding: var(--space-4);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 弹窗样式 - 使用项目设计系统
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// 🎨 设计令牌引用
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
.selector-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--space-2);
|
||||
max-height: 480px;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-2);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 顶部标题栏
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
.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;
|
||||
|
||||
.selector-item {
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
border: 2px solid transparent;
|
||||
transition: all var(--duration-base) ease;
|
||||
position: relative;
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-inset-card);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--shadow-inset-card);
|
||||
}
|
||||
|
||||
&--selected {
|
||||
border-color: var(--color-success);
|
||||
box-shadow: var(--shadow-inset-card);
|
||||
}
|
||||
|
||||
.selector-thumb {
|
||||
aspect-ratio: 16/9;
|
||||
background: var(--color-bg);
|
||||
.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;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-2xl);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.selector-name {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--font-size-xs);
|
||||
text-align: center;
|
||||
.card-name {
|
||||
padding: var(--space-2);
|
||||
font-size: 11px;
|
||||
color: @text-secondary;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg);
|
||||
background: @bg-card;
|
||||
}
|
||||
}
|
||||
|
||||
.selector-checkmark {
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
right: var(--space-2);
|
||||
background: var(--color-success);
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 底部操作栏
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
.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;
|
||||
justify-content: center;
|
||||
font-size: var(--font-size-base);
|
||||
z-index: 2;
|
||||
box-shadow: 0 2px 4px rgba(16, 185, 129, 0.3);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.selector-actions-footer {
|
||||
margin-top: var(--space-2);
|
||||
padding: var(--space-4);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-md);
|
||||
text-align: right;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.selector-grid {
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 响应式适配
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
@media (max-width: 800px) {
|
||||
.material-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.selector-grid {
|
||||
@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