feat: 完善技能树功能架构与逻辑流程文档,新增视频播放与学习进度追踪功能

This commit is contained in:
zhongyidian committed 2026-01-03 17:31:24 +08:00
1 parent 6090abf955
commit e9a1fee27f
42 files changed
+1849 -760

No files matched your search

+251 -147
View File
@@ -6,6 +6,7 @@ import {
getSubTagsByTag,
getTagResourceCount
} from '../resourceData'
import { getStaticImage, getTagColor } from '@/utils'
interface Props {
selectedModuleId: string | null
@@ -32,6 +33,54 @@ const displayedModules = computed(() => {
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)
@@ -39,42 +88,57 @@ const isTagExpanded = (tagId: string) => {
// 检查标签是否选中
const isTagSelected = (moduleId: string, tagId: string) => {
return props.selectedModuleId === moduleId && props.selectedTagId === tagId
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) => {
return (
props.selectedModuleId === moduleId &&
props.selectedTagId === tagId &&
props.selectedSubTagId === subTagId
)
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) => {
return getTagResourceCount(moduleId, tagId)
// 获取标签的资源数量(如果显示所有模块,则合并同名标签的数量)
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 'bg-orange-500 border-white text-white'
return getTagColor(tagName) + ' brightness-125'
}
// 根据标签名称返回不同的颜色
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'
return getTagColor(tagName)
}
const handleTagClick = (moduleId: string, tagId: string) => {
@@ -85,166 +149,206 @@ 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
// 获取子标签的图标
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'
}
globalTagIndex += tagIndex
return iconMap[subTagName] || 'skill/icon/构图.png' // 默认图标
}
// 计算总标签数
const totalAllTags = modules.reduce((sum, m) => sum + getTagsByModule(m.id).length, 0)
// 获取技能树标题
const getTreeTitle = () => {
if (!props.selectedModuleId) {
return '技能树总览'
}
const module = allModules.value.find(m => m.id === props.selectedModuleId)
return module?.name || '技能树总览'
}
// 垂直位置:从树根向上分布,形成树形结构
const baseTop = 85 // 树根位置(底部)
const maxTop = 15 // 最顶部位置
const verticalRange = baseTop - maxTop
// 计算标签节点的位置 - 根据目标效果图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 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)
// 如果标签数量超过预定义位置,使用循环
const position = positions[tagIndex % positions.length]
return {
top: `${Math.max(maxTop, Math.min(baseTop, top))}%`,
left: `${Math.max(5, Math.min(95, left))}%`
top: `${position.top}%`,
left: `${position.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位置(底部)
// 计算子标签围绕父标签的位置(圆形分布)
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 {
x1: rootX,
y1: rootY,
x2: parseFloat(tagPos.left),
y2: parseFloat(tagPos.top) + 3 // 标签中心位置
transform: `translate(calc(-50% + ${offsetX}px), calc(-50% + ${offsetY + 30}px))` // 让子标签以自身中心定位,并应用偏移
}
}
</script>
<template>
<div class="relative w-full h-full overflow-hidden">
<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 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"
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>
<!-- 连接线容器 -->
<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"
<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"
>
{{ getResourceCount(module.id, tag.id) }}
</span>
<span
v-if="getSubTagsByTag(module.id, tag.id).length > 0"
class="text-white text-xs"
{{ 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)"
>
{{ isTagExpanded(tag.id) ? '▼' : '▶' }}
</span>
<!-- 图标-图标资源已经带了文字 -->
<div class="w-30">
<img :src="getStaticImage(getSubTagIcon(subTag.name))" :alt="subTag.name" />
</div>
</div>
</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"
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"
>
<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>
{{ getResourceCount(tag.moduleId, tag.tagId, tag.tagName) }}
</div>
</div>
</template>
</div>
</template>
</div>
<!-- 右侧标签 -->
<div class="absolute right-0 top-1/2 -translate-y-1/2 -translate-x-4 z-20">
<!-- 点击之后的蒙版 - 使用径向渐变,从中心透明到边缘半透明黑色 -->
<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
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>
v-if="props.selectedTagId"
class="absolute inset-0 z-10 pointer-events-auto"
style="
background: radial-gradient(
circle at center,
rgba(0, 0, 0, 0.5) 0%,
rgba(0, 0, 0, 0.4) 20%,
rgba(0, 0, 0, 0.3) 40%,
transparent 70%
);
"
></div>
</transition>
<!-- 右侧标签 -->
<img class="absolute right-4 top-0 z-30" :src="getStaticImage('skill/技能树标题.png')" alt="" />
</div>
</template>