forked from zhongyidian/super_video
feat: 技能树
This commit is contained in:
1 parent
6ab98c4764
commit
b980f2993b
31 files changed
+861
-292
No files matched your search
@@ -0,0 +1,256 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
getAllModules,
|
||||
getTagsByModule,
|
||||
getSubTagsByTag,
|
||||
getTagResourceCount
|
||||
} from '../resourceData'
|
||||
|
||||
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 isTagExpanded = (tagId: string) => {
|
||||
return props.expandedTags.has(tagId)
|
||||
}
|
||||
|
||||
// 检查标签是否选中
|
||||
const isTagSelected = (moduleId: string, tagId: string) => {
|
||||
return props.selectedModuleId === moduleId && props.selectedTagId === tagId
|
||||
}
|
||||
|
||||
// 检查子标签是否选中
|
||||
const isSubTagSelected = (moduleId: string, tagId: string, subTagId: string) => {
|
||||
return (
|
||||
props.selectedModuleId === moduleId &&
|
||||
props.selectedTagId === tagId &&
|
||||
props.selectedSubTagId === subTagId
|
||||
)
|
||||
}
|
||||
|
||||
// 获取标签的资源数量
|
||||
const getResourceCount = (moduleId: string, tagId: string) => {
|
||||
return getTagResourceCount(moduleId, tagId)
|
||||
}
|
||||
|
||||
// 获取标签颜色类 - 根据标签类型返回不同的颜色
|
||||
const getTagColorClass = (tagName: string, isSelected: boolean) => {
|
||||
if (isSelected) {
|
||||
return 'bg-orange-500 border-white text-white'
|
||||
}
|
||||
|
||||
// 根据标签名称返回不同的颜色
|
||||
const colorMap: Record<string, string> = {
|
||||
后期制作: 'bg-blue-400 border-blue-300 text-white',
|
||||
拍摄执行: 'bg-green-400 border-green-300 text-white',
|
||||
内容策划: 'bg-orange-400 border-orange-300 text-white',
|
||||
视觉素养: 'bg-orange-400 border-orange-300 text-white',
|
||||
摄影基础: 'bg-blue-400 border-blue-300 text-white',
|
||||
剪映基础操作: 'bg-purple-400 border-purple-300 text-white',
|
||||
AI与版权伦理知识: 'bg-blue-400 border-blue-300 text-white',
|
||||
思路提点与案例赏析: 'bg-blue-400 border-blue-300 text-white'
|
||||
}
|
||||
|
||||
return colorMap[tagName] || 'bg-blue-400 border-blue-300 text-white'
|
||||
}
|
||||
|
||||
const handleTagClick = (moduleId: string, tagId: string) => {
|
||||
emit('tagClick', moduleId, tagId)
|
||||
}
|
||||
|
||||
const handleSubTagClick = (moduleId: string, tagId: string, subTagId: string) => {
|
||||
emit('subTagClick', moduleId, tagId, subTagId)
|
||||
}
|
||||
|
||||
// 计算标签节点的位置 - 使用更自然的树形分布
|
||||
const getTagPosition = (
|
||||
moduleIndex: number,
|
||||
tagIndex: number,
|
||||
totalTags: number,
|
||||
moduleId: string
|
||||
) => {
|
||||
// 计算所有标签的总数(用于全局定位)
|
||||
const modules = displayedModules.value
|
||||
let globalTagIndex = 0
|
||||
for (let i = 0; i < moduleIndex; i++) {
|
||||
globalTagIndex += getTagsByModule(modules[i].id).length
|
||||
}
|
||||
globalTagIndex += tagIndex
|
||||
|
||||
// 计算总标签数
|
||||
const totalAllTags = modules.reduce((sum, m) => sum + getTagsByModule(m.id).length, 0)
|
||||
|
||||
// 垂直位置:从树根向上分布,形成树形结构
|
||||
const baseTop = 85 // 树根位置(底部)
|
||||
const maxTop = 15 // 最顶部位置
|
||||
const verticalRange = baseTop - maxTop
|
||||
|
||||
// 根据全局索引计算垂直位置,形成扇形分布
|
||||
const verticalRatio = totalAllTags > 1 ? globalTagIndex / (totalAllTags - 1) : 0
|
||||
const top = baseTop - verticalRange * verticalRatio * 0.75 // 使用0.75让标签更集中在树的上半部分
|
||||
|
||||
// 水平位置:左右交替分布,形成树形
|
||||
const isLeft = globalTagIndex % 2 === 0
|
||||
const horizontalSpread = 28 // 水平展开范围
|
||||
const horizontalOffset = isLeft ? -horizontalSpread : horizontalSpread
|
||||
|
||||
// 根据垂直位置调整水平偏移,形成扇形(越往上,水平偏移越小)
|
||||
const verticalFactor = Math.max(0, (top - baseTop) / (maxTop - baseTop))
|
||||
const left = 50 + horizontalOffset * (1 - verticalFactor * 0.4)
|
||||
|
||||
return {
|
||||
top: `${Math.max(maxTop, Math.min(baseTop, top))}%`,
|
||||
left: `${Math.max(5, Math.min(95, left))}%`
|
||||
}
|
||||
}
|
||||
|
||||
// 计算连接线的起点和终点
|
||||
const getConnectionLine = (
|
||||
moduleIndex: number,
|
||||
tagIndex: number,
|
||||
totalTags: number,
|
||||
moduleId: string
|
||||
) => {
|
||||
const tagPos = getTagPosition(moduleIndex, tagIndex, totalTags, moduleId)
|
||||
const rootX = 50 // 树根中心X位置
|
||||
const rootY = 92 // 树根中心Y位置(底部)
|
||||
|
||||
return {
|
||||
x1: rootX,
|
||||
y1: rootY,
|
||||
x2: parseFloat(tagPos.left),
|
||||
y2: parseFloat(tagPos.top) + 3 // 标签中心位置
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative w-full h-full overflow-hidden">
|
||||
<!-- 技能树背景容器 -->
|
||||
<div class="absolute inset-0">
|
||||
<!-- 树根节点 - 底部居中 -->
|
||||
<div
|
||||
class="absolute bottom-4 left-1/2 -translate-x-1/2 px-8 py-4 bg-gradient-to-r from-blue-500/90 to-blue-400/90 border-2 border-blue-300 rounded-xl text-white text-2xl font-bold shadow-2xl shadow-blue-500/60 z-10"
|
||||
>
|
||||
技能树总览
|
||||
</div>
|
||||
|
||||
<!-- 连接线容器 -->
|
||||
<svg class="absolute inset-0 pointer-events-none z-0" style="overflow: visible">
|
||||
<template v-for="(module, moduleIndex) in displayedModules" :key="`lines-${module.id}`">
|
||||
<template v-for="(tag, tagIndex) in getTagsByModule(module.id)" :key="`line-${tag.id}`">
|
||||
<line
|
||||
:x1="`${getConnectionLine(moduleIndex, tagIndex, getTagsByModule(module.id).length, module.id).x1}%`"
|
||||
:y1="`${getConnectionLine(moduleIndex, tagIndex, getTagsByModule(module.id).length, module.id).y1}%`"
|
||||
:x2="`${getConnectionLine(moduleIndex, tagIndex, getTagsByModule(module.id).length, module.id).x2}%`"
|
||||
:y2="`${getConnectionLine(moduleIndex, tagIndex, getTagsByModule(module.id).length, module.id).y2}%`"
|
||||
stroke="rgba(255, 255, 255, 0.4)"
|
||||
stroke-width="2"
|
||||
class="drop-shadow-sm"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</svg>
|
||||
|
||||
<!-- 模块和标签节点 -->
|
||||
<template v-for="(module, moduleIndex) in displayedModules" :key="module.id">
|
||||
<template v-for="(tag, tagIndex) in getTagsByModule(module.id)" :key="tag.id">
|
||||
<!-- 标签节点 -->
|
||||
<div
|
||||
class="absolute z-10"
|
||||
:style="
|
||||
getTagPosition(moduleIndex, tagIndex, getTagsByModule(module.id).length, module.id)
|
||||
"
|
||||
>
|
||||
<!-- 资源标签节点 -->
|
||||
<div
|
||||
:class="[
|
||||
'relative px-4 py-2.5 rounded-lg border-2 cursor-pointer transition-all shadow-lg hover:scale-110 min-w-[140px] max-w-[180px]',
|
||||
getTagColorClass(tag.name, isTagSelected(module.id, tag.id)),
|
||||
isTagSelected(module.id, tag.id) ? 'ring-2 ring-orange-300 z-20' : ''
|
||||
]"
|
||||
@click="handleTagClick(module.id, tag.id)"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm font-semibold truncate">{{ tag.name }}</span>
|
||||
<div class="flex items-center gap-1.5 flex-shrink-0">
|
||||
<span
|
||||
class="flex items-center justify-center w-5 h-5 bg-white/30 rounded-full text-white text-xs font-bold"
|
||||
>
|
||||
{{ getResourceCount(module.id, tag.id) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="getSubTagsByTag(module.id, tag.id).length > 0"
|
||||
class="text-white text-xs"
|
||||
>
|
||||
{{ isTagExpanded(tag.id) ? '▼' : '▶' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 子标签列表(展开时显示) -->
|
||||
<div
|
||||
v-if="getSubTagsByTag(module.id, tag.id).length > 0 && isTagExpanded(tag.id)"
|
||||
class="absolute top-full left-0 mt-2 space-y-1.5 z-30"
|
||||
>
|
||||
<div
|
||||
v-for="subTag in getSubTagsByTag(module.id, tag.id)"
|
||||
:key="subTag.id"
|
||||
:class="[
|
||||
'px-3 py-1.5 rounded-lg border-2 cursor-pointer transition-all shadow-md hover:scale-105 whitespace-nowrap',
|
||||
isSubTagSelected(module.id, tag.id, subTag.id)
|
||||
? 'bg-orange-400 border-white text-white ring-1 ring-orange-300'
|
||||
: 'bg-blue-300/80 border-blue-200 text-white'
|
||||
]"
|
||||
@click.stop="handleSubTagClick(module.id, tag.id, subTag.id)"
|
||||
>
|
||||
<span class="text-xs font-medium">{{ subTag.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 右侧标签 -->
|
||||
<div class="absolute right-0 top-1/2 -translate-y-1/2 -translate-x-4 z-20">
|
||||
<div
|
||||
class="writing-vertical text-white text-2xl font-bold bg-blue-500/40 px-3 py-6 rounded-lg border border-blue-300/60 shadow-lg"
|
||||
>
|
||||
<span class="inline-block mb-2">●</span>
|
||||
<span>技能树</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.writing-vertical {
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: upright;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user