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

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { ResourceItem } from '../resourceData'
const props = defineProps<{
resources: ResourceItem[]
}>()
</script>
<template>
<div class="w-full h-full overflow-y-auto">
<div v-if="props.resources.length === 0" class="text-white text-2xl text-center mt-10">
暂无资源
</div>
<div v-else class="space-y-4">
<div
v-for="resource in props.resources"
:key="resource.id"
class="text-white text-2xl p-4 bg-blue-500/20 rounded cursor-pointer hover:bg-blue-500/30 transition"
>
<div class="font-bold">{{ resource.title }}</div>
<div v-if="resource.description" class="text-lg text-gray-300 mt-2">
{{ resource.description }}
</div>
</div>
</div>
</div>
</template>
<style scoped></style>
+29
View File
@@ -0,0 +1,29 @@
<script setup lang="ts">
interface SearchTag {
type: 'module' | 'tag' | 'subTag'
name: string
}
const props = defineProps<{
tags: SearchTag[]
}>()
</script>
<template>
<div v-if="props.tags.length > 0" class="flex items-center gap-2 flex-wrap max-w-[400px]">
<div
v-for="(tag, index) in props.tags"
:key="index"
class="inline-flex items-center px-3 py-1 rounded-full text-white text-base font-medium whitespace-nowrap border border-white/80"
:class="{
'bg-blue-500/80': tag.type === 'module',
'bg-orange-500/80': tag.type === 'tag',
'bg-purple-500/80': tag.type === 'subTag'
}"
>
{{ tag.name }}
</div>
</div>
</template>
<style scoped></style>
+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>
+233 -292
View File
@@ -1,320 +1,261 @@
<script lang="tsx" setup> <script lang="ts" setup>
defineOptions({ name: 'SkillPage' }) import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { getBackgroundImage, IMAGE_PATHS } from '@/utils'
import { ImgButton } from '@/components/Button' import { ImgButton } from '@/components/Button'
import ModuleSwitcher from './components/ModuleSwitcher.vue' import { useRouter } from 'vue-router'
import CategoryTags from './components/CategoryTags.vue' import { ref, computed } from 'vue'
import IconLayer from './components/IconLayer.vue' import {
import { GuideSteps } from '@/components/Guide' getAllResources,
import KnowledgeCard from '@/components/KnowledgeCard/KnowledgeCard.vue' getResourcesByModule,
import { useSkillState, type KnowledgeBtn } from './useSkillState' getResourcesByTag,
getResourcesBySubTag,
getSubTagsByTag,
type ResourceItem
} from './resourceData'
import SearchTags from './components/SearchTags.vue'
import ResourceList from './components/ResourceList.vue'
import SkillTree from './components/SkillTree.vue'
const router = useRouter() const router = useRouter()
// 仅从 hook 获取必要的业务状态与意图判断 // 搜索框文本(保留用于用户输入搜索)
const { const search = ref('')
currentModule,
nextModule,
prevModule,
selectedCategory,
handleCategoryClick,
currentCategoryConfig,
displayedIcons,
showSubIcons,
selectedPrimaryIcon,
getIconAction
} = useSkillState()
// 页面 UI 状态(留在组件内) /**
const status = ref(false) * 模块列表
setTimeout(() => { * 新手导航:新手导航-选中.png
status.value = true * vlog发现与记录:vlog发现与记录-选中.png
}, 1000) * 访谈聆听与对话:访谈聆听与对话-选中.png
const sideStatus = ref(true) * 电影创作与表达:电影创作与表达-选中.png
*/
// 引导 const moduleList = ref([
const stepIndex = ref(0) {
type SimpleStep = { id: string; content: string } id: 'module-1',
const steps: SimpleStep[] = [{ id: 'step1', content: '可以点击左右箭头切换到对应模块的知识树哦' }] name: '新手导航',
const currentHighlight = computed(() => image: 'skill/新手导航-选中.png'
stepIndex.value === 0 ? '.img-switch-item--vlog' : '.work-border' },
) {
const handleStepChange = (_step: SimpleStep | null, index: number) => { id: 'module-2',
stepIndex.value = index name: 'vlog发现与记录',
} image: 'skill/vlog-选中.png'
},
// 模块切换(本地完成联动重置) {
const onNextModule = () => { id: 'module-3',
nextModule() name: '访谈聆听与对话',
if (selectedCategory.value) { image: 'skill/访谈-选中.png'
selectedCategory.value = '' },
sideStatus.value = true {
id: 'module-4',
name: '电影创作与表达',
image: 'skill/电影-选中.png'
} }
showSubIcons.value = false ])
selectedPrimaryIcon.value = null
// 状态管理
const selectedModuleId = ref<string | null>(null)
const selectedTagId = ref<string | null>(null)
const selectedSubTagId = ref<string | null>(null)
const expandedTags = ref<Set<string>>(new Set())
// 搜索标签数组
interface SearchTag {
type: 'module' | 'tag' | 'subTag'
name: string
} }
const onPrevModule = () => {
prevModule() const searchTags = computed<SearchTag[]>(() => {
if (selectedCategory.value) { const tags: SearchTag[] = []
selectedCategory.value = ''
sideStatus.value = true if (selectedModuleId.value) {
// 从资源数组中查找模块名
const moduleResource = getAllResources().find(r => r.moduleId === selectedModuleId.value)
if (moduleResource) {
tags.push({ type: 'module', name: moduleResource.moduleName })
}
} }
showSubIcons.value = false
selectedPrimaryIcon.value = null
}
// 分类选择(保持原交互) if (selectedModuleId.value && selectedTagId.value) {
const onCategorySelect = (categoryName: string) => { // 从资源数组中查找标签名
handleCategoryClick(categoryName) const tagResource = getAllResources().find(
// 如果已解锁(status=true),则不隐藏侧边栏;否则隐藏 r => r.moduleId === selectedModuleId.value && r.tagId === selectedTagId.value
sideStatus.value = status.value ? true : false )
showSubIcons.value = false if (tagResource) {
selectedPrimaryIcon.value = null tags.push({ type: 'tag', name: tagResource.tagName })
} }
}
// 返回按钮相关 if (selectedModuleId.value && selectedTagId.value && selectedSubTagId.value) {
const backButtonTooltip = computed(() => { // 从资源数组中查找子标签名
if (showSubIcons.value) return '返回上一步' const subTagResource = getAllResources().find(
return selectedCategory.value ? '返回主视图' : '退出页面' r =>
r.moduleId === selectedModuleId.value &&
r.tagId === selectedTagId.value &&
r.subTagId === selectedSubTagId.value
)
if (subTagResource && subTagResource.subTagName) {
tags.push({ type: 'subTag', name: subTagResource.subTagName })
}
}
return tags
}) })
// 过滤后的资源列表
const filteredResources = computed<ResourceItem[]>(() => {
// 如果选择了子标签,返回子标签的资源
if (selectedModuleId.value && selectedTagId.value && selectedSubTagId.value) {
return getResourcesBySubTag(selectedModuleId.value, selectedTagId.value, selectedSubTagId.value)
}
// 如果选择了资源标签,返回标签的资源
if (selectedModuleId.value && selectedTagId.value) {
return getResourcesByTag(selectedModuleId.value, selectedTagId.value)
}
// 如果选择了模块,返回模块的资源
if (selectedModuleId.value) {
return getResourcesByModule(selectedModuleId.value)
}
// 默认返回所有资源
return getAllResources()
})
// 处理模块点击
const handleModuleClick = (moduleId: string | null) => {
selectedModuleId.value = moduleId
selectedTagId.value = null
selectedSubTagId.value = null
expandedTags.value.clear()
}
// 处理资源标签点击
const handleTagClick = (moduleId: string, tagId: string) => {
// 如果点击的是已选中的标签,则折叠
if (selectedModuleId.value === moduleId && selectedTagId.value === tagId) {
if (expandedTags.value.has(tagId)) {
// 如果已展开,则折叠并取消选择
expandedTags.value.delete(tagId)
selectedTagId.value = null
selectedSubTagId.value = null
} else {
// 如果未展开,则展开
expandedTags.value.add(tagId)
}
} else {
// 选择新的标签
selectedModuleId.value = moduleId
selectedTagId.value = tagId
selectedSubTagId.value = null
// 如果标签有子标签,则展开
const subTags = getSubTagsByTag(moduleId, tagId)
if (subTags.length > 0) {
expandedTags.value.add(tagId)
} else {
expandedTags.value.delete(tagId)
}
}
}
// 处理子标签点击
const handleSubTagClick = (moduleId: string, tagId: string, subTagId: string) => {
// 如果点击的是已选中的子标签,则取消选择
if (
selectedModuleId.value === moduleId &&
selectedTagId.value === tagId &&
selectedSubTagId.value === subTagId
) {
selectedSubTagId.value = null
} else {
selectedModuleId.value = moduleId
selectedTagId.value = tagId
selectedSubTagId.value = subTagId
// 确保父标签是展开的
expandedTags.value.add(tagId)
}
}
// 处理模块列表点击
const handleModuleListItemClick = (moduleId: string) => {
handleModuleClick(moduleId === selectedModuleId.value ? null : moduleId)
}
const handleClose = () => { const handleClose = () => {
// if (showSubIcons.value) { router.push({ name: 'Action' })
// showSubIcons.value = false
// selectedPrimaryIcon.value = null
// return
// }
// if (selectedCategory.value) {
// handleBackToMain()
// } else {
router.back()
// }
}
// 知识卡片(本地维护)
const showKnowledgeCard = ref(false)
const currentKnowledge = ref<KnowledgeBtn | null>(null)
const knowledgeMap: Record<'三分法构图' | '居中构图' | '对角线构图', KnowledgeBtn> = {
居中构图: {
key: '1',
name: '居中构图',
btnImage: 'shooting/居中构图底板',
desc: '居中构图就是主体放在画面中心,让画面更稳定主体更突出,拍摄时需避免选取杂乱的背景,可以用大光圈或者长焦距让背景虚化使主体从背景中跳出来适合拍摄单个主体,操作比较简单,非常适合新手使用',
images: ['shooting/居中构图1', 'shooting/居中构图2']
},
三分法构图: {
key: '2',
name: '三分法构图',
btnImage: 'shooting/三分法构图底板',
desc: '三分法构图又称九宫格构图,就是把拍摄主体置于九宫格的任意一条三分位置,三分构图法是比较常见的构图法可以视觉上让整体画面更加生动,使用三分构图法拍摄人像时,尽量占据画面的下2格比例和重心会更协调',
images: ['shooting/图片待定1', 'shooting/图片待定2']
},
对角线构图: {
key: '3',
name: '对角线构图',
btnImage: 'shooting/对角线构图底板',
desc: '对角线构图属于引导线构图的一种,所以也是借助引导线的视觉引导效果来突出强调主体。另外对角线构图在画面中制造出对角线的走向即可。',
images: ['shooting/对角线构图1', 'shooting/对角线构图2']
}
}
const openKnowledge = (name: '三分法构图' | '居中构图' | '对角线构图') => {
currentKnowledge.value = knowledgeMap[name]
showKnowledgeCard.value = true
}
const currentCompositionKey = computed(() => {
const k = currentKnowledge.value?.key
if (k === '1' || k === '2' || k === '3') return k
return '1'
})
// 图标点击(基于 hook 的意图判断)
const handleIconClick = (icon: string) => {
const action = getIconAction(icon)
if (!action) return
if (action.type === 'navigate') {
router.push(action.payload)
return
}
if (action.type === 'expand') {
showSubIcons.value = true
selectedPrimaryIcon.value = 'skill/画面构图选中'
return
}
if (action.type === 'openKnowledge') {
openKnowledge(action.payload.name)
}
} }
</script> </script>
<template> <template>
<div class="w-full h-full relative font-semibold"> <div
<!-- 背景图片-center --> class="w-full h-full bg-cover bg-center"
<div :style="{
class="absolute inset-0 bg-cover bg-center bg-no-repeat" backgroundImage: getBackgroundImage('skill/背景.png'),
:style="{ backgroundImage: getBackgroundImage('skill/技能树背景') }" backgroundSize: 'cover'
></div> }"
>
<ModuleSwitcher :module-name="currentModule.name" @prev="onPrevModule" @next="onNextModule" /> <!-- 关闭 -->
<!-- 周围模块标签(静态绘制) -->
<CategoryTags
:status="status"
:side-status="sideStatus"
:selected-category="selectedCategory"
@select="onCategorySelect"
/>
<!-- 动态显示分类对应的icon -->
<IconLayer
v-if="currentCategoryConfig"
:positions="currentCategoryConfig.positions"
:displayed-icons="displayedIcons"
:side-status="sideStatus"
@iconClick="handleIconClick"
/>
<!-- 返回按钮 -->
<ImgButton <ImgButton
class="absolute right-0 top-0" class="absolute right-0 top-0 z-20"
:image="IMAGE_PATHS.COMPONENTS.BACK" :image="IMAGE_PATHS.COMPONENTS.BACK"
:title="backButtonTooltip"
@click="handleClose" @click="handleClose"
/> />
<!-- 树 --> <img class="absolute right-5 top-44" :src="getStaticImage('skill/技能树.png')" alt="" />
<div
class="absolute top-28 left-[calc(50%-30px)] -translate-x-1/2 w-[786px] h-[875px] bg-cover bg-center bg-no-repeat"
:style="{
backgroundImage: status
? getBackgroundImage('skill/技能树-亮')
: getBackgroundImage('skill/技能树-灰')
}"
></div>
<div class="absolute bottom-10 left-1/2 -translate-x-1/2"> <!-- 搜索框 -->
<span class="red-text text-4xl">{{ <div
!status ? '当前尚未收集任何知识点,快去学习收集知识点吧' : '当前已全部解锁' class="absolute left-10 top-10 w-[547px] h-[84px] bg-cover bg-center pl-[130px] pr-[30px] pt-[20px] pb-[21px] flex items-center gap-2 overflow-hidden"
}}</span> :style="{
backgroundImage: getBackgroundImage('skill/搜索框-已搜索.png'),
backgroundSize: 'cover'
}"
>
<!-- 搜索标签显示 -->
<div class="flex-shrink-0 overflow-x-auto scrollbar-hide">
<SearchTags :tags="searchTags" />
</div>
<!-- 搜索文本输入(可选,用于额外搜索) -->
<input
v-model="search"
type="text"
name="skill"
:placeholder="searchTags.length === 0 ? '请搜索检索' : ''"
maxlength="14"
class="flex-1 min-w-0 h-full bg-transparent border-none placeholder:text-white placeholder:text-2xl outline-none text-white text-2xl"
/>
</div> </div>
<!-- 引导与高亮 --> <!-- 模块列表 -->
<GuideSteps <div class="absolute left-[55%] -translate-x-1/2 top-10 bg-cover bg-center">
v-if="status" <div class="flex gap-10 -mt-7">
:steps="steps" <div
:highlight-selector="currentHighlight" v-for="module in moduleList"
:highlight-padding="12" :key="module.id"
:highlight-radius="20" class="relative w-[141px] h-[150px] cursor-pointer"
@step-change="handleStepChange" :class="{ 'opacity-80': selectedModuleId === module.id }"
position-offset-y="85%" @click="handleModuleListItemClick(module.id)"
/> >
<img class="absolute inset-0" :src="getStaticImage(module.image)" alt="" />
</div>
</div>
</div>
<!-- 知识卡片 --> <!-- 资源列表 -->
<KnowledgeCard <div class="absolute left-10 bottom-20 w-[740px] h-[800px]">
v-if="currentKnowledge" <ResourceList :resources="filteredResources" />
:visible="showKnowledgeCard" </div>
:title="currentKnowledge.name"
:desc="currentKnowledge.desc" <!-- 技能树-资源树 -->
:images="currentKnowledge.images" <div class="absolute right-28 bottom-20 w-[740px] h-[820px]">
:compositionKey="currentCompositionKey" <SkillTree
@close="showKnowledgeCard = false" :selected-module-id="selectedModuleId"
/> :expanded-tags="expandedTags"
:selected-tag-id="selectedTagId"
:selected-sub-tag-id="selectedSubTagId"
@tag-click="handleTagClick"
@sub-tag-click="handleSubTagClick"
/>
</div>
</div> </div>
</template> </template>
<style scoped>
/* 强发光蓝绿主标题(图2风格) */
.blue-text {
/* 文字本体采用蓝绿渐变并保持白色高亮视觉 */
background: linear-gradient(180deg, #cfffff 0%, #9ff3ff 40%, #6ee7ff 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
/* 多层发光,柔边外扩 */
text-shadow:
0 0 2px rgba(255, 255, 255, 0.9),
0 0 6px rgba(79, 241, 255, 0.9),
0 0 14px rgba(79, 241, 255, 0.8),
0 0 28px rgba(0, 199, 255, 0.6),
0 0 46px rgba(0, 199, 255, 0.45);
}
/* 强发光白粉副标题(图2风格) */
.red-text {
background: linear-gradient(180deg, #ffffff 0%, #ffe9e9 55%, #ffd1d1 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
text-shadow:
0 0 2px rgba(255, 255, 255, 0.9),
0 0 8px rgba(255, 135, 135, 0.85),
0 0 18px rgba(255, 135, 135, 0.65),
0 0 32px rgba(255, 110, 110, 0.55);
}
/* 模块标签基础样式 */
.module {
position: absolute;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.3s ease;
}
.module:hover {
transform: scale(1.05);
filter: brightness(1.2);
}
/* 竖排布局 */
.module-vertical {
cursor: pointer;
align-items: flex-end;
}
.module-vertical .module-text {
writing-mode: vertical-rl;
text-orientation: upright;
}
.module-vertical .module-percent {
transform: translateY(6px);
}
.module-text {
/* color: rgba(150, 150, 150, 0.9); */
font-size: 36px;
letter-spacing: 8px;
}
.module-percent {
color: #ffffff;
font-size: 28px;
}
/* 位置近似图2布局 */
.module-left-bottom {
left: 10%;
bottom: 22%;
}
.module-left-middle {
left: 25%;
top: 48%;
}
.module-right-middle {
right: 26%;
top: 52%;
}
.module-right-upper {
right: 18%;
top: 42%;
}
.module-right-far {
right: 8%;
top: 50%;
}
</style>
+314
View File
@@ -0,0 +1,314 @@
// 资源数据结构定义 - 一维数组格式
export interface ResourceItem {
id: string
title: string
description?: string
// 用于检索的属性
moduleId: string
moduleName: string
tagId: string
tagName: string
subTagId?: string
subTagName?: string
// 资源数量(用于技能树显示)
resourceCount?: number
}
// 一维数组格式的资源数据
export const resourceData: ResourceItem[] = [
// 新手导航模块
{
id: 'res-1',
title: '九种构图法',
description: '学习九种基础构图技巧',
moduleId: 'module-1',
moduleName: '新手导航',
tagId: 'tag-1-1',
tagName: '视觉素养',
subTagId: 'subtag-1-1-1',
subTagName: '构图'
},
{
id: 'res-2',
title: '光线运用基础',
description: '掌握基础光线运用技巧',
moduleId: 'module-1',
moduleName: '新手导航',
tagId: 'tag-1-1',
tagName: '视觉素养',
subTagId: 'subtag-1-1-2',
subTagName: '光线'
},
{
id: 'res-3',
title: '运镜技巧入门',
description: '学习基础运镜方法',
moduleId: 'module-1',
moduleName: '新手导航',
tagId: 'tag-1-1',
tagName: '视觉素养',
subTagId: 'subtag-1-1-3',
subTagName: '运镜'
},
{
id: 'res-4',
title: '摄影基础教程',
description: '摄影基础知识讲解',
moduleId: 'module-1',
moduleName: '新手导航',
tagId: 'tag-1-2',
tagName: '摄影基础'
},
{
id: 'res-5',
title: '剪映入门',
description: '剪映基础操作教程',
moduleId: 'module-1',
moduleName: '新手导航',
tagId: 'tag-1-3',
tagName: '剪映基础操作'
},
// vlog发现与记录模块
{
id: 'res-6',
title: '设备准备指南',
description: 'vlog拍摄设备准备',
moduleId: 'module-2',
moduleName: 'vlog发现与记录',
tagId: 'tag-2-1',
tagName: '内容策划',
subTagId: 'subtag-2-1-1',
subTagName: '设备准备'
},
{
id: 'res-7',
title: 'vlog脚本撰写',
description: '如何撰写vlog脚本',
moduleId: 'module-2',
moduleName: 'vlog发现与记录',
tagId: 'tag-2-1',
tagName: '内容策划',
subTagId: 'subtag-2-1-2',
subTagName: '脚本制作'
},
{
id: 'res-8',
title: 'vlog构图技巧',
description: 'vlog拍摄构图方法',
moduleId: 'module-2',
moduleName: 'vlog发现与记录',
tagId: 'tag-2-2',
tagName: '拍摄执行',
subTagId: 'subtag-2-2-1',
subTagName: '构图'
},
{
id: 'res-9',
title: 'vlog光线控制',
description: 'vlog拍摄光线运用',
moduleId: 'module-2',
moduleName: 'vlog发现与记录',
tagId: 'tag-2-2',
tagName: '拍摄执行',
subTagId: 'subtag-2-2-2',
subTagName: '光线运用'
},
{
id: 'res-10',
title: 'vlog运镜方法',
description: 'vlog拍摄运镜技巧',
moduleId: 'module-2',
moduleName: 'vlog发现与记录',
tagId: 'tag-2-2',
tagName: '拍摄执行',
subTagId: 'subtag-2-2-3',
subTagName: '运镜技巧'
},
{
id: 'res-11',
title: 'vlog后期剪辑',
description: 'vlog视频后期制作',
moduleId: 'module-2',
moduleName: 'vlog发现与记录',
tagId: 'tag-2-3',
tagName: '后期制作'
},
// 访谈聆听与对话模块
{
id: 'res-12',
title: '访谈选题方法',
description: '如何选择访谈主题',
moduleId: 'module-3',
moduleName: '访谈聆听与对话',
tagId: 'tag-3-1',
tagName: '内容策划',
subTagId: 'subtag-3-1-1',
subTagName: '选题与构思'
},
{
id: 'res-13',
title: '访谈脚本制作',
description: '访谈脚本撰写技巧',
moduleId: 'module-3',
moduleName: '访谈聆听与对话',
tagId: 'tag-3-1',
tagName: '内容策划',
subTagId: 'subtag-3-1-2',
subTagName: '脚本与分镜制作'
},
{
id: 'res-14',
title: '访谈拍摄技巧',
description: '访谈视频拍摄方法',
moduleId: 'module-3',
moduleName: '访谈聆听与对话',
tagId: 'tag-3-2',
tagName: '拍摄执行'
},
// 电影创作与表达模块
{
id: 'res-15',
title: '电影设备准备',
description: '电影拍摄设备清单',
moduleId: 'module-4',
moduleName: '电影创作与表达',
tagId: 'tag-4-1',
tagName: '内容策划',
subTagId: 'subtag-4-1-1',
subTagName: '设备准备'
},
{
id: 'res-16',
title: '电影选题构思',
description: '电影创作选题方法',
moduleId: 'module-4',
moduleName: '电影创作与表达',
tagId: 'tag-4-1',
tagName: '内容策划',
subTagId: 'subtag-4-1-2',
subTagName: '选题与构思'
},
{
id: 'res-17',
title: '电影脚本分镜',
description: '电影脚本和分镜制作',
moduleId: 'module-4',
moduleName: '电影创作与表达',
tagId: 'tag-4-1',
tagName: '内容策划',
subTagId: 'subtag-4-1-3',
subTagName: '脚本与分镜制作'
},
{
id: 'res-18',
title: '电影拍摄执行',
description: '电影拍摄执行技巧',
moduleId: 'module-4',
moduleName: '电影创作与表达',
tagId: 'tag-4-2',
tagName: '拍摄执行'
},
{
id: 'res-19',
title: '电影后期制作',
description: '电影后期剪辑与特效',
moduleId: 'module-4',
moduleName: '电影创作与表达',
tagId: 'tag-4-3',
tagName: '后期制作'
},
{
id: 'res-20',
title: 'AI与版权伦理',
description: 'AI工具使用与版权规范',
moduleId: 'module-4',
moduleName: '电影创作与表达',
tagId: 'tag-4-4',
tagName: 'AI与版权伦理知识'
},
{
id: 'res-21',
title: '创作思路与案例',
description: '电影创作思路和案例分析',
moduleId: 'module-4',
moduleName: '电影创作与表达',
tagId: 'tag-4-5',
tagName: '思路提点与案例赏析'
}
]
// 从一维数组获取所有资源
export function getAllResources(): ResourceItem[] {
return resourceData
}
// 根据模块ID获取资源
export function getResourcesByModule(moduleId: string): ResourceItem[] {
return resourceData.filter(resource => resource.moduleId === moduleId)
}
// 根据资源标签ID获取资源
export function getResourcesByTag(moduleId: string, tagId: string): ResourceItem[] {
return resourceData.filter(resource => resource.moduleId === moduleId && resource.tagId === tagId)
}
// 根据子标签ID获取资源
export function getResourcesBySubTag(
moduleId: string,
tagId: string,
subTagId: string
): ResourceItem[] {
return resourceData.filter(
resource =>
resource.moduleId === moduleId && resource.tagId === tagId && resource.subTagId === subTagId
)
}
// 获取所有模块
export function getAllModules(): Array<{ id: string; name: string }> {
const modules = new Map<string, string>()
resourceData.forEach(resource => {
if (!modules.has(resource.moduleId)) {
modules.set(resource.moduleId, resource.moduleName)
}
})
return Array.from(modules.entries()).map(([id, name]) => ({ id, name }))
}
// 获取模块下的所有标签
export function getTagsByModule(moduleId: string): Array<{ id: string; name: string }> {
const tags = new Map<string, string>()
resourceData
.filter(resource => resource.moduleId === moduleId)
.forEach(resource => {
if (!tags.has(resource.tagId)) {
tags.set(resource.tagId, resource.tagName)
}
})
return Array.from(tags.entries()).map(([id, name]) => ({ id, name }))
}
// 获取标签下的所有子标签
export function getSubTagsByTag(
moduleId: string,
tagId: string
): Array<{ id: string; name: string }> {
const subTags = new Map<string, string>()
resourceData
.filter(
resource => resource.moduleId === moduleId && resource.tagId === tagId && resource.subTagId
)
.forEach(resource => {
if (resource.subTagId && !subTags.has(resource.subTagId)) {
subTags.set(resource.subTagId, resource.subTagName || '')
}
})
return Array.from(subTags.entries()).map(([id, name]) => ({ id, name }))
}
// 获取标签的资源数量
export function getTagResourceCount(moduleId: string, tagId: string): number {
return resourceData.filter(resource => resource.moduleId === moduleId && resource.tagId === tagId)
.length
}