feat: 优化

This commit is contained in:
2026-03-04 02:13:16 +08:00
parent aa06782953
commit 7f5d9d9f19
17 changed files with 1958 additions and 1727 deletions

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

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

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

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

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

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