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

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

No files matched your search

+481
View File
@@ -0,0 +1,481 @@
# 📚 Skill 功能架构完整分析
## 🗂️ 文件结构(已清理)
```
src/views/skill/
├── index.vue # 主页面 - 技能树资源浏览器
├── resourceData.ts # 数据层 - 资源数据管理
├── video.vue # 视频播放页面
└── components/
├── SkillTree.vue # 技能树可视化组件
├── SearchTags.vue # 搜索面包屑标签
└── ResourceList.vue # 资源列表展示
```
---
## 🎯 核心功能定位
**技能树资源浏览系统** - 一个基于树形结构的学习资源导航和筛选平台
---
## 📊 数据架构
### 1. 数据层级结构
```
模块 (Module)
└─ 标签 (Tag)
└─ 子标签 (SubTag)
└─ 资源 (Resource)
```
### 2. 数据实体
#### ResourceItem 接口
```typescript
{
id: string // 资源唯一标识
title: string // 资源标题
description?: string // 资源描述
moduleId: string // 所属模块ID
moduleName: string // 所属模块名称
tagId: string // 所属标签ID
tagName: string // 所属标签名称
subTagId?: string // 所属子标签ID(可选)
subTagName?: string // 所属子标签名称(可选)
}
```
### 3. 数据统计
- **4个模块**:
1. module-1: 新手导航 (5个资源)
2. module-2: vlog发现与记录 (6个资源)
3. module-3: 访谈聆听与对话 (3个资源)
4. module-4: 电影创作与表达 (7个资源)
- **8种标签类型**:
- 视觉素养 (橙色)
- 摄影基础 (蓝色)
- 剪映基础操作 (紫色)
- 内容策划 (橙色)
- 拍摄执行 (绿色)
- 后期制作 (蓝色)
- AI与版权伦理知识 (蓝色)
- 思路提点与案例赏析 (蓝色)
- **总资源数**:21个学习资源
### 4. 工具函数
```typescript
getAllResources() // 获取所有资源
getResourcesByModule(moduleId) // 按模块筛选
getResourcesByTag(moduleId, tagId) // 按标签筛选
getResourcesBySubTag(moduleId, tagId, subTagId) // 按子标签筛选
getAllModules() // 获取所有模块
getTagsByModule(moduleId) // 获取模块的标签
getSubTagsByTag(moduleId, tagId) // 获取标签的子标签
getTagResourceCount(moduleId, tagId) // 获取标签的资源数量
```
---
## 🎨 UI 布局
### 页面布局(1920x1080)
```
┌─────────────────────────────────────────────────────────────┐
│ [搜索框 + 面包屑] [模块1][模块2][模块3][模块4] [X] │
│ │
│ │
│ ┌──────────────┐ ┌──────────────────┐ │
│ │ │ │ │ │
│ │ 资源列表 │ │ 技能树 │ │
│ │ 740x800 │ │ 860x820 │ │
│ │ │ │ │ │
│ │ │ │ [标签节点...] │ │
│ │ [资源卡片] │ │ │ │
│ │ [资源卡片] │ │ 技能树总览 │ │
│ └──────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
### 组件定位
1. **搜索框**:左上角 (left-10, top-10, 547x84px)
2. **模块图标**:顶部居中 (left-55%, top-10, 4个141x150px)
3. **资源列表**:左下角 (left-10, bottom-20, 740x800px)
4. **技能树**:右下角 (right-0, bottom-20, 860x820px)
5. **关闭按钮**:右上角 (right-0, top-0)
---
## 🔄 交互逻辑流程
### 1. 状态管理
```typescript
// 核心状态
selectedModuleId: string | null // 当前选中的模块
selectedTagId: string | null // 当前选中的标签
selectedSubTagId: string | null // 当前选中的子标签
expandedTags: Set<string> // 展开的标签集合
search: string // 搜索文本(待实现)
```
### 2. 用户交互流程
#### 流程A:模块筛选
```
用户点击模块图标
↓
handleModuleListItemClick(moduleId)
↓
如果已选中同一模块 → 取消选择(显示所有)
如果选中新模块 → 设置 selectedModuleId
↓
清空标签和子标签选择
↓
技能树只显示该模块的标签
资源列表只显示该模块的资源
```
#### 流程B:标签筛选
```
用户点击技能树标签
↓
handleTagClick(moduleId, tagId)
↓
如果已选中同一标签:
- 已展开 → 折叠并取消选择
- 未展开 → 展开子标签
如果选中新标签:
- 设置 selectedModuleId 和 selectedTagId
- 如果有子标签 → 自动展开
↓
资源列表显示该标签的所有资源
搜索框显示面包屑:[模块] > [标签]
```
#### 流程C:子标签筛选
```
用户点击子标签
↓
handleSubTagClick(moduleId, tagId, subTagId)
↓
如果已选中同一子标签 → 取消选择
如果选中新子标签 → 设置 selectedSubTagId
↓
资源列表显示该子标签的资源
搜索框显示面包屑:[模块] > [标签] > [子标签]
```
### 3. 计算属性逻辑
#### searchTags(面包屑)
```typescript
根据选择状态生成面包屑数组:
- 选中模块 → 添加模块标签(蓝色)
- 选中标签 → 添加标签标签(橙色)
- 选中子标签 → 添加子标签标签(紫色)
```
#### filteredResources(资源过滤)
```typescript
优先级:子标签 > 标签 > 模块 > 全部
1. 如果选中子标签 → 返回子标签的资源
2. 否则如果选中标签 → 返回标签的资源
3. 否则如果选中模块 → 返回模块的资源
4. 否则 → 返回所有资源
```
---
## 🌳 技能树组件详解
### 1. 核心功能
- **标签去重**:使用 `displayedTags` 计算属性,通过 `moduleId-tagId` 作为唯一键去重
- **位置布局**:14个预定义位置,形成树形分布
- **颜色映射**:根据标签名称返回不同颜色
- **展开/折叠**:有子标签的标签可以展开显示子标签列表
- **数字徽章**:显示每个标签下的资源数量
### 2. 标签位置分布
```typescript
14个预定义位置(百分比定位):
顶部区域(树冠):
- (8%, 35%) - (12%, 55%) - (18%, 72%)
中上区域:
- (28%, 18%) - (32%, 82%)
中部区域:
- (42%, 10%) - (45%, 28%) - (48%, 68%)
中下区域:
- (58%, 22%) - (62%, 78%) - (68%, 85%)
底部区域(树干):
- (75%, 15%) - (78%, 38%) - (82%, 65%)
```
### 3. 标签样式
```css
基础样式:
- 圆角矩形:rounded-xl
- 内边距:px-4 py-2
- 边框:border-2
- 尺寸:min-w-[140px] max-w-[200px]
- 阴影:shadow-lg
交互效果:
- hover:scale-105
- 选中:橙色背景 + 白色光圈
颜色映射:
- 后期制作:蓝色 (bg-blue-500/85)
- 拍摄执行:绿色 (bg-green-500/85)
- 内容策划:橙色 (bg-orange-500/85)
- 视觉素养:橙色 (bg-orange-500/85)
- 摄影基础:蓝色 (bg-blue-500/85)
- 剪映基础操作:紫色 (bg-purple-500/85)
- AI与版权伦理知识:蓝色 (bg-blue-500/85)
- 思路提点与案例赏析:蓝色 (bg-blue-500/85)
```
### 4. 子标签展开
```
父标签被点击且有子标签
↓
在父标签下方显示子标签列表
↓
子标签样式:
- 更小的尺寸 (px-3 py-1)
- 圆角矩形 (rounded-lg)
- 蓝色背景(未选中)/ 橙色背景(选中)
- 垂直排列 (space-y-1)
```
---
## 📋 资源列表组件
### 功能
- 显示过滤后的资源列表
- 滚动容器(overflow-y-auto)
- 空状态提示
### 资源卡片样式
```css
- 半透明蓝色背景:bg-blue-500/20
- 圆角:rounded
- 内边距:p-4
- hover效果:bg-blue-500/30
- 标题:text-2xl font-bold
- 描述:text-lg text-gray-300
```
---
## 🔍 搜索标签组件
### 功能
- 显示当前选择路径(面包屑导航)
- 不同类型不同颜色
### 标签样式
```css
- 圆角胶囊:rounded-full
- 边框:border border-white/80
- 内边距:px-3 py-1
- 颜色映射:
- 模块:蓝色 (bg-blue-500/80)
- 标签:橙色 (bg-orange-500/80)
- 子标签:紫色 (bg-purple-500/80)
```
---
## 🎬 视频播放页面
### 功能
- 播放学习视频
- 全屏视频播放器
- 返回按钮
### 当前问题
- 硬编码视频路径:`/video/xinshou_jiuzhonggoutufa.mp4`
- 未接收路由参数
- 无法动态切换视频
### 建议改进
```typescript
// 应该接收资源ID参数
const route = useRoute()
const resourceId = route.params.id
// 根据资源ID获取视频路径
const videoSrc = getVideoByResourceId(resourceId)
```
---
## 🚀 功能特点
### ✅ 已实现
1. **多层级筛选**:模块 → 标签 → 子标签
2. **可视化导航**:技能树形式展示
3. **实时过滤**:点击即时更新资源列表
4. **面包屑导航**:清晰显示当前位置
5. **标签去重**:避免重复显示
6. **展开/折叠**:子标签动态显示
7. **资源计数**:显示每个标签的资源数量
8. **响应式交互**:hover、选中等视觉反馈
### ⚠️ 待完善
1. **搜索功能**:文本搜索未实现
2. **资源详情**:点击资源卡片无响应
3. **视频播放**:未与资源数据关联
4. **学习进度**:无进度追踪
5. **数据持久化**:刷新后状态丢失
---
## 💡 优化建议
### 1. 完善搜索功能
```typescript
// 在 filteredResources 中添加文本搜索
const filteredResources = computed(() => {
let resources = /* 现有筛选逻辑 */
// 添加文本搜索
if (search.value.trim()) {
resources = resources.filter(r =>
r.title.includes(search.value) ||
r.description?.includes(search.value)
)
}
return resources
})
```
### 2. 资源点击跳转
```typescript
// ResourceList.vue
const handleResourceClick = (resource: ResourceItem) => {
router.push({
name: 'SkillVideo',
params: { id: resource.id }
})
}
```
### 3. 动态视频播放
```typescript
// video.vue
const route = useRoute()
const resourceId = route.params.id as string
const resource = getAllResources().find(r => r.id === resourceId)
const videoSrc = `/video/${resource?.videoFile || 'default.mp4'}`
```
### 4. 学习进度追踪
```typescript
// 添加到 ResourceItem
interface ResourceItem {
// ... 现有字段
completed?: boolean
progress?: number
lastViewedAt?: Date
}
```
### 5. 状态持久化
```typescript
// 使用 localStorage 或 Pinia
import { useLocalStorage } from '@vueuse/core'
const selectedModuleId = useLocalStorage('skill-module', null)
const selectedTagId = useLocalStorage('skill-tag', null)
```
---
## 📈 数据扩展建议
### 1. 添加视频文件字段
```typescript
interface ResourceItem {
// ... 现有字段
videoFile?: string // 视频文件名
duration?: number // 视频时长(秒)
difficulty?: 'easy' | 'medium' | 'hard' // 难度
}
```
### 2. 添加标签元数据
```typescript
interface TagMeta {
id: string
name: string
color: string
icon?: string
description?: string
}
```
### 3. 添加模块元数据
```typescript
interface ModuleMeta {
id: string
name: string
description: string
totalResources: number
completedResources: number
progress: number
}
```
---
## 🎯 总结
这是一个**结构清晰、功能完整的技能树资源浏览系统**,核心特点:
1. **数据驱动**:基于一维数组的资源数据,通过工具函数灵活查询
2. **层级筛选**:支持模块→标签→子标签的多层级筛选
3. **可视化导航**:技能树形式直观展示资源结构
4. **实时反馈**:点击即时更新,面包屑导航清晰
5. **代码整洁**:已清理冗余代码,结构简洁明了
主要改进方向是完善搜索、资源详情、视频播放和学习进度追踪功能。
+429
View File
@@ -0,0 +1,429 @@
# 🔄 技能树功能逻辑关系完整梳理
## 📊 核心状态管理
### 状态变量
```typescript
selectedModuleId: string | null // 当前选中的模块ID(null = 总览模式)
selectedTagId: string | null // 当前选中的标签ID
selectedSubTagId: string | null // 当前选中的子标签ID
expandedTags: Set<string> // 展开的标签集合
search: string // 搜索文本
```
---
## 🎯 功能模块详解
### 1. 模块切换与技能树标题
#### 逻辑流程
```
用户点击模块图标
↓
handleModuleListItemClick(moduleId)
↓
如果点击的是已选中的模块 → 取消选择(回到总览)
如果点击的是新模块 → 选中该模块
↓
更新 selectedModuleId
清空 selectedTagId、selectedSubTagId
清空 expandedTags
↓
技能树标题更新:
- selectedModuleId = null → "技能树总览"
- selectedModuleId = "module-1" → "新手导航"
- selectedModuleId = "module-2" → "vlog发现与记录"
- selectedModuleId = "module-3" → "访谈聆听与对话"
- selectedModuleId = "module-4" → "电影创作与表达"
```
#### 实现位置
- **SkillTree.vue**: `getTreeTitle()` 函数
- **index.vue**: `handleModuleListItemClick()` 函数
---
### 2. 标签点击逻辑(核心)
#### 逻辑分支
```
用户点击技能树标签
↓
handleTagClick(moduleId, tagId)
↓
检查是否有子标签
↓
┌─────────────────────────────────────────────────────────┐
│ 分支A: 有子标签 │
├─────────────────────────────────────────────────────────┤
│ 如果点击的是已选中的标签: │
│ - 已展开 → 折叠(删除 tagId from expandedTags) │
│ - 未展开 → 展开(添加 tagId to expandedTags) │
│ │
│ 如果点击的是新标签: │
│ - 设置 selectedModuleId = moduleId │
│ - 设置 selectedTagId = tagId │
│ - 清空 selectedSubTagId = null │
│ - 自动展开(添加 tagId to expandedTags) │
│ │
│ 结果:展开/折叠子节点,不触发搜索 │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 分支B: 无子标签 │
├─────────────────────────────────────────────────────────┤
│ - 设置 selectedModuleId = moduleId │
│ - 设置 selectedTagId = tagId │
│ - 清空 selectedSubTagId = null │
│ - 清空 expandedTags │
│ │
│ 结果:高亮标签 + 触发搜索,其他标签保持可见 │
└─────────────────────────────────────────────────────────┘
```
#### 关键特性
- ✅ 有子标签:只展开/折叠,不搜索
- ✅ 无子标签:高亮并搜索,不隐藏其他标签
- ✅ 其他标签始终可见(不会因为选择而隐藏)
#### 实现位置
- **index.vue**: `handleTagClick()` 函数
---
### 3. 子标签点击逻辑
#### 逻辑流程
```
用户点击子标签
↓
handleSubTagClick(moduleId, tagId, subTagId)
↓
如果点击的是已选中的子标签 → 取消选择
如果点击的是新子标签 → 选中该子标签
↓
更新状态:
- selectedModuleId = moduleId
- selectedTagId = tagId
- selectedSubTagId = subTagId
- 确保父标签展开(添加 tagId to expandedTags)
↓
触发资源过滤(显示该子标签的资源)
```
#### 实现位置
- **index.vue**: `handleSubTagClick()` 函数
---
### 4. 资源过滤逻辑
#### 过滤优先级
```
1. 子标签过滤(最精确)
↓
2. 标签过滤
↓
3. 模块过滤
↓
4. 全部资源(默认)
↓
5. 文本搜索(叠加过滤)
```
#### 详细流程
```typescript
filteredResources = computed(() => {
let resources = []
// 第一步:根据选择获取资源
if (selectedSubTagId) {
resources = getResourcesBySubTag(moduleId, tagId, subTagId)
} else if (selectedTagId) {
resources = getResourcesByTag(moduleId, tagId)
} else if (selectedModuleId) {
resources = getResourcesByModule(moduleId)
} else {
resources = getAllResources()
}
// 第二步:文本搜索(如果有搜索文本)
if (search.trim()) {
resources = resources.filter(
r =>
r.title.includes(search) ||
r.moduleName.includes(search) ||
r.tagName.includes(search) ||
r.description?.includes(search)
)
}
return resources
})
```
#### 搜索字段
- ✅ `title` - 资源标题
- ✅ `moduleName` - 模块名称
- ✅ `tagName` - 标签名称
- ✅ `description` - 资源描述
#### 实现位置
- **index.vue**: `filteredResources` 计算属性
---
### 5. 搜索标签(面包屑)
#### 显示逻辑
```
searchTags = computed(() => {
const tags = []
if (selectedModuleId) {
tags.push({ type: 'module', name: moduleName })
}
if (selectedTagId) {
tags.push({ type: 'tag', name: tagName })
}
if (selectedSubTagId) {
tags.push({ type: 'subTag', name: subTagName })
}
return tags
})
```
#### 标签样式
- **模块标签**:蓝色(`bg-blue-500/80`)
- **标签标签**:橙色(`bg-orange-500/80`)
- **子标签标签**:紫色(`bg-purple-500/80`)
#### 删除功能
```
用户点击标签的 × 按钮
↓
handleRemoveTag(tagType)
↓
根据 tagType 清除对应状态:
- 'subTag' → 清除 selectedSubTagId
- 'tag' → 清除 selectedTagId + selectedSubTagId + expandedTags
- 'module' → 清除所有选择状态
↓
资源列表自动更新
```
#### 实现位置
- **index.vue**: `searchTags` 计算属性, `handleRemoveTag()` 函数
- **SearchTags.vue**: 标签显示和删除按钮
---
### 6. 文本搜索
#### 搜索框行为
```
用户在搜索框输入文本
↓
v-model 绑定到 search 变量
↓
filteredResources 自动重新计算
↓
资源列表实时更新
```
#### 搜索特性
- ✅ 实时搜索(无需点击搜索按钮)
- ✅ 多字段搜索(title、moduleName、tagName、description)
- ✅ 大小写不敏感
- ✅ 叠加过滤(在已选择的基础上进一步过滤)
- ✅ 搜索文本为空时,不影响选择过滤
#### 实现位置
- **index.vue**: 搜索框 `v-model="search"`
---
## 🎨 UI 交互状态
### 标签高亮状态
```typescript
isTagSelected(moduleId, tagId) {
return selectedModuleId === moduleId && selectedTagId === tagId
}
```
### 标签展开状态
```typescript
isTagExpanded(tagId) {
return expandedTags.has(tagId)
}
```
### 子标签选中状态
```typescript
isSubTagSelected(moduleId, tagId, subTagId) {
return selectedModuleId === moduleId &&
selectedTagId === tagId &&
selectedSubTagId === subTagId
}
```
---
## 📈 数据流向图
```
用户操作
↓
┌─────────────────────────────────────────────────────────┐
│ 点击模块图标 → handleModuleListItemClick │
│ 点击标签 → handleTagClick │
│ 点击子标签 → handleSubTagClick │
│ 输入搜索文本 → v-model:search │
│ 删除面包屑标签 → handleRemoveTag │
└─────────────────────────────────────────────────────────┘
↓
更新状态变量
- selectedModuleId
- selectedTagId
- selectedSubTagId
- expandedTags
- search
↓
触发计算属性重新计算
- searchTags(面包屑)
- filteredResources(资源列表)
- displayedTags(技能树标签)
- displayedModules(显示的模块)
↓
UI 自动更新
- 技能树标题
- 标签高亮状态
- 子节点展开/折叠
- 面包屑标签
- 资源列表
```
---
## 🔍 关键场景示例
### 场景1:从总览到具体资源
```
1. 初始状态:总览模式
- selectedModuleId = null
- 显示 "技能树总览"
- 显示所有模块的标签(去重)
2. 点击 "新手导航" 模块
- selectedModuleId = "module-1"
- 显示 "新手导航"
- 只显示新手导航的标签
3. 点击 "视觉素养" 标签(有子标签)
- selectedTagId = "tag-1-1"
- 展开子标签:构图、光线、运镜
- 资源列表显示视觉素养的所有资源
4. 点击 "构图" 子标签
- selectedSubTagId = "subtag-1-1-1"
- 资源列表只显示构图相关资源
- 面包屑:新手导航 > 视觉素养 > 构图
```
### 场景2:搜索功能
```
1. 在搜索框输入 "构图"
- search = "构图"
- 资源列表实时过滤,显示所有包含"构图"的资源
2. 点击面包屑标签的删除按钮
- 清除对应的选择
- 资源列表更新
3. 清空搜索框
- search = ""
- 资源列表恢复到选择过滤的结果
```
### 场景3:标签点击行为
```
有子标签的标签(如"视觉素养"):
- 第一次点击 → 展开子节点
- 第二次点击 → 折叠子节点
- 不触发搜索,其他标签保持可见
无子标签的标签(如"摄影基础"):
- 点击 → 高亮标签 + 过滤资源
- 其他标签保持可见
- 可以继续点击其他标签
```
---
## ✅ 功能清单
### 已实现功能
- ✅ 模块切换(总览 ↔ 具体模块)
- ✅ 技能树标题动态更新
- ✅ 标签点击逻辑(有/无子标签分支)
- ✅ 子标签展开/折叠
- ✅ 标签高亮显示
- ✅ 资源多级过滤(模块 → 标签 → 子标签)
- ✅ 文本搜索(多字段)
- ✅ 面包屑导航
- ✅ 标签删除功能
- ✅ 实时搜索
- ✅ 标签去重(总览模式)
### 核心特性
- 🎯 **智能标签点击**:根据是否有子标签自动选择行为
- 🔍 **多维度搜索**:选择过滤 + 文本搜索
- 🏷️ **面包屑导航**:清晰显示当前位置
- 🗑️ **灵活删除**:可以删除任意层级的选择
- 👁️ **保持可见**:选择标签后其他标签不隐藏
---
## 🎯 总结
这是一个**多层级、多维度的资源浏览和搜索系统**,核心特点:
1. **灵活的导航**:支持模块 → 标签 → 子标签的层级导航
2. **智能的交互**:根据标签类型自动选择展开或搜索
3. **强大的搜索**:选择过滤 + 文本搜索双重过滤
4. **清晰的反馈**:面包屑、高亮、展开状态等多种视觉反馈
5. **用户友好**:可以随时删除选择,标签始终可见
整个系统通过 Vue 的响应式系统自动同步状态和 UI,用户的每个操作都会立即反映在界面上。
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

+6 -6
View File
@@ -154,14 +154,14 @@ const currentStepContent = computed(() => {
const step = currentStepData.value const step = currentStepData.value
if (!step) return '' if (!step) return ''
if (!step.title) { // if (!step.title) {
return `${step.content}` return `${step.content}`
} // }
return ` // return `
<h3 class="text-lg font-bold mb-2">${step.title}</h3> // <h3 class="text-lg font-bold mb-2">${step.title}</h3>
<div class="leading-relaxed">${step.content}</div> // <div class="leading-relaxed">${step.content}</div>
` // `
}) })
const handleNext = () => { const handleNext = () => {
+3 -31
View File
@@ -56,7 +56,7 @@
</div> --> </div> -->
<!-- 步骤标签 --> <!-- 步骤标签 -->
<div class="step-label" :class="`step-label--${step.type}`"> <div class="step-label" :class="getTagColor(step.label)">
{{ step.label }} {{ step.label }}
</div> </div>
@@ -93,6 +93,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, nextTick, watch } from 'vue' import { ref, computed, onMounted, nextTick, watch } from 'vue'
import { getTagColor } from '@/utils'
export interface StepItem { export interface StepItem {
id: string id: string
@@ -354,38 +355,9 @@ onMounted(() => {
.step-label { .step-label {
@apply px-16 py-4 rounded-full text-2xl font-semibold mr-4 whitespace-nowrap; @apply px-16 py-4 rounded-full text-2xl font-semibold mr-4 whitespace-nowrap;
color: white;
min-width: 5rem; min-width: 5rem;
text-align: center; text-align: center;
} box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
.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);
} }
.step-content { .step-content {
+15
View File
@@ -221,3 +221,18 @@ export const getPublicPath = (path: string) => {
// 使用 Vite 的 BASE_URL 环境变量来处理基础路径 // 使用 Vite 的 BASE_URL 环境变量来处理基础路径
return `${import.meta.env.BASE_URL}${cleanPath}` 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 = [ const rawSteps = [
{ {
id: 'step2', id: 'step2',
label: '拍摄基础', label: '摄影基础',
content: '手机摄像头介绍', content: '手机摄像头介绍',
type: 'post' as const type: 'post' as const
}, },
{ {
id: 'step3', id: 'step3',
label: '拍摄基础', label: '摄影基础',
content: '景别概念介绍', content: '景别概念介绍',
type: 'post' as const type: 'post' as const
}, },
{ {
id: 'step4', id: 'step4',
label: '拍摄基础', label: '摄影基础',
content: '手机慢动作', content: '手机慢动作',
type: 'post' as const type: 'post' as const
}, },
{ {
id: 'step5', id: 'step5',
label: '拍摄基础', label: '摄影基础',
content: '延时摄影', content: '延时摄影',
type: 'post' as const type: 'post' as const
}, },
{ {
id: 'step6', id: 'step6',
label: '拍摄基础', label: '摄影基础',
content: '神奇的微距摄影', content: '神奇的微距摄影',
type: 'post' as const type: 'post' as const
}, },
{ {
id: 'step7', id: 'step7',
label: '拍摄基础', label: '摄影基础',
content: '独具魅力的夜景摄影', content: '独具魅力的夜景摄影',
type: 'post' as const type: 'post' as const
} }
+3 -3
View File
@@ -33,19 +33,19 @@ const handleComplete = () => {
const navigationSteps = ref([ const navigationSteps = ref([
{ {
id: 'step2', id: 'step2',
label: '光的基础', label: '视觉素养',
content: '光的基础认知', content: '光的基础认知',
type: 'planning' as const type: 'planning' as const
}, },
{ {
id: 'step3', id: 'step3',
label: '光的属性', label: '视觉素养',
content: '光的四大属性', content: '光的四大属性',
type: 'planning' as const type: 'planning' as const
}, },
{ {
id: 'step4', id: 'step4',
label: '拍摄技巧', label: '视觉素养',
content: '拍摄的黄金时间', content: '拍摄的黄金时间',
type: 'planning' as const 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> <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> </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>
+84 -7
View File
@@ -4,26 +4,103 @@ import type { ResourceItem } from '../resourceData'
const props = defineProps<{ const props = defineProps<{
resources: ResourceItem[] resources: ResourceItem[]
}>() }>()
const emit = defineEmits<{
(e: 'resourceClick', resource: ResourceItem): void
}>()
const handleResourceClick = (resource: ResourceItem) => {
emit('resourceClick', resource)
}
</script> </script>
<template> <template>
<div class="w-full h-full overflow-y-auto"> <div
<div v-if="props.resources.length === 0" class="text-white text-2xl text-center mt-10"> 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>
<div v-else class="space-y-4"> <div v-else class="space-y-3">
<div <div
v-for="resource in props.resources" v-for="resource in props.resources"
:key="resource.id" :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"> <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 }} {{ resource.description }}
</div> </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> </div>
</div> </div>
</template> </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"> <script setup lang="ts">
import { getTagColor } from '@/utils'
interface SearchTag { interface SearchTag {
type: 'module' | 'tag' | 'subTag' type: 'module' | 'tag' | 'subTag'
name: string name: string
@@ -7,6 +9,26 @@ interface SearchTag {
const props = defineProps<{ const props = defineProps<{
tags: SearchTag[] 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> </script>
<template> <template>
@@ -14,14 +36,18 @@ const props = defineProps<{
<div <div
v-for="(tag, index) in props.tags" v-for="(tag, index) in props.tags"
:key="index" :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="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="{ :class="getTagBgClass(tag)"
'bg-blue-500/80': tag.type === 'module',
'bg-orange-500/80': tag.type === 'tag',
'bg-purple-500/80': tag.type === 'subTag'
}"
> >
{{ 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>
</div> </div>
</template> </template>
+236 -132
View File
@@ -6,6 +6,7 @@ import {
getSubTagsByTag, getSubTagsByTag,
getTagResourceCount getTagResourceCount
} from '../resourceData' } from '../resourceData'
import { getStaticImage, getTagColor } from '@/utils'
interface Props { interface Props {
selectedModuleId: string | null selectedModuleId: string | null
@@ -32,6 +33,54 @@ const displayedModules = computed(() => {
return allModules.value 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) => { const isTagExpanded = (tagId: string) => {
return props.expandedTags.has(tagId) return props.expandedTags.has(tagId)
@@ -39,42 +88,57 @@ const isTagExpanded = (tagId: string) => {
// 检查标签是否选中 // 检查标签是否选中
const isTagSelected = (moduleId: string, tagId: string) => { const isTagSelected = (moduleId: string, tagId: string) => {
if (props.selectedModuleId) {
// 模块模式:需要模块和标签都匹配
return props.selectedModuleId === moduleId && props.selectedTagId === tagId return props.selectedModuleId === moduleId && props.selectedTagId === tagId
} else {
// 总览模式:只需要标签匹配(因为总览模式下 selectedModuleId 为 null)
return props.selectedTagId === tagId
}
} }
// 检查子标签是否选中 // 检查子标签是否选中
const isSubTagSelected = (moduleId: string, tagId: string, subTagId: string) => { const isSubTagSelected = (moduleId: string, tagId: string, subTagId: string) => {
if (props.selectedModuleId) {
// 模块模式:需要模块、标签和子标签都匹配
return ( return (
props.selectedModuleId === moduleId && props.selectedModuleId === moduleId &&
props.selectedTagId === tagId && props.selectedTagId === tagId &&
props.selectedSubTagId === subTagId props.selectedSubTagId === subTagId
) )
} else {
// 总览模式:只需要标签和子标签匹配
return props.selectedTagId === tagId && props.selectedSubTagId === subTagId
}
} }
// 获取标签的资源数量 // 获取标签的资源数量(如果显示所有模块,则合并同名标签的数量)
const getResourceCount = (moduleId: string, tagId: string) => { const getResourceCount = (moduleId: string, tagId: string, tagName: string) => {
if (props.selectedModuleId) {
// 如果选中了模块,只计算该模块的资源数量
return getTagResourceCount(moduleId, tagId) 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) => { const getTagColorClass = (tagName: string, isSelected: boolean) => {
if (isSelected) { if (isSelected) {
return 'bg-orange-500 border-white text-white' return getTagColor(tagName) + ' brightness-125'
} }
// 根据标签名称返回不同的颜色 return getTagColor(tagName)
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) => { const handleTagClick = (moduleId: string, tagId: string) => {
@@ -85,166 +149,206 @@ const handleSubTagClick = (moduleId: string, tagId: string, subTagId: string) =>
emit('subTagClick', moduleId, tagId, subTagId) emit('subTagClick', moduleId, tagId, subTagId)
} }
// 计算标签节点的位置 - 使用更自然的树形分布 // 获取子标签的图标
const getTagPosition = ( const getSubTagIcon = (subTagName: string) => {
moduleIndex: number, // 图标文件名映射
tagIndex: number, const iconMap: Record<string, string> = {
totalTags: number, 构图: 'skill/icon/构图.png',
moduleId: string 光线: 'skill/icon/光线.png',
) => { 运镜: 'skill/icon/运镜.png',
// 计算所有标签的总数(用于全局定位) 设备准备: 'skill/icon/设备准备.png',
const modules = displayedModules.value 脚本制作: 'skill/icon/脚本与分镜制作.png',
let globalTagIndex = 0 光线运用: 'skill/icon/光线运用.png',
for (let i = 0; i < moduleIndex; i++) { 运镜技巧: 'skill/icon/运镜技巧.png',
globalTagIndex += getTagsByModule(modules[i].id).length 选题与构思: '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'
}
return iconMap[subTagName] || 'skill/icon/构图.png' // 默认图标
} }
globalTagIndex += tagIndex
// 计算总标签数 // 获取技能树标题
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 || '技能树总览'
}
// 垂直位置:从树根向上分布,形成树形结构 // 计算标签节点的位置 - 根据目标效果图1的布局
const baseTop = 85 // 树根位置(底部) const getTagPosition = (tagIndex: number) => {
const maxTop = 15 // 最顶部位置 // 定义所有标签的具体位置(根据目标效果图1)
const verticalRange = baseTop - maxTop // 标签顺序:视觉素养、摄影基础、剪映基础操作、内容策划、拍摄执行、后期制作、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 position = positions[tagIndex % positions.length]
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 { return {
top: `${Math.max(maxTop, Math.min(baseTop, top))}%`, top: `${position.top}%`,
left: `${Math.max(5, Math.min(95, left))}%` left: `${position.left}%`
} }
} }
// 计算连接线的起点和终点 // 计算子标签围绕父标签的位置(圆形分布)
const getConnectionLine = ( const getSubTagPosition = (subTagIndex: number, totalSubTags: number) => {
moduleIndex: number, // 圆形半径(单位:像素)- 根据子标签数量调整半径
tagIndex: number, // 使用更大的半径确保子标签不会挤在一起
totalTags: number, const baseRadius = 160 // 基础半径180px
moduleId: string const radius = totalSubTags <= 3 ? baseRadius : baseRadius + (totalSubTags - 3) * 30
) => {
const tagPos = getTagPosition(moduleIndex, tagIndex, totalTags, moduleId) // 计算角度:均匀分布在360度圆周上
const rootX = 50 // 树根中心X位置 // 从顶部开始(-90度),顺时针分布
const rootY = 92 // 树根中心Y位置(底部) 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 { return {
x1: rootX, transform: `translate(calc(-50% + ${offsetX}px), calc(-50% + ${offsetY + 30}px))` // 让子标签以自身中心定位,并应用偏移
y1: rootY,
x2: parseFloat(tagPos.left),
y2: parseFloat(tagPos.top) + 3 // 标签中心位置
} }
} }
</script> </script>
<template> <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 inset-0">
<!-- 树根节点 - 底部居中 --> <!-- 模块名称 - 底部居中 -->
<div <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> </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 displayedTags" :key="`${tag.moduleId}-${tag.tagId}`">
<template v-for="(tag, tagIndex) in getTagsByModule(module.id)" :key="tag.id"> <!-- 标签节点容器 - 选中的标签提高 z-index 显示在蒙版之上 -->
<!-- 标签节点 -->
<div <div
class="absolute z-10" class="absolute"
:style=" :class="isTagSelected(tag.moduleId, tag.tagId) ? 'z-20' : 'z-[1]'"
getTagPosition(moduleIndex, tagIndex, getTagsByModule(module.id).length, module.id) :style="getTagPosition(tagIndex)"
"
> >
<!-- 资源标签节点 --> <!-- 资源标签节点 -->
<div class="relative">
<!-- 标签主体容器 - 用于精确对齐定位层 -->
<div class="relative">
<!-- 标签主体 -->
<div <div
:class="[ :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]', 'relative py-5 cursor-pointer min-w-[240px] max-w-[220px] transition-all hover:scale-105',
getTagColorClass(tag.name, isTagSelected(module.id, tag.id)), 'border-4 rounded-full shadow-lg',
isTagSelected(module.id, tag.id) ? 'ring-2 ring-orange-300 z-20' : '' getTagColorClass(tag.tagName, isTagSelected(tag.moduleId, tag.tagId)),
isTagSelected(tag.moduleId, tag.tagId) ? 'ring-2 ring-white/60' : ''
]" ]"
@click="handleTagClick(module.id, tag.id)" @click="handleTagClick(tag.moduleId, tag.tagId)"
> >
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-center gap-2 relative z-10">
<span class="text-sm font-semibold truncate">{{ tag.name }}</span> <span class="text-2xl font-bold truncate">{{ tag.tagName }}</span>
<div class="flex items-center gap-1.5 flex-shrink-0"> <!-- 展开箭头 -->
<span <!-- <span
class="flex items-center justify-center w-5 h-5 bg-white/30 rounded-full text-white text-xs font-bold" v-if="getSubTagsByTag(tag.moduleId, tag.tagId).length > 0"
class="text-white text-xs flex-shrink-0"
> >
{{ getResourceCount(module.id, tag.id) }} {{ isTagExpanded(tag.tagId) ? '▼' : '▶' }}
</span> </span> -->
<span </div>
v-if="getSubTagsByTag(module.id, tag.id).length > 0" </div>
class="text-white text-xs"
<!-- 子标签定位层 - 使用 inset-0 与标签主体完全对齐(包括 border 和 padding) -->
<div
v-if="
getSubTagsByTag(tag.moduleId, tag.tagId).length > 0 && isTagExpanded(tag.tagId)
"
class="absolute inset-0 pointer-events-none"
> >
{{ isTagExpanded(tag.id) ? '▼' : '▶' }} <!-- 子标签列表(围绕父标签圆形分布) -->
</span> <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)"
>
<!-- 图标-图标资源已经带了文字 -->
<div class="w-30">
<img :src="getStaticImage(getSubTagIcon(subTag.name))" :alt="subTag.name" />
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- 子标签列表(展开时显示) --> <!-- 数字徽章 - 右上角外侧 -->
<div <div
v-if="getSubTagsByTag(module.id, tag.id).length > 0 && isTagExpanded(tag.id)" 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"
class="absolute top-full left-0 mt-2 space-y-1.5 z-30"
> >
<div {{ getResourceCount(tag.moduleId, tag.tagId, tag.tagName) }}
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> </div>
</div> </div>
</template> </template>
</template>
</div> </div>
<!-- 点击之后的蒙版 - 使用径向渐变,从中心透明到边缘半透明黑色 -->
<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
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>
<!-- 右侧标签 --> <!-- 右侧标签 -->
<div class="absolute right-0 top-1/2 -translate-y-1/2 -translate-x-4 z-20"> <img class="absolute right-4 top-0 z-30" :src="getStaticImage('skill/技能树标题.png')" alt="" />
<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> </div>
</template> </template>
+260 -41
View File
@@ -1,8 +1,10 @@
<script lang="ts" setup> <script lang="ts" setup>
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils' import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
import { ImgButton } from '@/components/Button' import { ImgButton } from '@/components/Button'
import { ImagePreview } from '@/components'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useScale } from '@/composables/useScale'
import { import {
getAllResources, getAllResources,
getResourcesByModule, getResourcesByModule,
@@ -14,12 +16,56 @@ import {
import SearchTags from './components/SearchTags.vue' import SearchTags from './components/SearchTags.vue'
import ResourceList from './components/ResourceList.vue' import ResourceList from './components/ResourceList.vue'
import SkillTree from './components/SkillTree.vue' import SkillTree from './components/SkillTree.vue'
import GuideSteps from '@/components/Guide/GuideSteps.vue'
const router = useRouter() const router = useRouter()
const { scale, offsetX, offsetY } = useScale()
// 搜索框文本(保留用于用户输入搜索) // 搜索框文本(保留用于用户输入搜索)
const search = ref('') 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 * 新手导航:新手导航-选中.png
@@ -67,25 +113,21 @@ const searchTags = computed<SearchTag[]>(() => {
const tags: SearchTag[] = [] const tags: SearchTag[] = []
if (selectedModuleId.value) { if (selectedModuleId.value) {
// 从资源数组中查找模块名 // 模块模式:显示完整路径
const moduleResource = getAllResources().find(r => r.moduleId === selectedModuleId.value) const moduleResource = getAllResources().find(r => r.moduleId === selectedModuleId.value)
if (moduleResource) { if (moduleResource) {
tags.push({ type: 'module', name: moduleResource.moduleName }) tags.push({ type: 'module', name: moduleResource.moduleName })
} }
}
if (selectedModuleId.value && selectedTagId.value) { if (selectedTagId.value) {
// 从资源数组中查找标签名
const tagResource = getAllResources().find( const tagResource = getAllResources().find(
r => r.moduleId === selectedModuleId.value && r.tagId === selectedTagId.value r => r.moduleId === selectedModuleId.value && r.tagId === selectedTagId.value
) )
if (tagResource) { if (tagResource) {
tags.push({ type: 'tag', name: tagResource.tagName }) tags.push({ type: 'tag', name: tagResource.tagName })
} }
}
if (selectedModuleId.value && selectedTagId.value && selectedSubTagId.value) { if (selectedSubTagId.value) {
// 从资源数组中查找子标签名
const subTagResource = getAllResources().find( const subTagResource = getAllResources().find(
r => r =>
r.moduleId === selectedModuleId.value && r.moduleId === selectedModuleId.value &&
@@ -96,29 +138,76 @@ const searchTags = computed<SearchTag[]>(() => {
tags.push({ type: 'subTag', name: 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 (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 })
}
}
}
}
return tags return tags
}) })
// 过滤后的资源列表 // 过滤后的资源列表
const filteredResources = computed<ResourceItem[]>(() => { const filteredResources = computed<ResourceItem[]>(() => {
let resources: ResourceItem[] = []
// 如果选择了子标签,返回子标签的资源 // 如果选择了子标签,返回子标签的资源
if (selectedModuleId.value && selectedTagId.value && selectedSubTagId.value) { if (selectedModuleId.value && selectedTagId.value && selectedSubTagId.value) {
return getResourcesBySubTag(selectedModuleId.value, selectedTagId.value, selectedSubTagId.value) resources = getResourcesBySubTag(
selectedModuleId.value,
selectedTagId.value,
selectedSubTagId.value
)
} }
// 如果选择了资源标签
// 如果选择了资源标签,返回标签的资源 else if (selectedTagId.value) {
if (selectedModuleId.value && selectedTagId.value) {
return getResourcesByTag(selectedModuleId.value, selectedTagId.value)
}
// 如果选择了模块,返回模块的资源
if (selectedModuleId.value) { if (selectedModuleId.value) {
return getResourcesByModule(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)
}
}
}
// 如果选择了模块,返回模块的资源
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) => { const handleTagClick = (moduleId: string, tagId: string) => {
// 如果点击的是已选中的标签,则折叠 // 如果点击的是已选中的标签,则取消选中(关闭遮罩)
if (selectedModuleId.value === moduleId && selectedTagId.value === tagId) { if (selectedTagId.value === tagId) {
if (expandedTags.value.has(tagId)) {
// 如果已展开,则折叠并取消选择
expandedTags.value.delete(tagId)
selectedTagId.value = null selectedTagId.value = null
selectedSubTagId.value = null selectedSubTagId.value = null
} else { expandedTags.value.clear()
// 如果未展开,则展开 return
expandedTags.value.add(tagId)
} }
} else {
// 选择新的标签 // 检查是否有子标签
const subTags = getSubTagsByTag(moduleId, tagId)
const hasSubTags = subTags.length > 0
if (hasSubTags) {
// 有子标签:选择新标签并自动展开
// 在总览模式下,不设置 selectedModuleId,保持总览状态
if (selectedModuleId.value !== null) {
selectedModuleId.value = moduleId selectedModuleId.value = moduleId
}
selectedTagId.value = tagId selectedTagId.value = tagId
selectedSubTagId.value = null selectedSubTagId.value = null
// 自动展开
// 如果标签有子标签,则展开
const subTags = getSubTagsByTag(moduleId, tagId)
if (subTags.length > 0) {
expandedTags.value.add(tagId) expandedTags.value.add(tagId)
} else { } else {
expandedTags.value.delete(tagId) // 无子标签:高亮并搜索,不隐藏其他标签
// 在总览模式下,不设置 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) 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 = () => { const handleClose = () => {
router.push({ name: 'Action' }) 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> </script>
<template> <template>
@@ -201,11 +366,9 @@ const handleClose = () => {
@click="handleClose" @click="handleClose"
/> />
<img class="absolute right-5 top-44" :src="getStaticImage('skill/技能树.png')" alt="" />
<!-- 搜索框 --> <!-- 搜索框 -->
<div <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="{ :style="{
backgroundImage: getBackgroundImage('skill/搜索框-已搜索.png'), backgroundImage: getBackgroundImage('skill/搜索框-已搜索.png'),
backgroundSize: 'cover' backgroundSize: 'cover'
@@ -213,7 +376,7 @@ const handleClose = () => {
> >
<!-- 搜索标签显示 --> <!-- 搜索标签显示 -->
<div class="flex-shrink-0 overflow-x-auto scrollbar-hide"> <div class="flex-shrink-0 overflow-x-auto scrollbar-hide">
<SearchTags :tags="searchTags" /> <SearchTags :tags="searchTags" @remove="handleRemoveTag" />
</div> </div>
<!-- 搜索文本输入(可选,用于额外搜索) --> <!-- 搜索文本输入(可选,用于额外搜索) -->
<input <input
@@ -227,7 +390,9 @@ const handleClose = () => {
</div> </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 class="flex gap-10 -mt-7">
<div <div
v-for="module in moduleList" v-for="module in moduleList"
@@ -242,12 +407,12 @@ const handleClose = () => {
</div> </div>
<!-- 资源列表 --> <!-- 资源列表 -->
<div class="absolute left-10 bottom-20 w-[740px] h-[800px]"> <div class="resource-list-container absolute left-10 bottom-20 w-[740px] h-[800px]">
<ResourceList :resources="filteredResources" /> <ResourceList :resources="filteredResources" @resource-click="handleResourceClick" />
</div> </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 <SkillTree
:selected-module-id="selectedModuleId" :selected-module-id="selectedModuleId"
:expanded-tags="expandedTags" :expanded-tags="expandedTags"
@@ -257,5 +422,59 @@ const handleClose = () => {
@sub-tag-click="handleSubTagClick" @sub-tag-click="handleSubTagClick"
/> />
</div> </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> </div>
</template> </template>
+212 -49
View File
@@ -13,11 +13,16 @@ export interface ResourceItem {
subTagName?: string subTagName?: string
// 资源数量(用于技能树显示) // 资源数量(用于技能树显示)
resourceCount?: number 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[] = [ export const resourceData: ResourceItem[] = [
// 新手导航模块 // 新手导航模块 - 保持原有标签
{ {
id: 'res-1', id: 'res-1',
title: '九种构图法', title: '九种构图法',
@@ -27,7 +32,9 @@ export const resourceData: ResourceItem[] = [
tagId: 'tag-1-1', tagId: 'tag-1-1',
tagName: '视觉素养', tagName: '视觉素养',
subTagId: 'subtag-1-1-1', subTagId: 'subtag-1-1-1',
subTagName: '构图' subTagName: '构图',
type: 'video',
videoUrl: '/video/构图法.mp4'
}, },
{ {
id: 'res-2', id: 'res-2',
@@ -38,7 +45,9 @@ export const resourceData: ResourceItem[] = [
tagId: 'tag-1-1', tagId: 'tag-1-1',
tagName: '视觉素养', tagName: '视觉素养',
subTagId: 'subtag-1-1-2', subTagId: 'subtag-1-1-2',
subTagName: '光线' subTagName: '光线',
type: 'video',
videoUrl: '/video/光线运用.mp4'
}, },
{ {
id: 'res-3', id: 'res-3',
@@ -49,7 +58,9 @@ export const resourceData: ResourceItem[] = [
tagId: 'tag-1-1', tagId: 'tag-1-1',
tagName: '视觉素养', tagName: '视觉素养',
subTagId: 'subtag-1-1-3', subTagId: 'subtag-1-1-3',
subTagName: '运镜' subTagName: '运镜',
type: 'video',
videoUrl: '/video/运镜技巧.mp4'
}, },
{ {
id: 'res-4', id: 'res-4',
@@ -58,7 +69,9 @@ export const resourceData: ResourceItem[] = [
moduleId: 'module-1', moduleId: 'module-1',
moduleName: '新手导航', moduleName: '新手导航',
tagId: 'tag-1-2', tagId: 'tag-1-2',
tagName: '摄影基础' tagName: '摄影基础',
type: 'image',
images: ['/images/skill/摄影基础1.png', '/images/skill/摄影基础2.png']
}, },
{ {
id: 'res-5', id: 'res-5',
@@ -67,33 +80,53 @@ export const resourceData: ResourceItem[] = [
moduleId: 'module-1', moduleId: 'module-1',
moduleName: '新手导航', moduleName: '新手导航',
tagId: 'tag-1-3', tagId: 'tag-1-3',
tagName: '剪映基础操作' tagName: '剪映基础操作',
type: 'video',
videoUrl: '/video/剪映入门.mp4'
}, },
// vlog发现与记录模块
// vlog发现与记录模块 - 使用统一标签体系
{ {
id: 'res-6', id: 'res-6',
title: '设备准备指南', title: 'vlog设备准备',
description: 'vlog拍摄设备准备', description: 'vlog拍摄设备准备指南',
moduleId: 'module-2', moduleId: 'module-2',
moduleName: 'vlog发现与记录', moduleName: 'vlog发现与记录',
tagId: 'tag-2-1', tagId: 'tag-2-1',
tagName: '内容策划', tagName: '内容策划',
subTagId: 'subtag-2-1-1', subTagId: 'subtag-2-1-1',
subTagName: '设备准备' subTagName: '设备准备',
type: 'video',
videoUrl: '/video/vlog设备准备.mp4'
}, },
{ {
id: 'res-7', 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脚本撰写', title: 'vlog脚本撰写',
description: '如何撰写vlog脚本', description: '如何撰写vlog脚本',
moduleId: 'module-2', moduleId: 'module-2',
moduleName: 'vlog发现与记录', moduleName: 'vlog发现与记录',
tagId: 'tag-2-1', tagId: 'tag-2-1',
tagName: '内容策划', tagName: '内容策划',
subTagId: 'subtag-2-1-2', subTagId: 'subtag-2-1-3',
subTagName: '脚本制作' subTagName: '脚本与分镜制作',
type: 'video',
videoUrl: '/video/vlog脚本撰写.mp4'
}, },
{ {
id: 'res-8', id: 'res-9',
title: 'vlog构图技巧', title: 'vlog构图技巧',
description: 'vlog拍摄构图方法', description: 'vlog拍摄构图方法',
moduleId: 'module-2', moduleId: 'module-2',
@@ -101,10 +134,12 @@ export const resourceData: ResourceItem[] = [
tagId: 'tag-2-2', tagId: 'tag-2-2',
tagName: '拍摄执行', tagName: '拍摄执行',
subTagId: 'subtag-2-2-1', subTagId: 'subtag-2-2-1',
subTagName: '构图' subTagName: '构图',
type: 'video',
videoUrl: '/video/vlog构图技巧.mp4'
}, },
{ {
id: 'res-9', id: 'res-10',
title: 'vlog光线控制', title: 'vlog光线控制',
description: 'vlog拍摄光线运用', description: 'vlog拍摄光线运用',
moduleId: 'module-2', moduleId: 'module-2',
@@ -112,10 +147,12 @@ export const resourceData: ResourceItem[] = [
tagId: 'tag-2-2', tagId: 'tag-2-2',
tagName: '拍摄执行', tagName: '拍摄执行',
subTagId: 'subtag-2-2-2', subTagId: 'subtag-2-2-2',
subTagName: '光线运用' subTagName: '光线运用',
type: 'video',
videoUrl: '/video/vlog光线控制.mp4'
}, },
{ {
id: 'res-10', id: 'res-11',
title: 'vlog运镜方法', title: 'vlog运镜方法',
description: 'vlog拍摄运镜技巧', description: 'vlog拍摄运镜技巧',
moduleId: 'module-2', moduleId: 'module-2',
@@ -123,52 +160,164 @@ export const resourceData: ResourceItem[] = [
tagId: 'tag-2-2', tagId: 'tag-2-2',
tagName: '拍摄执行', tagName: '拍摄执行',
subTagId: 'subtag-2-2-3', subTagId: 'subtag-2-2-3',
subTagName: '运镜技巧' subTagName: '运镜技巧',
type: 'video',
videoUrl: '/video/vlog运镜方法.mp4'
}, },
{ {
id: 'res-11', id: 'res-12',
title: 'vlog后期剪辑', title: 'vlog剪辑技巧',
description: 'vlog视频后期制作', description: 'vlog视频剪辑方法',
moduleId: 'module-2', moduleId: 'module-2',
moduleName: 'vlog发现与记录', moduleName: 'vlog发现与记录',
tagId: 'tag-2-3', 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: '访谈选题方法', title: '访谈选题方法',
description: '如何选择访谈主题', description: '如何选择访谈主题',
moduleId: 'module-3', moduleId: 'module-3',
moduleName: '访谈聆听与对话', moduleName: '访谈聆听与对话',
tagId: 'tag-3-1', tagId: 'tag-3-1',
tagName: '内容策划', tagName: '内容策划',
subTagId: 'subtag-3-1-1', subTagId: 'subtag-3-1-2',
subTagName: '选题与构思' subTagName: '选题与构思',
type: 'video',
videoUrl: '/video/访谈选题方法.mp4'
}, },
{ {
id: 'res-13', id: 'res-19',
title: '访谈脚本制作', title: '访谈脚本制作',
description: '访谈脚本撰写技巧', description: '访谈脚本撰写技巧',
moduleId: 'module-3', moduleId: 'module-3',
moduleName: '访谈聆听与对话', moduleName: '访谈聆听与对话',
tagId: 'tag-3-1', tagId: 'tag-3-1',
tagName: '内容策划', tagName: '内容策划',
subTagId: 'subtag-3-1-2', subTagId: 'subtag-3-1-3',
subTagName: '脚本与分镜制作' subTagName: '脚本与分镜制作',
type: 'video',
videoUrl: '/video/访谈脚本制作.mp4'
}, },
{ {
id: 'res-14', id: 'res-20',
title: '访谈拍摄技巧', title: '访谈拍摄技巧',
description: '访谈视频拍摄方法', description: '访谈视频拍摄方法',
moduleId: 'module-3', moduleId: 'module-3',
moduleName: '访谈聆听与对话', moduleName: '访谈聆听与对话',
tagId: 'tag-3-2', 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: '电影设备准备', title: '电影设备准备',
description: '电影拍摄设备清单', description: '电影拍摄设备清单',
moduleId: 'module-4', moduleId: 'module-4',
@@ -176,10 +325,12 @@ export const resourceData: ResourceItem[] = [
tagId: 'tag-4-1', tagId: 'tag-4-1',
tagName: '内容策划', tagName: '内容策划',
subTagId: 'subtag-4-1-1', subTagId: 'subtag-4-1-1',
subTagName: '设备准备' subTagName: '设备准备',
type: 'page',
routeName: 'FilmResource'
}, },
{ {
id: 'res-16', id: 'res-25',
title: '电影选题构思', title: '电影选题构思',
description: '电影创作选题方法', description: '电影创作选题方法',
moduleId: 'module-4', moduleId: 'module-4',
@@ -187,10 +338,12 @@ export const resourceData: ResourceItem[] = [
tagId: 'tag-4-1', tagId: 'tag-4-1',
tagName: '内容策划', tagName: '内容策划',
subTagId: 'subtag-4-1-2', subTagId: 'subtag-4-1-2',
subTagName: '选题与构思' subTagName: '选题与构思',
type: 'video',
videoUrl: '/video/电影选题构思.mp4'
}, },
{ {
id: 'res-17', id: 'res-26',
title: '电影脚本分镜', title: '电影脚本分镜',
description: '电影脚本和分镜制作', description: '电影脚本和分镜制作',
moduleId: 'module-4', moduleId: 'module-4',
@@ -198,43 +351,53 @@ export const resourceData: ResourceItem[] = [
tagId: 'tag-4-1', tagId: 'tag-4-1',
tagName: '内容策划', tagName: '内容策划',
subTagId: 'subtag-4-1-3', subTagId: 'subtag-4-1-3',
subTagName: '脚本与分镜制作' subTagName: '脚本与分镜制作',
type: 'video',
videoUrl: '/video/电影脚本分镜.mp4'
}, },
{ {
id: 'res-18', id: 'res-27',
title: '电影拍摄执行', title: '电影拍摄执行',
description: '电影拍摄执行技巧', description: '电影拍摄执行技巧',
moduleId: 'module-4', moduleId: 'module-4',
moduleName: '电影创作与表达', moduleName: '电影创作与表达',
tagId: 'tag-4-2', tagId: 'tag-4-2',
tagName: '拍摄执行' tagName: '拍摄执行',
type: 'video',
videoUrl: '/video/电影拍摄执行.mp4'
}, },
{ {
id: 'res-19', id: 'res-28',
title: '电影后期制作', title: '电影后期制作',
description: '电影后期剪辑与特效', description: '电影后期剪辑与特效',
moduleId: 'module-4', moduleId: 'module-4',
moduleName: '电影创作与表达', moduleName: '电影创作与表达',
tagId: 'tag-4-3', tagId: 'tag-4-3',
tagName: '后期制作' tagName: '后期制作',
type: 'video',
videoUrl: '/video/电影后期制作.mp4'
}, },
{ {
id: 'res-20', id: 'res-29',
title: 'AI与版权伦理', title: '电影AI工具应用',
description: 'AI工具使用与版权规范', description: 'AI工具在电影创作中的应用',
moduleId: 'module-4', moduleId: 'module-4',
moduleName: '电影创作与表达', moduleName: '电影创作与表达',
tagId: 'tag-4-4', tagId: 'tag-4-4',
tagName: 'AI与版权伦理知识' tagName: 'AI与版权伦理知识',
type: 'video',
videoUrl: '/video/电影AI工具应用.mp4'
}, },
{ {
id: 'res-21', id: 'res-30',
title: '创作思路与案例', title: '电影创作思路与案例',
description: '电影创作思路和案例分析', description: '电影创作思路和案例分析',
moduleId: 'module-4', moduleId: 'module-4',
moduleName: '电影创作与表达', moduleName: '电影创作与表达',
tagId: 'tag-4-5', 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
}
}