forked from zhongyidian/super_video
367 lines
13 KiB
Vue
367 lines
13 KiB
Vue
<script setup lang="ts">
|
||
import { computed } from 'vue'
|
||
import {
|
||
getAllModules,
|
||
getTagsByModule,
|
||
getSubTagsByTag,
|
||
getTagResourceCount
|
||
} from '../resourceData'
|
||
import { getStaticImage, getTagColor } from '@/utils'
|
||
|
||
interface Props {
|
||
selectedModuleId: string | null
|
||
expandedTags: Set<string>
|
||
selectedTagId: string | null
|
||
selectedSubTagId: string | null
|
||
}
|
||
|
||
const props = defineProps<Props>()
|
||
|
||
const emit = defineEmits<{
|
||
(e: 'tagClick', moduleId: string, tagId: string): void
|
||
(e: 'subTagClick', moduleId: string, tagId: string, subTagId: string): void
|
||
}>()
|
||
|
||
// 获取所有模块
|
||
const allModules = computed(() => getAllModules())
|
||
|
||
// 计算要显示的模块
|
||
const displayedModules = computed(() => {
|
||
if (props.selectedModuleId) {
|
||
return allModules.value.filter(m => m.id === props.selectedModuleId)
|
||
}
|
||
return allModules.value
|
||
})
|
||
|
||
// 获取所有要显示的标签(按标签名称去重,并按固定顺序排列)
|
||
const displayedTags = computed(() => {
|
||
const tagMap = new Map<string, { moduleId: string; tagId: string; tagName: string }>()
|
||
|
||
displayedModules.value.forEach(module => {
|
||
const tags = getTagsByModule(module.id)
|
||
tags.forEach(tag => {
|
||
// 使用标签名称作为唯一键,避免同名标签重复显示
|
||
if (!tagMap.has(tag.name)) {
|
||
tagMap.set(tag.name, {
|
||
moduleId: module.id,
|
||
tagId: tag.id,
|
||
tagName: tag.name
|
||
})
|
||
}
|
||
})
|
||
})
|
||
|
||
// 定义固定的标签顺序(与位置数组对应)
|
||
const tagOrder = [
|
||
'视觉素养',
|
||
'摄影基础',
|
||
'剪映基础操作',
|
||
'内容策划',
|
||
'拍摄执行',
|
||
'后期制作',
|
||
'AI与版权伦理知识',
|
||
'思路提点与案例赏析'
|
||
]
|
||
|
||
// 按照固定顺序返回标签
|
||
const orderedTags: Array<{ moduleId: string; tagId: string; tagName: string }> = []
|
||
tagOrder.forEach(tagName => {
|
||
if (tagMap.has(tagName)) {
|
||
orderedTags.push(tagMap.get(tagName)!)
|
||
}
|
||
})
|
||
|
||
// 添加任何不在固定顺序中的标签
|
||
tagMap.forEach((tag, tagName) => {
|
||
if (!tagOrder.includes(tagName)) {
|
||
orderedTags.push(tag)
|
||
}
|
||
})
|
||
|
||
return orderedTags
|
||
})
|
||
|
||
// 检查标签是否展开
|
||
const isTagExpanded = (tagId: string) => {
|
||
return props.expandedTags.has(tagId)
|
||
}
|
||
|
||
// 检查标签是否选中
|
||
const isTagSelected = (moduleId: string, tagId: string) => {
|
||
if (props.selectedModuleId) {
|
||
// 模块模式:需要模块和标签都匹配
|
||
return props.selectedModuleId === moduleId && props.selectedTagId === tagId
|
||
} else {
|
||
// 总览模式:只需要标签匹配(因为总览模式下 selectedModuleId 为 null)
|
||
return props.selectedTagId === tagId
|
||
}
|
||
}
|
||
|
||
// 检查子标签是否选中
|
||
const isSubTagSelected = (moduleId: string, tagId: string, subTagId: string) => {
|
||
if (props.selectedModuleId) {
|
||
// 模块模式:需要模块、标签和子标签都匹配
|
||
return (
|
||
props.selectedModuleId === moduleId &&
|
||
props.selectedTagId === tagId &&
|
||
props.selectedSubTagId === subTagId
|
||
)
|
||
} else {
|
||
// 总览模式:只需要标签和子标签匹配
|
||
return props.selectedTagId === tagId && props.selectedSubTagId === subTagId
|
||
}
|
||
}
|
||
|
||
// 获取标签的资源数量(如果显示所有模块,则合并同名标签的数量)
|
||
const getResourceCount = (moduleId: string, tagId: string, tagName: string) => {
|
||
if (props.selectedModuleId) {
|
||
// 如果选中了模块,只计算该模块的资源数量
|
||
return getTagResourceCount(moduleId, tagId)
|
||
} else {
|
||
// 如果显示所有模块,计算所有模块中同名标签的资源总数
|
||
let totalCount = 0
|
||
displayedModules.value.forEach(module => {
|
||
const tags = getTagsByModule(module.id)
|
||
tags.forEach(tag => {
|
||
if (tag.name === tagName) {
|
||
totalCount += getTagResourceCount(module.id, tag.id)
|
||
}
|
||
})
|
||
})
|
||
return totalCount
|
||
}
|
||
}
|
||
|
||
// 获取标签颜色类 - 根据标签类型返回不同的颜色(匹配目标效果图)
|
||
const getTagColorClass = (tagName: string, isSelected: boolean) => {
|
||
if (isSelected) {
|
||
return getTagColor(tagName) + ' brightness-125'
|
||
}
|
||
|
||
return getTagColor(tagName)
|
||
}
|
||
|
||
const handleTagClick = (moduleId: string, tagId: string) => {
|
||
emit('tagClick', moduleId, tagId)
|
||
}
|
||
|
||
const handleSubTagClick = (moduleId: string, tagId: string, subTagId: string) => {
|
||
emit('subTagClick', moduleId, tagId, subTagId)
|
||
}
|
||
|
||
// 获取子标签的图标
|
||
const getSubTagIcon = (subTagName: string) => {
|
||
// 图标文件名映射
|
||
const iconMap: Record<string, string> = {
|
||
构图: 'skill/icon/构图.png',
|
||
光线: 'skill/icon/光线.png',
|
||
运镜: 'skill/icon/运镜.png',
|
||
设备准备: 'skill/icon/设备准备.png',
|
||
脚本制作: 'skill/icon/脚本与分镜制作.png',
|
||
光线运用: 'skill/icon/光线运用.png',
|
||
运镜技巧: 'skill/icon/运镜技巧.png',
|
||
选题与构思: 'skill/icon/选题与构思.png',
|
||
脚本与分镜制作: 'skill/icon/脚本与分镜制作.png',
|
||
拍摄技巧: 'skill/icon/拍摄技巧.png',
|
||
剪辑技巧: 'skill/icon/剪辑技巧.png',
|
||
包装: 'skill/icon/包装.png',
|
||
调色: 'skill/icon/调色.png',
|
||
音效: 'skill/icon/音效.png',
|
||
素材管理: 'skill/icon/素材管理.png',
|
||
蒙太奇剪辑: 'skill/icon/蒙太奇剪辑.png',
|
||
版权意识: 'skill/icon/版权意识.png',
|
||
ai技术应用: 'skill/icon/ai技术应用.png',
|
||
思路提点: 'skill/icon/思路提点.png',
|
||
案例合集: 'skill/icon/案例合集.png'
|
||
}
|
||
return iconMap[subTagName] || 'skill/icon/构图.png' // 默认图标
|
||
}
|
||
|
||
// 获取技能树标题
|
||
const getTreeTitle = () => {
|
||
if (!props.selectedModuleId) {
|
||
return '技能树总览'
|
||
}
|
||
const module = allModules.value.find(m => m.id === props.selectedModuleId)
|
||
return module?.name || '技能树总览'
|
||
}
|
||
|
||
// 计算标签节点的位置 - 根据目标效果图1的布局
|
||
const getTagPosition = (tagIndex: number) => {
|
||
// 定义所有标签的具体位置(根据目标效果图1)
|
||
// 标签顺序:视觉素养、摄影基础、剪映基础操作、内容策划、拍摄执行、后期制作、AI与版权伦理知识、思路提点与案例赏析
|
||
const positions = [
|
||
{ top: 38, left: 33 }, // 0: 视觉素养 - 顶部左侧
|
||
{ top: 51, left: 10 }, // 1: 摄影基础 - 顶部右侧偏左
|
||
{ top: 51, left: 58 }, // 2: 剪映基础操作 - 顶部右侧
|
||
{ top: 32, left: 4 }, // 3: 内容策划 - 中部左侧
|
||
{ top: 18, left: 14 }, // 4: 拍摄执行 - 中部右侧(部分在外)
|
||
{ top: 5, left: 35 }, // 5: 后期制作 - 中下部左侧
|
||
{ top: 22, left: 48 }, // 6: AI与版权伦理知识 - 中下部中间
|
||
{ top: 35, left: 64 } // 7: 思路提点与案例赏析 - 中下部右侧
|
||
]
|
||
|
||
// 如果标签数量超过预定义位置,使用循环
|
||
const position = positions[tagIndex % positions.length]
|
||
|
||
return {
|
||
top: `${position.top}%`,
|
||
left: `${position.left}%`
|
||
}
|
||
}
|
||
|
||
// 计算子标签围绕父标签的位置(圆形分布)
|
||
const getSubTagPosition = (subTagIndex: number, totalSubTags: number) => {
|
||
// 圆形半径(单位:像素)- 根据子标签数量调整半径
|
||
// 使用更大的半径确保子标签不会挤在一起
|
||
const baseRadius = 160 // 基础半径180px
|
||
const radius = totalSubTags <= 3 ? baseRadius : baseRadius + (totalSubTags - 3) * 30
|
||
|
||
// 计算角度:均匀分布在360度圆周上
|
||
// 从顶部开始(-90度),顺时针分布
|
||
const angleStep = (360 / totalSubTags) * (Math.PI / 180) // 转换为弧度
|
||
const angle = -Math.PI / 2 + subTagIndex * angleStep // -90度开始
|
||
|
||
// 计算子标签的位置(相对于父标签中心,使用像素单位)
|
||
const offsetX = radius * Math.cos(angle)
|
||
const offsetY = radius * Math.sin(angle)
|
||
|
||
return {
|
||
transform: `translate(calc(-50% + ${offsetX}px), calc(-50% + ${offsetY + 30}px))` // 让子标签以自身中心定位,并应用偏移
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="relative w-full h-full select-none">
|
||
<!-- 技能树背景容器 -->
|
||
<div class="absolute inset-0">
|
||
<!-- 模块名称 - 底部居中 -->
|
||
<div
|
||
class="absolute bottom-4 left-1/2 -translate-x-1/2 text-white text-5xl font-bold [text-shadow:_0px_0px_20px_#3fcdd6,_0px_0px_20px_#3fcdd6,_0px_0px_20px_#3fcdd6,_0px_0px_20px_#3fcdd6] z-30"
|
||
>
|
||
{{ getTreeTitle() }}
|
||
</div>
|
||
|
||
<!-- 模块和标签节点 -->
|
||
<template v-for="(tag, tagIndex) in displayedTags" :key="`${tag.moduleId}-${tag.tagId}`">
|
||
<!-- 标签节点容器 - 选中的标签提高 z-index 显示在蒙版之上 -->
|
||
<div
|
||
class="absolute"
|
||
:class="isTagSelected(tag.moduleId, tag.tagId) ? 'z-20' : 'z-[1]'"
|
||
:style="getTagPosition(tagIndex)"
|
||
>
|
||
<!-- 资源标签节点 -->
|
||
<div class="relative">
|
||
<!-- 标签主体容器 - 用于精确对齐定位层 -->
|
||
<div class="relative">
|
||
<!-- 标签主体 -->
|
||
<div
|
||
:class="[
|
||
'relative py-5 cursor-pointer min-w-[240px] max-w-[220px] transition-all hover:scale-105',
|
||
'border-4 rounded-full shadow-lg',
|
||
getTagColorClass(tag.tagName, isTagSelected(tag.moduleId, tag.tagId)),
|
||
isTagSelected(tag.moduleId, tag.tagId) ? 'ring-2 ring-white/60' : ''
|
||
]"
|
||
@click="handleTagClick(tag.moduleId, tag.tagId)"
|
||
>
|
||
<div class="flex items-center justify-center gap-2 relative z-10">
|
||
<span class="text-2xl font-bold truncate">{{ tag.tagName }}</span>
|
||
<!-- 展开箭头 -->
|
||
<!-- <span
|
||
v-if="getSubTagsByTag(tag.moduleId, tag.tagId).length > 0"
|
||
class="text-white text-xs flex-shrink-0"
|
||
>
|
||
{{ isTagExpanded(tag.tagId) ? '▼' : '▶' }}
|
||
</span> -->
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 子标签定位层 - 使用 inset-0 与标签主体完全对齐(包括 border 和 padding) -->
|
||
<div
|
||
v-if="
|
||
getSubTagsByTag(tag.moduleId, tag.tagId).length > 0 && isTagExpanded(tag.tagId)
|
||
"
|
||
class="absolute inset-0 pointer-events-none"
|
||
>
|
||
<!-- 子标签列表(围绕父标签圆形分布) -->
|
||
<div
|
||
v-for="(subTag, subTagIndex) in getSubTagsByTag(tag.moduleId, tag.tagId)"
|
||
:key="subTag.id"
|
||
class="absolute z-30 pointer-events-auto"
|
||
style="top: 50%; left: 50%"
|
||
:style="
|
||
getSubTagPosition(subTagIndex, getSubTagsByTag(tag.moduleId, tag.tagId).length)
|
||
"
|
||
>
|
||
<div
|
||
:class="[
|
||
'flex flex-col items-center justify-center cursor-pointer transition-all hover:scale-105'
|
||
// isSubTagSelected(tag.moduleId, tag.tagId, subTag.id)
|
||
// ? 'border-orange-400 ring-2 ring-orange-300'
|
||
// : 'border-white/50'
|
||
]"
|
||
@click.stop="handleSubTagClick(tag.moduleId, tag.tagId, subTag.id)"
|
||
>
|
||
<!-- 图标-图标资源已经带了文字 -->
|
||
<div class="w-30">
|
||
<img :src="getStaticImage(getSubTagIcon(subTag.name))" :alt="subTag.name" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 数字徽章 - 右上角外侧 -->
|
||
<div
|
||
class="absolute -top-4 -right-4 flex items-center justify-center w-12 h-12 bg-[#5287c1] rounded-full text-white text-3xl font-bold shadow-lg"
|
||
>
|
||
{{ getResourceCount(tag.moduleId, tag.tagId, tag.tagName) }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- 点击之后的蒙版 - 使用径向渐变,从中心透明到边缘半透明黑色 -->
|
||
<transition
|
||
enter-active-class="transition-opacity duration-300 ease-out"
|
||
leave-active-class="transition-opacity duration-300 ease-in"
|
||
enter-from-class="opacity-0"
|
||
enter-to-class="opacity-100"
|
||
leave-from-class="opacity-100"
|
||
leave-to-class="opacity-0"
|
||
>
|
||
<div
|
||
v-if="props.selectedTagId"
|
||
class="absolute inset-0 z-10 pointer-events-auto"
|
||
style="
|
||
background: radial-gradient(
|
||
circle at center,
|
||
rgba(46, 141, 218, 0.8) 0%,
|
||
rgba(46, 141, 218, 0.4) 30%,
|
||
rgba(46, 141, 218, 0.3) 60%,
|
||
transparent 70%
|
||
);
|
||
"
|
||
></div>
|
||
<!-- <img
|
||
:src="getStaticImage('skill/树蒙版.png')"
|
||
alt=""
|
||
class="absolute w-full h-full inset-0 z-20"
|
||
v-if="props.selectedTagId"
|
||
/> -->
|
||
</transition>
|
||
|
||
<!-- 右侧标签 -->
|
||
<img class="absolute right-4 top-0 z-30" :src="getStaticImage('skill/技能树标题.png')" alt="" />
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.writing-vertical {
|
||
writing-mode: vertical-rl;
|
||
text-orientation: upright;
|
||
}
|
||
</style>
|