feat: 技能树

This commit is contained in:
zhongyingmin committed 2025-12-31 01:08:21 +08:00
1 parent 6ab98c4764
commit b980f2993b
31 files changed
+861 -292

No files matched your search

+256
View File
@@ -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>