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

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

+7 -7
View File
@@ -154,14 +154,14 @@ const currentStepContent = computed(() => {
const step = currentStepData.value
if (!step) return ''
if (!step.title) {
return `${step.content}`
}
// if (!step.title) {
return `${step.content}`
// }
return `
<h3 class="text-lg font-bold mb-2">${step.title}</h3>
<div class="leading-relaxed">${step.content}</div>
`
// return `
// <h3 class="text-lg font-bold mb-2">${step.title}</h3>
// <div class="leading-relaxed">${step.content}</div>
// `
})
const handleNext = () => {
+3 -31
View File
@@ -56,7 +56,7 @@
</div> -->
<!-- 步骤标签 -->
<div class="step-label" :class="`step-label--${step.type}`">
<div class="step-label" :class="getTagColor(step.label)">
{{ step.label }}
</div>
@@ -93,6 +93,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, nextTick, watch } from 'vue'
import { getTagColor } from '@/utils'
export interface StepItem {
id: string
@@ -354,38 +355,9 @@ onMounted(() => {
.step-label {
@apply px-16 py-4 rounded-full text-2xl font-semibold mr-4 whitespace-nowrap;
color: white;
min-width: 5rem;
text-align: center;
}
.step-label--shooting {
background: linear-gradient(135deg, #22c55e, #16a34a);
box-shadow: 0 2px 8px rgba(34, 197, 94, 0.4);
}
.step-label--planning {
background: linear-gradient(135deg, #e38872, #e38872);
box-shadow: 0 2px 8px rgba(249, 115, 22, 0.4);
}
/* 蓝色 */
.step-label--blue {
background: linear-gradient(135deg, #0ea5e9, #0284c7);
box-shadow: 0 2px 8px rgba(14, 165, 233, 0.4);
}
/* 天蓝色 */
.step-label--post {
/* background: linear-gradient(180deg, #9ed5ee, #0ea5e9); */
background-color: #70c7ef;
box-shadow: 0 2px 8px rgba(14, 165, 233, 0.4);
}
.step-label--other {
/* background: linear-gradient(135deg, #a855f7, #9445de); */
background-color: #5f15d7;
box-shadow: 0 2px 8px rgba(168, 85, 247, 0.4);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.step-content {
+15
View File
@@ -221,3 +221,18 @@ export const getPublicPath = (path: string) => {
// 使用 Vite 的 BASE_URL 环境变量来处理基础路径
return `${import.meta.env.BASE_URL}${cleanPath}`
}
// 获取标签颜色
export const getTagColor = (tagName: string) => {
const colorMap: Record<string, string> = {
后期制作: 'bg-[#49dcda] border-white text-white',
拍摄执行: 'bg-[#98d265] border-white text-white',
内容策划: 'bg-[#f1a554] border-white text-white',
视觉素养: 'bg-[#e38872] border-white text-white',
摄影基础: 'bg-[#2cb067] border-white text-white',
剪映基础操作: 'bg-[#945bc6] border-white text-white',
AI与版权伦理知识: 'bg-[#6f8cf9] border-white text-white',
思路提点与案例赏析: 'bg-[#32a4fb] border-white text-white'
}
return colorMap[tagName] || 'bg-blue-400 border-white/60 text-white'
}
@@ -30,37 +30,37 @@ const showQuiz = ref(false) // 控制是否显示答题窗口
const rawSteps = [
{
id: 'step2',
label: '拍摄基础',
label: '摄影基础',
content: '手机摄像头介绍',
type: 'post' as const
},
{
id: 'step3',
label: '拍摄基础',
label: '摄影基础',
content: '景别概念介绍',
type: 'post' as const
},
{
id: 'step4',
label: '拍摄基础',
label: '摄影基础',
content: '手机慢动作',
type: 'post' as const
},
{
id: 'step5',
label: '拍摄基础',
label: '摄影基础',
content: '延时摄影',
type: 'post' as const
},
{
id: 'step6',
label: '拍摄基础',
label: '摄影基础',
content: '神奇的微距摄影',
type: 'post' as const
},
{
id: 'step7',
label: '拍摄基础',
label: '摄影基础',
content: '独具魅力的夜景摄影',
type: 'post' as const
}
+3 -3
View File
@@ -33,19 +33,19 @@ const handleComplete = () => {
const navigationSteps = ref([
{
id: 'step2',
label: '光的基础',
label: '视觉素养',
content: '光的基础认知',
type: 'planning' as const
},
{
id: 'step3',
label: '光的属性',
label: '视觉素养',
content: '光的四大属性',
type: 'planning' as const
},
{
id: 'step4',
label: '拍摄技巧',
label: '视觉素养',
content: '拍摄的黄金时间',
type: 'planning' as const
}
@@ -1,5 +1,50 @@
<script lang="ts" setup></script>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
import { Video } from '@/components'
import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils'
const router = useRouter()
const handleBack = () => {
router.back()
}
// 创作随机性视频 URL(暂时用其他视频代替)
const randomVideoUrl = getPublicPath('video/on-site/dygmk_czsjx_batch.mp4')
// 视频播放状态
const videoPlaying = ref(false)
</script>
<template>
<div></div>
<div class="relative w-full h-full flex items-center justify-center overflow-hidden">
<!-- 背景 -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat -z-10"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.SHOOTING.BG) }"
></div>
<!-- 视频播放区域 -->
<div class="w-full h-full bg-black/40 backdrop-blur-md rounded-3xl border border-white/20">
<Video
class="w-full h-full"
:src="randomVideoUrl"
v-model="videoPlaying"
:muted="false"
:volume="1"
:controls="true"
:hideNativeControls="true"
:autoplay="true"
/>
</div>
<!-- 返回按钮 -->
<ImgButton
class="absolute right-0 top-0 z-20"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleBack"
/>
</div>
</template>
-212
View File
@@ -1,212 +0,0 @@
<script setup lang="ts">
type CategoryName = '拍摄基础' | '内容策划' | '拍摄执行' | '后期制作' | '版权伦理意识'
const props = defineProps<{
status: boolean
sideStatus: boolean
selectedCategory: string
}>()
const emit = defineEmits<{ (e: 'select', name: CategoryName): void }>()
</script>
<template>
<div
v-show="props.sideStatus || props.selectedCategory === '拍摄基础'"
:class="[
'module module-left-bottom module-vertical',
{ active: props.selectedCategory === '拍摄基础' }
]"
@click="emit('select', '拍摄基础')"
>
<div
:class="[
'module-text blue-text',
props.status ? 'text-white' : 'text-[#969696e6_!important]'
]"
>
拍摄基础
</div>
<div :class="['module-percent', props.status ? 'red-text' : 'blue-text']">0%</div>
</div>
<div
v-show="props.sideStatus || props.selectedCategory === '内容策划'"
:class="[
'module module-left-middle module-vertical',
{ active: props.selectedCategory === '内容策划' }
]"
@click="emit('select', '内容策划')"
>
<div
:class="[
'module-text blue-text',
props.status ? 'text-white' : 'text-[#969696e6_!important]'
]"
>
内容策划
</div>
<div :class="['module-percent', props.status ? 'red-text' : 'blue-text']">0%</div>
</div>
<div
v-show="props.sideStatus || props.selectedCategory === '拍摄执行'"
:class="[
'module module-right-middle module-vertical',
{ active: props.selectedCategory === '拍摄执行' }
]"
@click="emit('select', '拍摄执行')"
>
<div
:class="[
'module-text blue-text',
props.status ? 'text-white' : 'text-[#969696e6_!important]'
]"
>
拍摄执行
</div>
<div :class="['module-percent', props.status ? 'red-text' : 'blue-text']">0%</div>
</div>
<div
v-show="props.sideStatus || props.selectedCategory === '后期制作'"
:class="[
'module module-right-upper module-vertical',
{ active: props.selectedCategory === '后期制作' }
]"
@click="emit('select', '后期制作')"
>
<div
:class="[
'module-text blue-text',
props.status ? 'text-white' : 'text-[#969696e6_!important]'
]"
>
后期制作
</div>
<div :class="['module-percent', props.status ? 'red-text' : 'blue-text']">0%</div>
</div>
<div
v-show="props.sideStatus || props.selectedCategory === '版权伦理意识'"
:class="[
'module module-right-far module-vertical',
{ active: props.selectedCategory === '版权伦理意识' }
]"
@click="emit('select', '版权伦理意识')"
>
<div
:class="[
'module-text blue-text',
props.status ? 'text-white' : 'text-[#969696e6_!important]'
]"
>
版权伦理意识
</div>
<div :class="['module-percent', props.status ? 'red-text' : 'blue-text']">0%</div>
</div>
</template>
<style scoped>
.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 {
font-size: 36px;
letter-spacing: 8px;
}
.module-percent {
color: #ffffff;
font-size: 28px;
}
.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%;
}
.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);
}
.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.active {
transform: scale(1.15);
filter: brightness(1.3);
z-index: 10;
}
.module.active .module-text {
text-shadow:
0 0 4px rgba(255, 255, 255, 1),
0 0 10px rgba(79, 241, 255, 1),
0 0 20px rgba(79, 241, 255, 0.9),
0 0 40px rgba(0, 199, 255, 0.8);
}
</style>
-22
View File
@@ -1,22 +0,0 @@
<script setup lang="ts">
import { ImgButton } from '@/components/Button'
const props = defineProps<{
positions: { top: string; left: string }[]
displayedIcons: string[]
sideStatus: boolean
}>()
const emit = defineEmits<{ (e: 'iconClick', icon: string): void }>()
</script>
<template>
<ImgButton
v-for="(icon, index) in props.displayedIcons"
:key="index"
@click="emit('iconClick', icon)"
class="absolute z-10"
:style="{ top: props.positions[index]?.top, left: props.positions[index]?.left }"
:image="icon"
/>
</template>
@@ -1,39 +0,0 @@
<script setup lang="ts">
import { ImgButton } from '@/components/Button'
const props = defineProps<{ moduleName: string }>()
const emit = defineEmits<{ (e: 'prev'): void; (e: 'next'): void }>()
</script>
<template>
<ImgButton
@click="emit('prev')"
class="absolute left-0 top-1/2 -translate-y-1/2"
:image="'skill/左滑'"
/>
<ImgButton
@click="emit('next')"
class="absolute right-0 top-1/2 -translate-y-1/2"
:image="'skill/右滑'"
/>
<div class="absolute bottom-28 left-1/2 -translate-x-1/2 z-10">
<span class="blue-text tracking-[18px] text-5xl">{{ props.moduleName }}</span>
</div>
</template>
<style scoped>
.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);
}
</style>
+85 -8
View File
@@ -4,26 +4,103 @@ import type { ResourceItem } from '../resourceData'
const props = defineProps<{
resources: ResourceItem[]
}>()
const emit = defineEmits<{
(e: 'resourceClick', resource: ResourceItem): void
}>()
const handleResourceClick = (resource: ResourceItem) => {
emit('resourceClick', resource)
}
</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
class="w-full h-full overflow-y-auto pr-2 scrollbar-thin scrollbar-thumb-white/30 scrollbar-track-transparent"
>
<div
v-if="props.resources.length === 0"
class="text-white text-2xl text-center mt-10 bg-black/40 backdrop-blur-sm rounded-2xl p-8"
>
暂无资源
</div>
<div v-else class="space-y-4">
<div v-else class="space-y-3">
<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"
class="group relative overflow-hidden rounded-2xl cursor-pointer transition-all duration-300 hover:scale-[1.02] hover:shadow-2xl"
@click="handleResourceClick(resource)"
>
<div class="font-bold">{{ resource.title }}</div>
<div v-if="resource.description" class="text-lg text-gray-300 mt-2">
{{ resource.description }}
<!-- 背景层 - 使用柔和的半透明渐变 -->
<div
class="absolute inset-0 bg-gradient-to-r from-white/70 via-blue-50/60 to-cyan-50/60 backdrop-blur-md"
></div>
<!-- 左侧装饰条 -->
<div
class="absolute left-0 top-0 bottom-0 w-1.5 bg-gradient-to-b from-cyan-400 via-blue-500 to-purple-500 group-hover:w-2 transition-all duration-300"
></div>
<!-- 内容层 -->
<div class="relative px-6 py-4">
<!-- 标题 -->
<div
class="font-bold text-3xl text-gray-800 group-hover:text-blue-600 transition-colors duration-300 mb-2"
>
{{ resource.title }}
</div>
<!-- 描述 -->
<div v-if="resource.description" class="text-2xl text-gray-600 leading-relaxed mb-3">
{{ resource.description }}
</div>
<!-- 标签信息 -->
<div class="flex items-center gap-3 mt-2 text-2xl">
<span class="px-3 py-1 rounded-full bg-blue-100 text-blue-700 font-medium">
{{ resource.moduleName }}
</span>
<span class="px-3 py-1 rounded-full bg-purple-100 text-purple-700 font-medium">
{{ resource.tagName }}
</span>
<span
v-if="resource.subTagName"
class="px-3 py-1 rounded-full bg-green-100 text-green-700 font-medium"
>
{{ resource.subTagName }}
</span>
</div>
</div>
<!-- 悬停时的光效 -->
<div
class="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none"
>
<div
class="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent"
></div>
</div>
</div>
</div>
</div>
</template>
<style scoped></style>
<style scoped>
/* 自定义滚动条样式 */
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 3px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}
</style>
+33 -7
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { getTagColor } from '@/utils'
interface SearchTag {
type: 'module' | 'tag' | 'subTag'
name: string
@@ -7,6 +9,26 @@ interface SearchTag {
const props = defineProps<{
tags: SearchTag[]
}>()
const emit = defineEmits<{
(e: 'remove', type: 'module' | 'tag' | 'subTag'): void
}>()
const handleRemove = (type: 'module' | 'tag' | 'subTag') => {
emit('remove', type)
}
// 获取标签的背景颜色类
const getTagBgClass = (tag: SearchTag) => {
if (tag.type === 'module') {
return 'bg-blue-500/80'
} else if (tag.type === 'tag') {
// 使用 getTagColor 获取标签颜色
return getTagColor(tag.name)
} else {
return 'bg-purple-500/80'
}
}
</script>
<template>
@@ -14,14 +36,18 @@ const props = defineProps<{
<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'
}"
class="inline-flex items-center gap-2 px-3 py-1 rounded-full text-white text-base font-medium whitespace-nowrap border border-white/80 group hover:border-white transition-all"
:class="getTagBgClass(tag)"
>
{{ tag.name }}
<span>{{ tag.name }}</span>
<!-- 删除按钮 -->
<button
@click="handleRemove(tag.type)"
class="flex items-center justify-center w-4 h-4 rounded-full bg-white/20 hover:bg-white/40 transition-all"
title="删除"
>
<span class="text-xs leading-none">×</span>
</button>
</div>
</div>
</template>
+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>
+277 -58
View File
@@ -1,8 +1,10 @@
<script lang="ts" setup>
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
import { ImgButton } from '@/components/Button'
import { ImagePreview } from '@/components'
import { useRouter } from 'vue-router'
import { ref, computed } from 'vue'
import { useScale } from '@/composables/useScale'
import {
getAllResources,
getResourcesByModule,
@@ -14,12 +16,56 @@ import {
import SearchTags from './components/SearchTags.vue'
import ResourceList from './components/ResourceList.vue'
import SkillTree from './components/SkillTree.vue'
import GuideSteps from '@/components/Guide/GuideSteps.vue'
const router = useRouter()
const { scale, offsetX, offsetY } = useScale()
// 搜索框文本(保留用于用户输入搜索)
const search = ref('')
// 视频播放弹窗状态
const showVideoModal = ref(false)
const currentVideoUrl = ref('')
// 操作引导状态
const showGuide = ref(true)
const guideSteps = ref([
{
id: 'step-1',
title: '',
content:
'点击顶部模块卡片,选择您想要学习的模块(新手导航、vlog发现与记录、访谈聆听与对话、电影创作与表达)',
highlightSelector: '.module-list-container'
},
{
id: 'step-2',
title: '',
content:
'在右侧技能树中,点击标签可以查看该技能下的资源。有子标签的标签可以展开查看更多细分技能',
highlightSelector: '.skill-tree-container'
},
{
id: 'step-3',
title: '',
content: '左侧会显示当前选中标签下的所有资源,包括视频、图文和页面链接',
highlightSelector: '.resource-list-container'
},
{
id: 'step-4',
title: '',
content: '使用顶部搜索框可以快速搜索资源,支持按标题、模块、标签等关键词搜索',
highlightSelector: '.search-box-container'
},
{
id: 'step-5',
title: '',
content: '点击资源列表中的任意资源,可以查看视频、图文或跳转到相关页面',
highlightSelector: '.resource-list-container'
}
])
const currentGuideStep = ref(0)
/**
* 模块列表
* 新手导航:新手导航-选中.png
@@ -67,33 +113,48 @@ const searchTags = computed<SearchTag[]>(() => {
const tags: SearchTag[] = []
if (selectedModuleId.value) {
// 从资源数组中查找模块名
// 模块模式:显示完整路径
const moduleResource = getAllResources().find(r => r.moduleId === selectedModuleId.value)
if (moduleResource) {
tags.push({ type: 'module', name: moduleResource.moduleName })
}
}
if (selectedModuleId.value && selectedTagId.value) {
// 从资源数组中查找标签名
const tagResource = getAllResources().find(
r => r.moduleId === selectedModuleId.value && r.tagId === selectedTagId.value
)
if (tagResource) {
tags.push({ type: 'tag', name: tagResource.tagName })
if (selectedTagId.value) {
const tagResource = getAllResources().find(
r => r.moduleId === selectedModuleId.value && r.tagId === selectedTagId.value
)
if (tagResource) {
tags.push({ type: 'tag', name: tagResource.tagName })
}
if (selectedSubTagId.value) {
const subTagResource = getAllResources().find(
r =>
r.moduleId === selectedModuleId.value &&
r.tagId === selectedTagId.value &&
r.subTagId === selectedSubTagId.value
)
if (subTagResource && subTagResource.subTagName) {
tags.push({ type: 'subTag', name: subTagResource.subTagName })
}
}
}
}
} else {
// 总览模式:只显示标签名称(不显示模块)
if (selectedTagId.value) {
const tagResource = getAllResources().find(r => r.tagId === selectedTagId.value)
if (tagResource) {
tags.push({ type: 'tag', name: tagResource.tagName })
}
if (selectedModuleId.value && selectedTagId.value && selectedSubTagId.value) {
// 从资源数组中查找子标签名
const subTagResource = getAllResources().find(
r =>
r.moduleId === selectedModuleId.value &&
r.tagId === selectedTagId.value &&
r.subTagId === selectedSubTagId.value
)
if (subTagResource && subTagResource.subTagName) {
tags.push({ type: 'subTag', name: subTagResource.subTagName })
if (selectedSubTagId.value) {
const subTagResource = getAllResources().find(
r => r.tagId === selectedTagId.value && r.subTagId === selectedSubTagId.value
)
if (subTagResource && subTagResource.subTagName) {
tags.push({ type: 'subTag', name: subTagResource.subTagName })
}
}
}
}
@@ -102,23 +163,51 @@ const searchTags = computed<SearchTag[]>(() => {
// 过滤后的资源列表
const filteredResources = computed<ResourceItem[]>(() => {
let resources: ResourceItem[] = []
// 如果选择了子标签,返回子标签的资源
if (selectedModuleId.value && selectedTagId.value && selectedSubTagId.value) {
return getResourcesBySubTag(selectedModuleId.value, selectedTagId.value, selectedSubTagId.value)
resources = getResourcesBySubTag(
selectedModuleId.value,
selectedTagId.value,
selectedSubTagId.value
)
}
// 如果选择了资源标签,返回标签的资源
if (selectedModuleId.value && selectedTagId.value) {
return getResourcesByTag(selectedModuleId.value, selectedTagId.value)
// 如果选择了资源标签
else if (selectedTagId.value) {
if (selectedModuleId.value) {
// 模块模式:只返回该模块该标签的资源
resources = getResourcesByTag(selectedModuleId.value, selectedTagId.value)
} else {
// 总览模式:返回所有模块中该标签名称的资源
const tagName = getAllResources().find(r => r.tagId === selectedTagId.value)?.tagName
if (tagName) {
resources = getAllResources().filter(r => r.tagName === tagName)
}
}
}
// 如果选择了模块,返回模块的资源
if (selectedModuleId.value) {
return getResourcesByModule(selectedModuleId.value)
else if (selectedModuleId.value) {
resources = getResourcesByModule(selectedModuleId.value)
}
// 默认返回所有资源
else {
resources = getAllResources()
}
// 默认返回所有资源
return getAllResources()
// 如果有搜索文本,进行文本搜索
if (search.value.trim()) {
const searchText = search.value.trim().toLowerCase()
resources = resources.filter(
r =>
r.title.toLowerCase().includes(searchText) ||
r.moduleName.toLowerCase().includes(searchText) ||
r.tagName.toLowerCase().includes(searchText) ||
r.description?.toLowerCase().includes(searchText)
)
}
return resources
})
// 处理模块点击
@@ -131,30 +220,37 @@ const handleModuleClick = (moduleId: string | null) => {
// 处理资源标签点击
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)
// 如果点击的是已选中的标签,则取消选中(关闭遮罩)
if (selectedTagId.value === tagId) {
selectedTagId.value = null
selectedSubTagId.value = null
expandedTags.value.clear()
return
}
// 检查是否有子标签
const subTags = getSubTagsByTag(moduleId, tagId)
const hasSubTags = subTags.length > 0
if (hasSubTags) {
// 有子标签:选择新标签并自动展开
// 在总览模式下,不设置 selectedModuleId,保持总览状态
if (selectedModuleId.value !== null) {
selectedModuleId.value = moduleId
}
} 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)
// 自动展开
expandedTags.value.add(tagId)
} else {
// 无子标签:高亮并搜索,不隐藏其他标签
// 在总览模式下,不设置 selectedModuleId,保持总览状态
if (selectedModuleId.value !== null) {
selectedModuleId.value = moduleId
}
selectedTagId.value = tagId
selectedSubTagId.value = null
expandedTags.value.clear()
}
}
@@ -181,9 +277,78 @@ const handleModuleListItemClick = (moduleId: string) => {
handleModuleClick(moduleId === selectedModuleId.value ? null : moduleId)
}
// 处理删除搜索标签
const handleRemoveTag = (tagType: 'module' | 'tag' | 'subTag') => {
if (tagType === 'subTag') {
selectedSubTagId.value = null
} else if (tagType === 'tag') {
selectedTagId.value = null
selectedSubTagId.value = null
expandedTags.value.clear()
} else if (tagType === 'module') {
selectedModuleId.value = null
selectedTagId.value = null
selectedSubTagId.value = null
expandedTags.value.clear()
}
}
const handleClose = () => {
router.push({ name: 'Action' })
}
// 处理资源点击
const handleResourceClick = (resource: ResourceItem) => {
if (resource.type === 'video' && resource.videoUrl) {
// 视频类型:打开视频播放弹窗
currentVideoUrl.value = resource.videoUrl
showVideoModal.value = true
} else if (resource.type === 'page' && resource.routeName) {
// 页面类型:跳转到对应路由
router.push({ name: resource.routeName })
} else if (resource.type === 'image' && resource.images && resource.images.length > 0) {
// 图文类型:使用 ImagePreview 展示
ImagePreview({
images: resource.images,
startIndex: 0,
maskClosable: true,
getScaleMetrics: () => ({
scale: scale.value,
offsetX: offsetX.value,
offsetY: offsetY.value
})
})
}
}
// 关闭视频弹窗
const closeVideoModal = () => {
showVideoModal.value = false
currentVideoUrl.value = ''
}
// 处理引导完成
const handleGuideComplete = () => {
showGuide.value = false
}
// 处理引导跳过
const handleGuideSkip = () => {
showGuide.value = false
}
// 处理引导步骤变化
const handleGuideStepChange = (step: any, index: number) => {
currentGuideStep.value = index
}
// 当前步骤的高亮选择器
const currentHighlightSelector = computed(() => {
if (showGuide.value && guideSteps.value[currentGuideStep.value]) {
return guideSteps.value[currentGuideStep.value].highlightSelector || null
}
return null
})
</script>
<template>
@@ -201,11 +366,9 @@ const handleClose = () => {
@click="handleClose"
/>
<img class="absolute right-5 top-44" :src="getStaticImage('skill/技能树.png')" alt="" />
<!-- 搜索框 -->
<div
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"
class="search-box-container 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"
:style="{
backgroundImage: getBackgroundImage('skill/搜索框-已搜索.png'),
backgroundSize: 'cover'
@@ -213,7 +376,7 @@ const handleClose = () => {
>
<!-- 搜索标签显示 -->
<div class="flex-shrink-0 overflow-x-auto scrollbar-hide">
<SearchTags :tags="searchTags" />
<SearchTags :tags="searchTags" @remove="handleRemoveTag" />
</div>
<!-- 搜索文本输入(可选,用于额外搜索) -->
<input
@@ -227,7 +390,9 @@ const handleClose = () => {
</div>
<!-- 模块列表 -->
<div class="absolute left-[55%] -translate-x-1/2 top-10 bg-cover bg-center">
<div
class="module-list-container absolute left-[55%] -translate-x-1/2 top-10 bg-cover bg-center"
>
<div class="flex gap-10 -mt-7">
<div
v-for="module in moduleList"
@@ -242,12 +407,12 @@ const handleClose = () => {
</div>
<!-- 资源列表 -->
<div class="absolute left-10 bottom-20 w-[740px] h-[800px]">
<ResourceList :resources="filteredResources" />
<div class="resource-list-container absolute left-10 bottom-20 w-[740px] h-[800px]">
<ResourceList :resources="filteredResources" @resource-click="handleResourceClick" />
</div>
<!-- 技能树-资源树 -->
<div class="absolute right-28 bottom-20 w-[740px] h-[820px]">
<div class="skill-tree-container absolute right-0 bottom-20 w-[860px] h-[820px]">
<SkillTree
:selected-module-id="selectedModuleId"
:expanded-tags="expandedTags"
@@ -257,5 +422,59 @@ const handleClose = () => {
@sub-tag-click="handleSubTagClick"
/>
</div>
<!-- 视频播放弹窗 -->
<transition
enter-active-class="transition-opacity duration-300"
leave-active-class="transition-opacity duration-300"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="showVideoModal"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
@click="closeVideoModal"
>
<div
class="relative w-[90%] max-w-[1200px] h-[80%] bg-black rounded-2xl overflow-hidden shadow-2xl"
@click.stop
>
<!-- 关闭按钮 -->
<button
@click="closeVideoModal"
class="absolute top-4 right-4 z-10 w-12 h-12 flex items-center justify-center bg-white/20 hover:bg-white/30 rounded-full text-white text-3xl transition-all"
>
×
</button>
<!-- 视频播放器 -->
<video
v-if="currentVideoUrl"
:src="currentVideoUrl"
controls
autoplay
class="w-full h-full object-contain"
>
您的浏览器不支持视频播放
</video>
</div>
</div>
</transition>
<!-- 操作引导 -->
<GuideSteps
v-if="showGuide"
:steps="guideSteps"
:keyboard-navigation="true"
:highlight-selector="currentHighlightSelector"
:highlight-padding="12"
:highlight-radius="16"
position-offset-y="85%"
@complete="handleGuideComplete"
@skip="handleGuideSkip"
@step-change="handleGuideStepChange"
/>
</div>
</template>
+212 -49
View File
@@ -13,11 +13,16 @@ export interface ResourceItem {
subTagName?: string
// 资源数量(用于技能树显示)
resourceCount?: number
// 资源类型和链接
type: 'video' | 'page' | 'image' // video: 视频播放, page: 页面跳转, image: 图文展示
videoUrl?: string // 视频URL(type为video时使用)
routeName?: string // 路由名称(type为page时使用)
images?: string[] // 图片URL数组(type为image时使用)
}
// 一维数组格式的资源数据
export const resourceData: ResourceItem[] = [
// 新手导航模块
// 新手导航模块 - 保持原有标签
{
id: 'res-1',
title: '九种构图法',
@@ -27,7 +32,9 @@ export const resourceData: ResourceItem[] = [
tagId: 'tag-1-1',
tagName: '视觉素养',
subTagId: 'subtag-1-1-1',
subTagName: '构图'
subTagName: '构图',
type: 'video',
videoUrl: '/video/构图法.mp4'
},
{
id: 'res-2',
@@ -38,7 +45,9 @@ export const resourceData: ResourceItem[] = [
tagId: 'tag-1-1',
tagName: '视觉素养',
subTagId: 'subtag-1-1-2',
subTagName: '光线'
subTagName: '光线',
type: 'video',
videoUrl: '/video/光线运用.mp4'
},
{
id: 'res-3',
@@ -49,7 +58,9 @@ export const resourceData: ResourceItem[] = [
tagId: 'tag-1-1',
tagName: '视觉素养',
subTagId: 'subtag-1-1-3',
subTagName: '运镜'
subTagName: '运镜',
type: 'video',
videoUrl: '/video/运镜技巧.mp4'
},
{
id: 'res-4',
@@ -58,7 +69,9 @@ export const resourceData: ResourceItem[] = [
moduleId: 'module-1',
moduleName: '新手导航',
tagId: 'tag-1-2',
tagName: '摄影基础'
tagName: '摄影基础',
type: 'image',
images: ['/images/skill/摄影基础1.png', '/images/skill/摄影基础2.png']
},
{
id: 'res-5',
@@ -67,33 +80,53 @@ export const resourceData: ResourceItem[] = [
moduleId: 'module-1',
moduleName: '新手导航',
tagId: 'tag-1-3',
tagName: '剪映基础操作'
tagName: '剪映基础操作',
type: 'video',
videoUrl: '/video/剪映入门.mp4'
},
// vlog发现与记录模块
// vlog发现与记录模块 - 使用统一标签体系
{
id: 'res-6',
title: '设备准备指南',
description: 'vlog拍摄设备准备',
title: 'vlog设备准备',
description: 'vlog拍摄设备准备指南',
moduleId: 'module-2',
moduleName: 'vlog发现与记录',
tagId: 'tag-2-1',
tagName: '内容策划',
subTagId: 'subtag-2-1-1',
subTagName: '设备准备'
subTagName: '设备准备',
type: 'video',
videoUrl: '/video/vlog设备准备.mp4'
},
{
id: 'res-7',
title: 'vlog选题构思',
description: 'vlog选题与创意构思',
moduleId: 'module-2',
moduleName: 'vlog发现与记录',
tagId: 'tag-2-1',
tagName: '内容策划',
subTagId: 'subtag-2-1-2',
subTagName: '选题与构思',
type: 'video',
videoUrl: '/video/vlog选题构思.mp4'
},
{
id: 'res-8',
title: 'vlog脚本撰写',
description: '如何撰写vlog脚本',
moduleId: 'module-2',
moduleName: 'vlog发现与记录',
tagId: 'tag-2-1',
tagName: '内容策划',
subTagId: 'subtag-2-1-2',
subTagName: '脚本制作'
subTagId: 'subtag-2-1-3',
subTagName: '脚本与分镜制作',
type: 'video',
videoUrl: '/video/vlog脚本撰写.mp4'
},
{
id: 'res-8',
id: 'res-9',
title: 'vlog构图技巧',
description: 'vlog拍摄构图方法',
moduleId: 'module-2',
@@ -101,10 +134,12 @@ export const resourceData: ResourceItem[] = [
tagId: 'tag-2-2',
tagName: '拍摄执行',
subTagId: 'subtag-2-2-1',
subTagName: '构图'
subTagName: '构图',
type: 'video',
videoUrl: '/video/vlog构图技巧.mp4'
},
{
id: 'res-9',
id: 'res-10',
title: 'vlog光线控制',
description: 'vlog拍摄光线运用',
moduleId: 'module-2',
@@ -112,10 +147,12 @@ export const resourceData: ResourceItem[] = [
tagId: 'tag-2-2',
tagName: '拍摄执行',
subTagId: 'subtag-2-2-2',
subTagName: '光线运用'
subTagName: '光线运用',
type: 'video',
videoUrl: '/video/vlog光线控制.mp4'
},
{
id: 'res-10',
id: 'res-11',
title: 'vlog运镜方法',
description: 'vlog拍摄运镜技巧',
moduleId: 'module-2',
@@ -123,52 +160,164 @@ export const resourceData: ResourceItem[] = [
tagId: 'tag-2-2',
tagName: '拍摄执行',
subTagId: 'subtag-2-2-3',
subTagName: '运镜技巧'
subTagName: '运镜技巧',
type: 'video',
videoUrl: '/video/vlog运镜方法.mp4'
},
{
id: 'res-11',
title: 'vlog后期剪辑',
description: 'vlog视频后期制作',
id: 'res-12',
title: 'vlog剪辑技巧',
description: 'vlog视频剪辑方法',
moduleId: 'module-2',
moduleName: 'vlog发现与记录',
tagId: 'tag-2-3',
tagName: '后期制作'
tagName: '后期制作',
subTagId: 'subtag-2-3-1',
subTagName: '剪辑技巧',
type: 'video',
videoUrl: '/video/vlog剪辑技巧.mp4'
},
// 访谈聆听与对话模块
{
id: 'res-12',
id: 'res-13',
title: 'vlog调色技巧',
description: 'vlog视频调色方法',
moduleId: 'module-2',
moduleName: 'vlog发现与记录',
tagId: 'tag-2-3',
tagName: '后期制作',
subTagId: 'subtag-2-3-2',
subTagName: '调色',
type: 'video',
videoUrl: '/video/vlog调色技巧.mp4'
},
{
id: 'res-14',
title: 'vlog音效处理',
description: 'vlog音效添加与处理',
moduleId: 'module-2',
moduleName: 'vlog发现与记录',
tagId: 'tag-2-3',
tagName: '后期制作',
subTagId: 'subtag-2-3-3',
subTagName: '音效',
type: 'video',
videoUrl: '/video/vlog音效处理.mp4'
},
{
id: 'res-15',
title: 'vlog版权规范',
description: 'vlog创作版权注意事项',
moduleId: 'module-2',
moduleName: 'vlog发现与记录',
tagId: 'tag-2-4',
tagName: 'AI与版权伦理知识',
type: 'image',
images: [
'/images/skill/版权规范1.png',
'/images/skill/版权规范2.png',
'/images/skill/版权规范3.png'
]
},
{
id: 'res-16',
title: 'vlog案例赏析',
description: '优秀vlog作品分析',
moduleId: 'module-2',
moduleName: 'vlog发现与记录',
tagId: 'tag-2-5',
tagName: '思路提点与案例赏析',
type: 'video',
videoUrl: '/video/vlog案例赏析.mp4'
},
// 访谈聆听与对话模块 - 使用统一标签体系
{
id: 'res-17',
title: '访谈设备准备',
description: '访谈拍摄设备清单',
moduleId: 'module-3',
moduleName: '访谈聆听与对话',
tagId: 'tag-3-1',
tagName: '内容策划',
subTagId: 'subtag-3-1-1',
subTagName: '设备准备',
type: 'video',
videoUrl: '/video/访谈设备准备.mp4'
},
{
id: 'res-18',
title: '访谈选题方法',
description: '如何选择访谈主题',
moduleId: 'module-3',
moduleName: '访谈聆听与对话',
tagId: 'tag-3-1',
tagName: '内容策划',
subTagId: 'subtag-3-1-1',
subTagName: '选题与构思'
subTagId: 'subtag-3-1-2',
subTagName: '选题与构思',
type: 'video',
videoUrl: '/video/访谈选题方法.mp4'
},
{
id: 'res-13',
id: 'res-19',
title: '访谈脚本制作',
description: '访谈脚本撰写技巧',
moduleId: 'module-3',
moduleName: '访谈聆听与对话',
tagId: 'tag-3-1',
tagName: '内容策划',
subTagId: 'subtag-3-1-2',
subTagName: '脚本与分镜制作'
subTagId: 'subtag-3-1-3',
subTagName: '脚本与分镜制作',
type: 'video',
videoUrl: '/video/访谈脚本制作.mp4'
},
{
id: 'res-14',
id: 'res-20',
title: '访谈拍摄技巧',
description: '访谈视频拍摄方法',
moduleId: 'module-3',
moduleName: '访谈聆听与对话',
tagId: 'tag-3-2',
tagName: '拍摄执行'
tagName: '拍摄执行',
type: 'video',
videoUrl: '/video/访谈拍摄技巧.mp4'
},
// 电影创作与表达模块
{
id: 'res-15',
id: 'res-21',
title: '访谈后期剪辑',
description: '访谈视频后期制作',
moduleId: 'module-3',
moduleName: '访谈聆听与对话',
tagId: 'tag-3-3',
tagName: '后期制作',
type: 'video',
videoUrl: '/video/访谈后期剪辑.mp4'
},
{
id: 'res-22',
title: '访谈版权规范',
description: '访谈创作版权注意事项',
moduleId: 'module-3',
moduleName: '访谈聆听与对话',
tagId: 'tag-3-4',
tagName: 'AI与版权伦理知识',
type: 'video',
videoUrl: '/video/访谈版权规范.mp4'
},
{
id: 'res-23',
title: '访谈案例赏析',
description: '优秀访谈作品分析',
moduleId: 'module-3',
moduleName: '访谈聆听与对话',
tagId: 'tag-3-5',
tagName: '思路提点与案例赏析',
type: 'video',
videoUrl: '/video/访谈案例赏析.mp4'
},
// 电影创作与表达模块 - 使用统一标签体系
{
id: 'res-24',
title: '电影设备准备',
description: '电影拍摄设备清单',
moduleId: 'module-4',
@@ -176,10 +325,12 @@ export const resourceData: ResourceItem[] = [
tagId: 'tag-4-1',
tagName: '内容策划',
subTagId: 'subtag-4-1-1',
subTagName: '设备准备'
subTagName: '设备准备',
type: 'page',
routeName: 'FilmResource'
},
{
id: 'res-16',
id: 'res-25',
title: '电影选题构思',
description: '电影创作选题方法',
moduleId: 'module-4',
@@ -187,10 +338,12 @@ export const resourceData: ResourceItem[] = [
tagId: 'tag-4-1',
tagName: '内容策划',
subTagId: 'subtag-4-1-2',
subTagName: '选题与构思'
subTagName: '选题与构思',
type: 'video',
videoUrl: '/video/电影选题构思.mp4'
},
{
id: 'res-17',
id: 'res-26',
title: '电影脚本分镜',
description: '电影脚本和分镜制作',
moduleId: 'module-4',
@@ -198,43 +351,53 @@ export const resourceData: ResourceItem[] = [
tagId: 'tag-4-1',
tagName: '内容策划',
subTagId: 'subtag-4-1-3',
subTagName: '脚本与分镜制作'
subTagName: '脚本与分镜制作',
type: 'video',
videoUrl: '/video/电影脚本分镜.mp4'
},
{
id: 'res-18',
id: 'res-27',
title: '电影拍摄执行',
description: '电影拍摄执行技巧',
moduleId: 'module-4',
moduleName: '电影创作与表达',
tagId: 'tag-4-2',
tagName: '拍摄执行'
tagName: '拍摄执行',
type: 'video',
videoUrl: '/video/电影拍摄执行.mp4'
},
{
id: 'res-19',
id: 'res-28',
title: '电影后期制作',
description: '电影后期剪辑与特效',
moduleId: 'module-4',
moduleName: '电影创作与表达',
tagId: 'tag-4-3',
tagName: '后期制作'
tagName: '后期制作',
type: 'video',
videoUrl: '/video/电影后期制作.mp4'
},
{
id: 'res-20',
title: 'AI与版权伦理',
description: 'AI工具使用与版权规范',
id: 'res-29',
title: '电影AI工具应用',
description: 'AI工具在电影创作中的应用',
moduleId: 'module-4',
moduleName: '电影创作与表达',
tagId: 'tag-4-4',
tagName: 'AI与版权伦理知识'
tagName: 'AI与版权伦理知识',
type: 'video',
videoUrl: '/video/电影AI工具应用.mp4'
},
{
id: 'res-21',
title: '创作思路与案例',
id: 'res-30',
title: '电影创作思路与案例',
description: '电影创作思路和案例分析',
moduleId: 'module-4',
moduleName: '电影创作与表达',
tagId: 'tag-4-5',
tagName: '思路提点与案例赏析'
tagName: '思路提点与案例赏析',
type: 'video',
videoUrl: '/video/电影创作思路与案例.mp4'
}
]
-169
View File
@@ -1,169 +0,0 @@
import { ref, computed } from 'vue'
// 分类配置
interface CategoryConfig {
icons: string[]
positions: { top: string; left: string }[]
}
// 模块配置
interface ModuleConfig {
name: string
value: number
categories: Record<string, CategoryConfig>
}
// 知识卡片按钮数据结构(与 shooting/index.vue 对齐)
export type KnowledgeBtn = {
key: string
name: string
btnImage: string
images: string[]
desc: string
}
export function useSkillState() {
// 模块/分类/图标层级状态(仅保留必要业务状态)
const selectedModule = ref(1)
const selectedCategory = ref('')
const showSubIcons = ref(false)
const selectedPrimaryIcon = ref<string | null>(null)
const moduleList = ref<ModuleConfig[]>([
{
name: '技能树-新手启航',
value: 1,
categories: {
拍摄基础: {
icons: [],
positions: []
},
内容策划: {
icons: [],
positions: []
},
拍摄执行: {
icons: [],
positions: []
},
后期制作: {
icons: [],
positions: []
},
版权伦理意识: {
icons: [],
positions: []
}
}
},
{
name: '技能树-vlog',
value: 2,
categories: {
拍摄基础: {
icons: [],
positions: []
},
内容策划: {
icons: [],
positions: []
},
拍摄执行: {
icons: ['skill/画面构图未选中', 'skill/景别', 'skill/运镜技巧', 'skill/光线运用'],
positions: [
{ top: '36%', left: '23%' },
{ top: '15%', left: '34%' },
{ top: '20%', left: '45%' },
{ top: '28%', left: '58%' }
]
},
后期制作: {
icons: [],
positions: []
},
版权伦理意识: {
icons: [],
positions: []
}
}
}
])
const currentModule = computed(() => {
return moduleList.value.find(item => item.value === selectedModule.value) || moduleList.value[0]
})
const nextModule = () => {
if (selectedModule.value === moduleList.value.length) {
selectedModule.value = 1
} else {
selectedModule.value++
}
}
const prevModule = () => {
if (selectedModule.value === 1) {
selectedModule.value = moduleList.value.length
} else {
selectedModule.value--
}
}
const handleCategoryClick = (categoryName: string) => {
selectedCategory.value = categoryName
showSubIcons.value = false
selectedPrimaryIcon.value = null
}
const currentCategoryConfig = computed(() => {
if (!selectedCategory.value) return null
return currentModule.value.categories[selectedCategory.value] || null
})
const displayedIcons = computed(() => {
const cfg = currentCategoryConfig.value
if (!cfg) return [] as string[]
if (showSubIcons.value && selectedPrimaryIcon.value) {
return ['skill/画面构图选中', 'skill/三分法构图', 'skill/居中构图', 'skill/对角线构图']
}
return cfg.icons
})
type IconAction =
| { type: 'navigate'; payload: { name: string } }
| { type: 'expand' }
| { type: 'openKnowledge'; payload: { name: '三分法构图' | '居中构图' | '对角线构图' } }
| null
const getIconAction = (icon: string): IconAction => {
if (icon === 'skill/画面构图未选中' && selectedCategory.value === '拍摄执行') {
if (selectedModule.value === 1) return { type: 'navigate', payload: { name: 'SkillVdieo' } }
if (selectedModule.value === 2) return { type: 'expand' }
}
if (showSubIcons.value && selectedModule.value === 2 && selectedCategory.value === '拍摄执行') {
const map: Record<string, '三分法构图' | '居中构图' | '对角线构图'> = {
'skill/三分法构图': '三分法构图',
'skill/居中构图': '居中构图',
'skill/对角线构图': '对角线构图'
}
if (icon in map) return { type: 'openKnowledge', payload: { name: map[icon] } }
}
return null
}
return {
// module switcher
currentModule,
nextModule,
prevModule,
// category & icons
selectedCategory,
handleCategoryClick,
currentCategoryConfig,
displayedIcons,
selectedModule,
showSubIcons,
selectedPrimaryIcon,
getIconAction
}
}