Files
super_video/src/views/skill/components/SkillTree.vue
T

367 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>