feat: 对接各个视频数据

This commit is contained in:
zhongyidian committed 2025-12-30 19:42:47 +08:00
1 parent 6103453e32
commit 6ab98c4764
339 files changed
+17316 -1531

No files matched your search

-172
View File
@@ -1,172 +0,0 @@
<script lang="ts" setup>
import router from '@/router'
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
import { ImgButton } from '@/components/Button'
import { Overlay } from '@/components/Overlay'
import { computed, ref } from 'vue'
const selected = ref<number | undefined>()
const showColorBar = ref(false)
const btnState = ref(false)
const screenshot = ref()
const showModal = ref(false)
const colorBarList = ref([
{
id: 1,
name: '智能调色',
image: getStaticImage('afterditing/color/智能调色.png'),
Screenshot: getStaticImage('afterditing/color/卡片-智能调色.png')
},
{
id: 2,
name: '滤镜-日落增色',
image: getStaticImage('afterditing/color/日落增色.png'),
Screenshot: getStaticImage('afterditing/color/卡片-日落增色.png')
},
{
id: 3,
name: '滤镜-落日火烧云',
image: getStaticImage('afterditing/color/落日火烧云.png'),
Screenshot: getStaticImage('afterditing/color/卡片-落日火烧云.png')
}
])
const selectedImage = computed(() => {
return !btnState.value && colorBarList.value.find(v => v.id === selected.value)?.image
})
const changeOriginalImgae = () => {
btnState.value = !btnState.value
}
const handleSave = () => {
screenshot.value = colorBarList.value.find(v => v.id === selected.value)?.Screenshot
showModal.value = true
}
const handleModalClose = () => {
showModal.value = false
screenshot.value = null
}
const handleTryOther = () => {
showModal.value = false
}
const handleFinish = () => {
router.push({
name: 'afterditing',
query: { step: 4 }
})
}
const handleClose = () => {
router.back()
}
const changeColor = (item: { id: number; name: string }) => {
selected.value = item.id
}
</script>
<template>
<div class="w-full h-full relative">
<!-- 返回 -->
<ImgButton
class="absolute right-0 top-0 z-10"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleClose"
/>
<!-- 背景图片-center -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat"
:style="{
backgroundImage: `url(${selectedImage || getStaticImage('afterditing/color/原图')})`
}"
></div>
<!-- 分段控制器 -->
<div class="absolute top-10 left-1/2 transform -translate-x-1/2 z-20" v-if="!showColorBar">
<div
class="flex justify-between w-[800px] bg-white/60 backdrop-blur-sm border-[3px] border-white rounded-[50px] px-4 py-4 text-xl font-bold tracking-[4px]"
>
<!-- 后期制作选项 -->
<button :class="['px-12 py-3 rounded-[50px] bg-teal-500 text-white shadow-lg']">
后期制作
</button>
<!-- 智能调色和滤镜选项 -->
<button :class="['px-4 rounded-[50px] text-[#2F5A84] text-2xl']">智能调色和滤镜</button>
</div>
</div>
<!-- title -->
<div
class="absolute top-0 left-0 w-[332px] h-[153px] bg-cover bg-center bg-no-repeat"
:style="{
backgroundImage: showColorBar
? getBackgroundImage('afterditing/color/标题-调色实践')
: getBackgroundImage('afterditing/color/标题-调色')
}"
></div>
<!-- 调色栏 -->
<div class="absolute bottom-0 flex justify-center w-full" v-if="showColorBar">
<div class="flex">
<div
v-for="item in colorBarList"
:key="item.id"
:style="{
backgroundImage:
selected == item.id
? getBackgroundImage('afterditing/color/按钮-调色选中')
: getBackgroundImage('afterditing/color/按钮-调色未选中')
}"
class="w-[449px] h-[170px] text-white text-3xl font-bold flex items-center justify-center cursor-pointer bg-no-repeat bg-cover"
@click="changeColor(item)"
>
<span>{{ item.name }}</span>
</div>
</div>
<ImgButton
class="flex-[0_0_200px]"
@click="changeOriginalImgae"
:image="
btnState ? 'afterditing/color/开启对比 - 可开启' : 'afterditing/color/开启对比 - 不可开启'
"
></ImgButton>
</div>
<!-- 保存 -->
<ImgButton
v-if="selected"
class="absolute right-0 top-1/2 -translate-y-1/2 z-10"
image="afterditing/color/按钮-保存"
@click="handleSave"
/>
<!-- 调色栏显示隐藏按钮 -->
<ImgButton
v-if="!showColorBar"
class="absolute bottom-0 right-0"
image="afterditing/color/调色实践"
@click="showColorBar = true"
></ImgButton>
<!-- 保存图片弹窗 -->
<Overlay :visible="showModal" @close="handleModalClose">
<div class="relative w-[1000px] h-[600px] flex items-center justify-center">
<img :src="screenshot" alt="调色结果" class="max-w-full max-h-full object-contain" />
<!-- 操作按钮 -->
<div class="absolute bottom-1/2 -left-1/3 translate-y-1/2">
<ImgButton image="afterditing/color/按钮-试试其他调色" @click="handleTryOther" />
</div>
<div class="absolute bottom-1/2 -right-1/3 translate-y-1/2">
<ImgButton image="afterditing/color/按钮-结束调色实践" @click="handleFinish" />
</div>
</div>
</Overlay>
</div>
</template>
@@ -0,0 +1,303 @@
<script lang="ts" setup>
import { computed, nextTick, ref } from 'vue'
import { useRouter } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue'
import { StepNavigation } from '@/components/Guide'
import { Video, QuestionQuiz, type Question } from '@/components'
import { getStaticImage, IMAGE_PATHS } from '@/utils'
// 视频导入
// 手机摄像头介绍
import video1 from '/video/new/xinshou_shoujishexiangtoujieshao.mp4'
// 景别概念介绍 - 暂时用其他视频代替
import video2 from '/video/new/xsdh_jbgnjs_batch.mp4'
// 手机慢动作 - 暂时用其他视频代替
import video3 from '/video/new/xsdh_sjmdz_batch.mp4'
// 延时摄影 - 暂时用其他视频代替
import video4 from '/video/new/xsdh_yssy_batch.mp4'
// 神奇的微距摄影
import video5 from '/video/new/xsdh_sqdwjsy_batch.mp4'
// 夜景摄影 - 暂时用其他视频代替
import video6 from '/video/new/xsdh_djmldyjsy_batch.mp4'
const router = useRouter()
// 受控播放开关(用于切换到下一个视频时自动播放)
const playing = ref(false)
const status = ref(false)
const showQuiz = ref(false) // 控制是否显示答题窗口
// 步骤导航数据 - 确保content字段不包含(一)、【Q2】等标记
const rawSteps = [
{
id: 'step2',
label: '拍摄基础',
content: '手机摄像头介绍',
type: 'post' as const
},
{
id: 'step3',
label: '拍摄基础',
content: '景别概念介绍',
type: 'post' as const
},
{
id: 'step4',
label: '拍摄基础',
content: '手机慢动作',
type: 'post' as const
},
{
id: 'step5',
label: '拍摄基础',
content: '延时摄影',
type: 'post' as const
},
{
id: 'step6',
label: '拍摄基础',
content: '神奇的微距摄影',
type: 'post' as const
},
{
id: 'step7',
label: '拍摄基础',
content: '独具魅力的夜景摄影',
type: 'post' as const
}
]
const navigationSteps = ref(rawSteps)
// 步骤导航相关状态
const showStepNavigation = ref(false)
const currentNavigationStep = ref(0) // 对应视频索引,从0开始
// 视频列表
const videoList = [video1, video2, video3, video4, video5, video6]
const currentVideoIndex = ref(0)
// 题目数据:景别概念介绍(index 1)、手机慢动作(index 2)、独具魅力的夜景摄影(index 5)
const questions = ref<Question[]>([])
// 根据当前视频索引获取对应的题目
const getQuestionsForVideo = (index: number): Question[] => {
// 景别概念介绍(index 1)
if (index === 1) {
return [
{
id: 1,
title: '',
description: '(单选)如果你想让观众“看清环境、理解空间关系”,更适合用哪种镜头?',
options: [
{
id: 'a',
label: '面部特写视频'
},
{
id: 'b',
label: '中景人物视频'
},
{
id: 'c',
label: '全景环境视频'
}
],
correctOptionId: 'c',
explanation: '全景更适合交代故事发生在哪里。'
}
]
}
// 手机慢动作(index 2)
if (index === 2) {
return [
{
id: 2,
title: '',
description: '(多选题)下面这些拍摄场景中,哪些更适合使用"慢动作"来表现?',
options: [
{
id: 'a',
label: '城市街道上车水马龙,车辆快速穿行'
},
{
id: 'b',
label: '甩头发、转身、跳跃等人物动作瞬间'
},
{
id: 'c',
label: '水花飞溅、雨滴落地、浪花拍岸'
},
{
id: 'd',
label: '风吹动衣摆、窗帘或树叶缓慢摆动'
}
],
isMultiple: true,
correctOptionIds: ['b', 'c', 'd'],
correctOptionId: '', // 多选题时此字段不使用
explanation: '更适合用慢动作表现的是 B、C、D,它们都包含了"瞬间细节"和"节奏变化"。'
}
]
}
// 独具魅力的夜景摄影(index 5)
if (index === 5) {
return [
{
id: 3,
title: '互动题 4',
description: '(单选)你觉得下面哪句话,更接近“新手拍视频的正确思路”?',
options: [
{
id: 'a',
label: '哪里好看就拍哪里'
},
{
id: 'b',
label: '设备越贵,画面越好'
},
{
id: 'c',
label: '在合适的时间,用合适的方式拍'
}
],
correctOptionId: 'c',
explanation: '拍摄要学会判断时间、光线和方式。'
}
]
}
return []
}
//按顺序播放
const currentVideo = computed(() => videoList[currentVideoIndex.value])
const handleVideoChange = () => {
if (currentVideoIndex.value < videoList.length - 1) {
currentVideoIndex.value++
currentNavigationStep.value = currentVideoIndex.value
} else {
status.value = true
return
}
nextTick(() => {
// 使用受控播放来触发下一个视频自动播放
playing.value = true
})
}
const handleClose = () => {
router.back()
}
const handlePlay = () => {}
const onEnded = () => {
// 在指定视频结束时弹出答题
if (
currentVideoIndex.value === 1 ||
currentVideoIndex.value === 2 ||
currentVideoIndex.value === 5
) {
playing.value = false
questions.value = getQuestionsForVideo(currentVideoIndex.value)
showQuiz.value = true
} else {
handleVideoChange()
}
}
// 步骤导航事件处理
const handleStepClick = (index: number) => {
// 关闭答题窗口(如果打开)
showQuiz.value = false
currentVideoIndex.value = index
currentNavigationStep.value = index
nextTick(() => {
playing.value = true
})
}
const handleStepNavigationClose = () => {
showStepNavigation.value = false
}
// 答题完成后的处理
const handleQuestionsComplete = () => {
showQuiz.value = false
// 答题完成后继续播放下一个视频
handleVideoChange()
}
</script>
<template>
<div class="w-full h-full relative bg-white/10">
<ImgButton
class="absolute right-0 top-0 z-10"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleClose"
/>
<!-- 步骤导航按钮 -->
<button
class="absolute right-4 top-1/2 z-10 -translate-y-1/2 w-12 h-12 bg-blue-500/80 hover:bg-blue-500 rounded-full flex items-center justify-center text-white transition-all duration-200"
@click="showStepNavigation = true"
title="步骤导航"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
d="M3 4H17M3 8H17M3 12H17M3 16H17"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</button>
<Video
v-if="currentVideo"
:src="currentVideo"
v-model="playing"
:muted="false"
:volume="1"
:controls="true"
:hideNativeControls="true"
:anchors="[]"
@play="handlePlay"
@ended="onEnded"
>
<template #overlay>
<ImgButton
class="absolute right-0 top-0 z-10 pointer-events-auto"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleClose"
/>
<QuestionQuiz
v-if="showQuiz"
:show-question-number="false"
:questions="questions"
@complete="handleQuestionsComplete"
/>
</template>
</Video>
<!-- 步骤导航抽屉 -->
<StepNavigation
:visible="showStepNavigation"
:background-image="getStaticImage('work/vlog背景全屏待定')"
:steps="navigationSteps"
:current-step-index="currentNavigationStep"
@step-click="handleStepClick"
@close="handleStepNavigationClose"
/>
</div>
</template>
<style scoped>
video {
width: 100%;
height: 100%;
/* 核心:保持视频比例,多余部分被裁剪 */
object-fit: cover;
}
</style>
@@ -0,0 +1,156 @@
<script lang="ts" setup>
import { computed, nextTick, ref } from 'vue'
import { useRouter } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue'
import { StepNavigation } from '@/components/Guide'
import { Video } from '@/components'
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
// 运镜初识 - 暂时用运镜的基本方式代替
import video1 from '/video/new/xinshou_yunjingdejibenfangshi.mp4'
import video2 from '/video/new/xinshou_zhengquedeshoujipaishe.mp4'
import video3 from '/video/new/xinshou_yunjingdejibenfangshi.mp4'
const router = useRouter()
const props = defineProps<{
label: string
}>()
// 受控播放开关(用于切换到下一个视频时自动播放)
const playing = ref(false)
const status = ref(false)
// 步骤导航数据
const navigationSteps = ref([
// {
// id: 'step1',
// label: '视觉素养',
// content: '运镜初识',
// type: 'planning' as const
// },
{
id: 'step2',
label: '视觉素养',
content: '正确的手机拍摄持机姿势',
type: 'planning' as const
},
{
id: 'step3',
label: '视觉素养',
content: '运镜的基本方式',
type: 'planning' as const
}
])
// 步骤导航相关状态
const showStepNavigation = ref(false)
const currentNavigationStep = ref(0) // 对应视频索引,从0开始
// 视频列表
const videoList = [video2, video3]
const currentVideoIndex = ref(0)
//按顺序播放
const currentVideo = computed(() => videoList[currentVideoIndex.value])
const handleVideoChange = () => {
if (currentVideoIndex.value < videoList.length - 1) {
currentVideoIndex.value++
currentNavigationStep.value = currentVideoIndex.value
} else {
status.value = true
return
}
nextTick(() => {
// 使用受控播放来触发下一个视频自动播放
playing.value = true
})
}
const handleClose = () => {
router.back()
}
const label = computed(() => props.label as string)
const handlePlay = () => {}
const onEnded = () => {
handleVideoChange()
}
// 步骤导航事件处理
const handleStepClick = (index: number) => {
currentVideoIndex.value = index
currentNavigationStep.value = index
nextTick(() => {
playing.value = true
})
}
const handleStepNavigationClose = () => {
showStepNavigation.value = false
}
</script>
<template>
<div class="w-full h-full relative bg-white/10">
<ImgButton
class="absolute right-0 top-0 z-10"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleClose"
/>
<!-- 步骤导航按钮 -->
<button
class="absolute right-4 top-1/2 z-10 -translate-y-1/2 w-12 h-12 bg-blue-500/80 hover:bg-blue-500 rounded-full flex items-center justify-center text-white transition-all duration-200"
@click="showStepNavigation = true"
title="步骤导航"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
d="M3 4H17M3 8H17M3 12H17M3 16H17"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</button>
<Video
:src="currentVideo"
v-model="playing"
:muted="false"
:volume="1"
:controls="true"
:hideNativeControls="true"
:anchors="[]"
@play="handlePlay"
@ended="onEnded"
>
<template v-slot:overlay>
<ImgButton
class="absolute right-0 top-0 z-10 pointer-events-auto"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleClose"
/>
</template>
</Video>
<!-- 步骤导航抽屉 -->
<StepNavigation
:visible="showStepNavigation"
:background-image="getStaticImage('work/vlog背景全屏待定')"
:steps="navigationSteps"
:current-step-index="currentNavigationStep"
@step-click="handleStepClick"
@close="handleStepNavigationClose"
/>
</div>
</template>
<style scoped>
video {
width: 100%;
height: 100%;
/* 核心:保持视频比例,多余部分被裁剪 */
object-fit: cover;
}
</style>
@@ -0,0 +1,150 @@
<script lang="ts" setup>
import { computed, nextTick, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue'
import { StepNavigation } from '@/components/Guide'
import { Video } from '@/components'
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
import video1 from '/video/new/xsdh_rhxzgt_batch.mp4'
import video2 from '/video/new/xsdh_jzgtf_batch.mp4'
const router = useRouter()
const props = defineProps<{
label: string
}>()
// 受控播放开关(用于切换到下一个视频时自动播放)
const playing = ref(false)
const status = ref(false)
// 步骤导航数据
const navigationSteps = ref([
{
id: 'step1',
label: '视觉素养',
content: '如何选择构图',
type: 'planning' as const
},
{
id: 'step2',
label: '视觉素养',
content: '九种经典构图方式',
type: 'planning' as const
}
])
// 步骤导航相关状态
const showStepNavigation = ref(false)
const currentNavigationStep = ref(0) // 对应视频索引,从0开始
// 视频列表
const videoList = [video1, video2]
const currentVideoIndex = ref(0)
//按顺序播放
const currentVideo = computed(() => videoList[currentVideoIndex.value])
const handleVideoChange = () => {
if (currentVideoIndex.value < videoList.length - 1) {
currentVideoIndex.value++
currentNavigationStep.value = currentVideoIndex.value
} else {
status.value = true
return
}
nextTick(() => {
// 使用受控播放来触发下一个视频自动播放
playing.value = true
})
}
const handleClose = () => {
router.back()
}
const label = computed(() => props.label as string)
const handlePlay = () => {}
const onEnded = () => {
handleVideoChange()
}
// 步骤导航事件处理
const handleStepClick = (index: number) => {
currentVideoIndex.value = index
currentNavigationStep.value = index
nextTick(() => {
playing.value = true
})
}
const handleStepNavigationClose = () => {
showStepNavigation.value = false
}
onMounted(() => {})
</script>
<template>
<div class="w-full h-full relative bg-white/10">
<ImgButton
class="absolute right-0 top-0 z-10"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleClose"
/>
<!-- 步骤导航按钮 -->
<button
class="absolute right-4 top-1/2 z-10 -translate-y-1/2 w-12 h-12 bg-blue-500/80 hover:bg-blue-500 rounded-full flex items-center justify-center text-white transition-all duration-200"
@click="showStepNavigation = true"
title="步骤导航"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
d="M3 4H17M3 8H17M3 12H17M3 16H17"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</button>
<Video
:src="currentVideo"
v-model="playing"
:muted="false"
:volume="1"
:controls="true"
:hideNativeControls="true"
:anchors="[]"
@play="handlePlay"
@ended="onEnded"
>
<template v-slot:overlay>
<ImgButton
class="absolute right-0 top-0 z-10 pointer-events-auto"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleClose"
/>
</template>
</Video>
<!-- 步骤导航抽屉 -->
<StepNavigation
:visible="showStepNavigation"
:background-image="getStaticImage('work/vlog背景全屏待定')"
:steps="navigationSteps"
:current-step-index="currentNavigationStep"
@step-click="handleStepClick"
@close="handleStepNavigationClose"
/>
</div>
</template>
<style scoped>
video {
width: 100%;
height: 100%;
/* 核心:保持视频比例,多余部分被裁剪 */
object-fit: cover;
}
</style>
+230
View File
@@ -0,0 +1,230 @@
<script lang="ts" setup>
import { computed, nextTick, ref } from 'vue'
import { useRouter } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue'
import { StepNavigation } from '@/components/Guide'
import { Video, QuestionQuiz, type Question } from '@/components'
import { getStaticImage, IMAGE_PATHS } from '@/utils'
import { GuideSteps } from '@/components/Guide'
// 光初识 - 暂时用光的基础认知代替
import video1 from '/video/new/xinshou_guangdejichurenzhi.mp4'
import video2 from '/video/new/xinshou_guangdesidashuxing.mp4'
import video3 from '/video/new/xsdh_psdhjsj_batch.mp4'
import GuideStepsItem from '@/components/Guide/GuideStepsItem.vue'
const router = useRouter()
// 受控播放开关(用于切换到下一个视频时自动播放)
const playing = ref(false)
const status = ref(false)
const showQuiz = ref(false) // 控制是否显示答题窗口
const isGuide = ref(true)
const currentGuideStep = ref(0)
const handleSkip = (index: number) => {
currentGuideStep.value = index
}
const handleComplete = () => {
isGuide.value = false
}
// 步骤导航数据
const navigationSteps = ref([
{
id: 'step2',
label: '光的基础',
content: '光的基础认知',
type: 'planning' as const
},
{
id: 'step3',
label: '光的属性',
content: '光的四大属性',
type: 'planning' as const
},
{
id: 'step4',
label: '拍摄技巧',
content: '拍摄的黄金时间',
type: 'planning' as const
}
])
// 步骤导航相关状态
const showStepNavigation = ref(false)
const currentNavigationStep = ref(0) // 对应视频索引,从0开始
// 视频列表
const videoList = [video1, video2, video3]
const currentVideoIndex = ref(0)
// 题目数据:拍摄的黄金时间(index 2)
const questions = ref<Question[]>([])
// 根据当前视频索引获取对应的题目
const getQuestionsForVideo = (index: number): Question[] => {
// 拍摄的黄金时间(index 2)
if (index === 2) {
return [
{
id: 1,
title: '',
description: '(单选)同一条街道,在不同时间拍摄,哪一张画面更适合表现"温暖、舒服的日常感"?',
options: [
{
id: 'a',
label: '正午直射光,阴影很硬,路面反光明显'
},
{
id: 'b',
label: '傍晚低角度阳光,墙面被染成金色'
},
{
id: 'c',
label: '夜晚路灯照明,光源复杂、色温混杂'
}
],
correctOptionId: 'b',
explanation: '光线角度越低,画面柔和,情绪放大'
}
]
}
return []
}
//按顺序播放
const currentVideo = computed(() => videoList[currentVideoIndex.value])
const handleVideoChange = () => {
if (currentVideoIndex.value < videoList.length - 1) {
currentVideoIndex.value++
currentNavigationStep.value = currentVideoIndex.value
} else {
status.value = true
return
}
nextTick(() => {
// 使用受控播放来触发下一个视频自动播放
playing.value = true
})
}
const handleClose = () => {
router.back()
}
const handlePlay = () => {
isGuide.value = false
}
const onEnded = () => {
// 检查是否是"拍摄的黄金时间"(index 2)
if (currentVideoIndex.value === 2) {
// 暂停播放并显示答题窗口
playing.value = false
questions.value = getQuestionsForVideo(currentVideoIndex.value)
showQuiz.value = true
} else {
// 其他视频正常切换
handleVideoChange()
}
}
// 步骤导航事件处理
const handleStepClick = (index: number) => {
// 关闭答题窗口(如果打开)
showQuiz.value = false
currentVideoIndex.value = index
currentNavigationStep.value = index
nextTick(() => {
playing.value = true
})
}
const handleStepNavigationClose = () => {
showStepNavigation.value = false
}
// 答题完成后的处理
const handleQuestionsComplete = () => {
showQuiz.value = false
// 答题完成后继续播放下一个视频
handleVideoChange()
}
</script>
<template>
<div class="w-full h-full relative bg-white/10">
<ImgButton
class="absolute right-0 top-0 z-10"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleClose"
/>
<!-- 步骤导航按钮 -->
<button
class="absolute right-4 top-1/2 z-10 -translate-y-1/2 w-12 h-12 bg-blue-500/80 hover:bg-blue-500 rounded-full flex items-center justify-center text-white transition-all duration-200"
@click="showStepNavigation = true"
title="步骤导航"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
d="M3 4H17M3 8H17M3 12H17M3 16H17"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</button>
<Video
:src="currentVideo"
v-model="playing"
:muted="false"
:volume="1"
:controls="true"
:hideNativeControls="true"
:anchors="[]"
@play="handlePlay"
@ended="onEnded"
>
<template v-slot:overlay>
<ImgButton
class="absolute right-0 top-0 z-10 pointer-events-auto"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleClose"
/>
<QuestionQuiz
v-if="showQuiz"
:show-question-number="false"
:questions="questions"
@complete="handleQuestionsComplete"
/>
</template>
</Video>
<!-- 步骤导航抽屉 -->
<StepNavigation
:visible="showStepNavigation"
:background-image="getStaticImage('work/vlog背景全屏待定')"
:steps="navigationSteps"
:current-step-index="currentNavigationStep"
@step-click="handleStepClick"
@close="handleStepNavigationClose"
/>
<!-- 引导与高亮 -->
<GuideSteps v-if="isGuide" position-offset-y="85%" @complete="handleComplete">
<GuideStepsItem>光是摄影的灵魂,摄影高手一定是用光的高手</GuideStepsItem>
</GuideSteps>
</div>
</template>
<style scoped>
video {
width: 100%;
height: 100%;
/* 核心:保持视频比例,多余部分被裁剪 */
object-fit: cover;
}
</style>
@@ -0,0 +1,187 @@
<script lang="ts" setup>
import { computed, nextTick, ref } from 'vue'
import { useRouter } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue'
import { StepNavigation } from '@/components/Guide'
import { Video } from '@/components'
import { getStaticImage, IMAGE_PATHS } from '@/utils'
import video from '/video/new/xsdh_CapCut_Tutorial.mp4'
const router = useRouter()
// 受控播放开关(用于切换到下一个视频时自动播放)
const playing = ref(false)
const status = ref(false)
// 步骤导航数据 - 确保content字段不包含(一)、【Q2】等标记
const rawSteps = [
{
id: 'step2',
label: '剪映基础操作',
content: '认识剪映剪辑界面',
type: 'other' as const
},
{
id: 'step3',
label: '剪映基础操作',
content: '素材导入及调整',
type: 'other' as const
},
{
id: 'step4',
label: '剪映基础操作',
content: '比例及背景设置',
type: 'other' as const
},
{
id: 'step5',
label: '剪映基础操作',
content: '内容裁剪及组合',
type: 'other' as const
},
{
id: 'step6',
label: '剪映基础操作',
content: '音频添加及调整',
type: 'other' as const
},
{
id: 'step7',
label: '剪映基础操作',
content: '添加字幕和贴纸',
type: 'other' as const
},
{
id: 'step8',
label: '剪映基础操作',
content: '添加特效和滤镜',
type: 'other' as const
},
{
id: 'step9',
label: '剪映基础操作',
content: '添加转场和动画',
type: 'other' as const
},
{
id: 'step10',
label: '剪映基础操作',
content: '封面和视频导出',
type: 'other' as const
}
]
const navigationSteps = ref(rawSteps)
// 步骤导航相关状态
const showStepNavigation = ref(false)
const currentNavigationStep = ref(0) // 对应视频索引,从0开始
// 视频列表
const videoList = [video, video, video, video, video, video, video, video, video]
const currentVideoIndex = ref(0)
//按顺序播放
const currentVideo = computed(() => videoList[currentVideoIndex.value])
const handleVideoChange = () => {
if (currentVideoIndex.value < videoList.length - 1) {
currentVideoIndex.value++
currentNavigationStep.value = currentVideoIndex.value
} else {
status.value = true
return
}
nextTick(() => {
// 使用受控播放来触发下一个视频自动播放
playing.value = true
})
}
const handleClose = () => {
router.back()
}
const handlePlay = () => {}
const onEnded = () => {
handleVideoChange()
}
// 步骤导航事件处理
const handleStepClick = (index: number) => {
currentVideoIndex.value = index
currentNavigationStep.value = index
nextTick(() => {
playing.value = true
})
}
const handleStepNavigationClose = () => {
showStepNavigation.value = false
}
</script>
<template>
<div class="w-full h-full relative bg-white/10">
<ImgButton
class="absolute right-0 top-0 z-10"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleClose"
/>
<!-- 步骤导航按钮 -->
<button
class="absolute right-4 top-1/2 z-10 -translate-y-1/2 w-12 h-12 bg-blue-500/80 hover:bg-blue-500 rounded-full flex items-center justify-center text-white transition-all duration-200"
@click="showStepNavigation = true"
title="步骤导航"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
d="M3 4H17M3 8H17M3 12H17M3 16H17"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</button>
<Video
v-if="currentVideo"
:src="currentVideo"
v-model="playing"
:muted="false"
:volume="1"
:controls="true"
:hideNativeControls="true"
:anchors="[]"
@play="handlePlay"
@ended="onEnded"
>
<template #overlay>
<ImgButton
class="absolute right-0 top-0 z-10 pointer-events-auto"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleClose"
/>
</template>
</Video>
<!-- 步骤导航抽屉 -->
<StepNavigation
:visible="showStepNavigation"
:background-image="getStaticImage('work/vlog背景全屏待定')"
:steps="navigationSteps"
:current-step-index="currentNavigationStep"
@step-click="handleStepClick"
@close="handleStepNavigationClose"
/>
</div>
</template>
<style scoped>
video {
width: 100%;
height: 100%;
/* 核心:保持视频比例,多余部分被裁剪 */
object-fit: cover;
}
</style>
@@ -0,0 +1,49 @@
<script lang="ts" setup>
import { IMAGE_PATHS, getStaticImage } from '@/utils'
import { ImgButton } from '@/components/Button'
import { useRouter } from 'vue-router'
import { Video } from '@/components'
import { getPublicPath } from '@/utils'
const router = useRouter()
const back = () => {
router.back()
}
</script>
<template>
<div class="relative w-full h-full overflow-hidden">
<!-- 返回按钮 -->
<ImgButton
class="absolute right-0 top-0 z-20"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="back"
/>
<!-- 背景 -->
<img class="absolute inset-0" :src="getStaticImage('film/img/背景.png')" alt="" />
<!-- 标题 -->
<img class="absolute top-5 z-10" :src="getStaticImage('film/img/调色-标题.png')" alt="" />
<!-- 内容 -->
<div class="relative h-full pt-28 overflow-y-scroll">
<img :src="getStaticImage('film/img/调色.png')" alt="" />
<div class="absolute top-[6250px] left-36 w-[1670px] h-[1000px] rounded-2xl overflow-hidden">
<Video
:src="getPublicPath('video/editing/color_jianji_batch.mp4')"
playsinline
controls
:muted="false"
:volume="1"
/>
</div>
<ImgButton
class="absolute top-[7830px] left-[51.5%] -translate-x-1/2"
image="after-editing/color/调色实践"
@click="router.push({ name: 'FilmColor' })"
/>
</div>
</div>
</template>
+312
View File
@@ -0,0 +1,312 @@
<script lang="ts" setup>
import router from '@/router'
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
import { ImgButton } from '@/components/Button'
import { Overlay } from '@/components/Overlay'
import { ref } from 'vue'
import ImageFilter from '@/components/ImageFilter/ImageFilter.vue'
import { useFilmStore } from '@/stores'
const selected = ref<number | undefined>(1)
const btnState = ref(false)
const screenshot = ref()
const showModal = ref(false)
const activeTab = ref<'color' | 'filter'>('color') // 'color' | 'filter'
const adjustmentList = ref([
{ name: '亮度', value: 0, min: -100, max: 100 },
{ name: '对比度', value: 0, min: -100, max: 100 },
{ name: '饱和度', value: 0, min: -100, max: 100 },
{ name: '曝光', value: 0, min: -100, max: 100 },
{ name: '色调', value: 0, min: 0, max: 100 },
{ name: '锐化', value: 0, min: 0, max: 100 },
{ name: '模糊', value: 0, min: 0, max: 100 }
])
const colorBarList = ref([
{ id: 1, name: '原图', filter: 'none' },
{ id: 2, name: '暖阳', filter: 'saturate(2.5) contrast(2.1)' },
{ id: 3, name: '冷调', filter: 'hue-rotate(180deg) contrast(0.9)' },
{ id: 4, name: '复古', filter: 'sepia(0.6) contrast(1.2) brightness(0.9)' },
{ id: 5, name: '黑白', filter: 'grayscale(1)' },
{ id: 6, name: '鲜艳', filter: 'sepia(0.1) saturate(2.5) contrast(2.1) brightness(1)' }
])
// 获取指定参数的值
// 获取指定参数的值
const imageFilterRef = ref<InstanceType<typeof ImageFilter> | null>(null)
// 获取指定参数的值
const getVal = (name: string) => {
const val = adjustmentList.value.find(item => item.name === name)?.value || 0
return Number(val)
}
// 更新滤镜效果
const updateFilter = () => {
if (!imageFilterRef.value) return
// 如果开启了对比模式(显示原图),则不应用任何滤镜
if (btnState.value) {
imageFilterRef.value.setOptions({
brightness: 0,
contrast: 0,
saturation: 0,
exposure: 0,
hue: 0,
blur: 0,
sharpen: 0,
filter: 'none'
})
return
}
const selectedFilter = colorBarList.value.find(v => v.id === selected.value)?.filter || 'none'
imageFilterRef.value.setOptions({
brightness: getVal('亮度'),
contrast: getVal('对比度'),
saturation: getVal('饱和度'),
exposure: getVal('曝光'),
hue: getVal('色调'),
blur: getVal('模糊'),
sharpen: getVal('锐化'),
filter: selectedFilter
})
}
const changeOriginalImgae = () => {
btnState.value = !btnState.value
updateFilter()
}
// Tab 切换处理
const handleTabChange = (tab: 'color' | 'filter') => {
activeTab.value = tab
// 切换时重置所有参数
handleReset()
}
// 重置所有参数
const handleReset = () => {
adjustmentList.value.forEach(item => (item.value = 0))
selected.value = 1
updateFilter()
}
// 生成图片
const handleGenerate = async () => {
if (!imageFilterRef.value) return
try {
const dataUrl = await imageFilterRef.value.generate()
screenshot.value = dataUrl
showModal.value = true
} catch (error) {
console.error('生成图片失败:', error)
}
}
const handleModalClose = () => {
showModal.value = false
screenshot.value = null
}
const handleTryOther = () => {
showModal.value = false
}
const handleFinish = () => {
useFilmStore().setFilmUnlockProgress(4)
router.push({
name: 'Film'
})
}
const handleClose = () => {
router.back()
}
const changeColor = (item: { id: number; name: string }) => {
selected.value = item.id
updateFilter()
}
</script>
<template>
<div class="w-full h-full relative pt-44">
<!-- 返回 -->
<ImgButton
class="absolute right-0 top-0 z-10"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleClose"
/>
<!-- 背景图片-center -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat -z-10"
:style="{
backgroundImage: getBackgroundImage('after-editing/color/底图')
}"
></div>
<div class="flex h-[759px] justify-center space-x-12">
<!-- 效果图 -->
<div
class="relative w-[1273px] h-[732px] bg-no-repeat bg-cover mt-10 p-2 pb-[26px] pr-[26px]"
:style="{ backgroundImage: getBackgroundImage('after-editing/color/图片框.png') }"
>
<ImageFilter ref="imageFilterRef" :src="getStaticImage('after-editing/color/film_img')" />
<!-- 开启对比 -->
<ImgButton
class="absolute top-2 right-6 flex-[0_0_200px]"
@click="changeOriginalImgae"
:image="
btnState
? 'after-editing/color/开启对比 - 可开启'
: 'after-editing/color/开启对比 - 不可开启'
"
></ImgButton>
</div>
<!-- 滤镜/参数配置 -->
<div>
<div
class="w-[445px] h-[759px] bg-no-repeat bg-cover p-2 rounded-xl"
:style="{ backgroundImage: getBackgroundImage('after-editing/color/小底板') }"
>
<!-- tab切换 -->
<header class="flex h-[75px] items-center justify-center space-x-4">
<div @click="handleTabChange('color')">
<ImgButton
:image="
activeTab !== 'color'
? 'after-editing/color/调色-暗'
: 'after-editing/color/调色-亮'
"
/>
</div>
<div @click="handleTabChange('filter')">
<ImgButton
:image="
activeTab !== 'filter'
? 'after-editing/color/滤镜-暗'
: 'after-editing/color/滤镜-亮'
"
/>
</div>
</header>
<!-- tab-content -->
<div class="px-4 h-[calc(100%-75px)]">
<!-- 调色内容 -->
<div
v-show="activeTab === 'color'"
class="space-y-6 h-full overflow-y-auto custom-scrollbar pt-4"
>
<div v-for="item in adjustmentList" :key="item.name" class="px-2">
<div class="flex justify-between text-[#648ab2] text-2xl font-bold mb-2">
<span>{{ item.name }}</span>
<span>{{ item.value }}</span>
</div>
<input
type="range"
v-model.number="item.value"
:min="item.min"
:max="item.max"
@change="updateFilter"
class="w-full h-3 bg-[#b8cfe7] rounded-lg appearance-none cursor-pointer slider-thumb"
/>
</div>
</div>
<!-- 滤镜内容 -->
<div
v-show="activeTab === 'filter'"
class="space-y-10 h-full overflow-y-auto custom-scrollbar pt-10"
>
<div
v-for="item in colorBarList"
:key="item.id"
:style="{
backgroundImage:
selected == item.id
? getBackgroundImage('after-editing/color/滤镜-选中')
: getBackgroundImage('after-editing/color/滤镜-未选中')
}"
:class="[
'mx-auto w-[373px] h-[62px] text-white text-2xl font-bold flex items-center justify-center cursor-pointer bg-no-repeat bg-contain bg-center transition-all hover:scale-105',
selected !== item.id && 'text-gray-700'
]"
@click="changeColor(item)"
>
<span>{{ item.name }}</span>
</div>
</div>
</div>
</div>
<div class="flex justify-around mt-4">
<!-- 重置 -->
<ImgButton image="after-editing/color/重置" @click="handleReset" />
<!-- 生成 -->
<ImgButton image="after-editing/color/生成" @click="handleGenerate" />
</div>
</div>
</div>
<!-- title -->
<img class="absolute top-0 left-0" :src="getStaticImage('after-editing/color/标题-调色实践')" />
<!-- 回到顶部按钮 - 最后一页显示 -->
<p class="absolute left-40 bottom-10 flex items-center">
<img class="rotate-0 w-28 mr-4" src="@/assets/svg/deng.svg" />
<span class="text-white text-2xl font-bold"
>推荐参数:亮度 90、对比度 100、饱和度 100、曝光 - 90、色调 0、锐化 100、模糊 0</span
>
</p>
<!-- 保存图片弹窗 -->
<Overlay :visible="showModal" @close="handleModalClose">
<div class="relative w-[1000px] h-[600px] flex items-center justify-center">
<!-- 截图内容 -->
<div class="bg-white p-2 rounded-md -rotate-6">
<img :src="screenshot" alt="调色结果" />
</div>
<!-- <img :src="screenshot" alt="调色结果" class="max-w-full max-h-full object-contain rotate-2" /> -->
<!-- 操作按钮 -->
<div class="absolute bottom-1/2 -left-1/3 translate-y-1/2">
<ImgButton image="after-editing/color/按钮-试试其他调色" @click="handleTryOther" />
</div>
<div class="absolute bottom-1/2 -right-1/3 translate-y-1/2">
<ImgButton image="after-editing/color/按钮-结束调色实践" @click="handleFinish" />
</div>
</div>
</Overlay>
</div>
</template>
<style scoped>
/* 自定义滑块样式 */
.slider-thumb::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 34px;
height: 34px;
background: #92c6f2;
border: 3px solid #ffffff;
border-radius: 4px;
cursor: pointer;
box-shadow: 4px 4px 2px rgba(0, 0, 0, 0.4);
}
.slider-thumb::-moz-range-thumb {
width: 34px;
height: 34px;
background: #92c6f2;
border: 3px solid #ffffff;
border-radius: 4px;
cursor: pointer;
box-shadow: 4px 4px 2px rgba(0, 0, 0, 0.4);
}
</style>
@@ -0,0 +1,51 @@
<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 associationVideoUrl = getPublicPath('video/on-site/dygmk_gljj_batch.mp4')
// 视频播放状态
const videoPlaying = ref(false)
</script>
<template>
<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="associationVideoUrl"
v-model="videoPlaying"
:muted="false"
:volume="1"
:controls="true"
:hideNativeControls="true"
:autoplay="true"
:show-center-play-icon="true"
/>
</div>
<!-- 返回按钮 -->
<ImgButton
class="absolute right-0 top-0 z-20"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleBack"
/>
</div>
</template>
@@ -0,0 +1,61 @@
<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 continuityVideoUrl = getPublicPath('video/on-site/dygmk_lxjj_batch.mp4')
// 视频播放状态
const videoEnded = ref(false)
const videoPlaying = ref(false)
const showSummary = ref(false)
// 视频播放完成
const handleVideoEnded = () => {
videoEnded.value = true
showSummary.value = true
}
</script>
<template>
<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="continuityVideoUrl"
v-model="videoPlaying"
:muted="false"
:volume="1"
:controls="true"
:hideNativeControls="true"
:autoplay="true"
:show-center-play-icon="true"
@ended="handleVideoEnded"
>
</Video>
</div>
<!-- 返回按钮 -->
<ImgButton
class="absolute right-0 top-0 z-20"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleBack"
/>
</div>
</template>
@@ -0,0 +1,51 @@
<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 cutVideoUrl = getPublicPath('video/on-site/dygmk_kq_batch.mp4')
// 视频播放状态
const videoPlaying = ref(false)
</script>
<template>
<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="cutVideoUrl"
v-model="videoPlaying"
:muted="false"
:volume="1"
:controls="true"
:hideNativeControls="true"
:autoplay="true"
:show-center-play-icon="true"
/>
</div>
<!-- 返回按钮 -->
<ImgButton
class="absolute right-0 top-0 z-20"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleBack"
/>
</div>
</template>
@@ -0,0 +1,147 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils'
import { ImgButton } from '@/components/Button'
import { Video } from '@/components'
const router = useRouter()
const handleClose = () => {
router.back()
}
// 剪辑介绍视频 URL(暂时用其他视频代替)
const introVideoUrl = getPublicPath('video/on-site/xianchangpaishe_yanshipaishe.mp4')
// 视频播放状态
const videoEnded = ref(true)
const videoPlaying = ref(false)
// 视频播放完成
const handleVideoEnded = () => {
videoEnded.value = true
}
const goStart = () => {
router.push({ name: 'FilmEditingStart' })
}
const goRhythm = () => {
router.push({ name: 'FilmEditingRhythm' })
}
const goSoundTransition = () => {
router.push({ name: 'FilmEditingSoundTransition' })
}
const goCut = () => {
router.push({ name: 'FilmEditingCut' })
}
const goAssociation = () => {
router.push({ name: 'FilmEditingAssociation' })
}
const goContinuity = () => {
router.push({ name: 'FilmEditingContinuity' })
}
</script>
<template>
<div class="relative w-full h-full overflow-hidden">
<!-- 背景 -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.SHOOTING.BG) }"
></div>
<!-- 视频播放区域 -->
<div v-if="!videoEnded" class="absolute inset-0 flex items-center justify-center">
<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="introVideoUrl"
v-model="videoPlaying"
:muted="false"
:volume="1"
:controls="true"
:hideNativeControls="true"
:autoplay="true"
:show-center-play-icon="true"
@ended="handleVideoEnded"
/>
</div>
</div>
<!-- 内容卡片 + 按钮 -->
<div
v-if="videoEnded"
class="absolute inset-0 flex items-center justify-center px-16 pointer-events-none"
>
<div
class="max-w-screen-2xl w-full bg-black/35 rounded-3xl border border-white/15 p-16 space-y-10 text-white pointer-events-auto"
>
<div class="flex items-baseline justify-between gap-6">
<h1 class="text-5xl font-extrabold tracking-wide">剪辑</h1>
<!-- <span class="text-3xl opacity-80">从不同维度练习你的剪辑技巧</span> -->
</div>
<p class="text-2xl leading-8 opacity-90">
选择下面任意一个练习模块,进入对应的剪辑场景,按提示完成小任务,逐步建立自己的剪辑手感和创作节奏。
</p>
<div class="grid grid-cols-3 gap-10">
<button type="button" class="onsite-btn" @click="goStart">
<span class="onsite-btn-title">剪辑从哪里开始</span>
<span class="onsite-btn-desc">通过不同的剪辑起点,营造不同的叙事效果。</span>
</button>
<button type="button" class="onsite-btn" @click="goRhythm">
<span class="onsite-btn-title">节奏型剪辑</span>
<span class="onsite-btn-desc">通过剪辑节奏控制画面节奏,营造紧张感或舒缓感。</span>
</button>
<button type="button" class="onsite-btn" @click="goSoundTransition">
<span class="onsite-btn-title">声音转场</span>
<span class="onsite-btn-desc">利用相似声音连接不同场景,实现自然的转场效果。</span>
</button>
<button type="button" class="onsite-btn" @click="goCut">
<span class="onsite-btn-title">快切</span>
<span class="onsite-btn-desc">通过精准的切块技巧,提升画面的节奏感和连贯性。</span>
</button>
<button type="button" class="onsite-btn" @click="goAssociation">
<span class="onsite-btn-title">关联剪辑</span>
<span class="onsite-btn-desc">通过画面元素的关联性,建立场景之间的逻辑联系。</span>
</button>
<button type="button" class="onsite-btn" @click="goContinuity">
<span class="onsite-btn-title">连续剪辑</span>
<span class="onsite-btn-desc">保持动作和空间的连续性,让画面过渡更加自然流畅。</span>
</button>
</div>
</div>
</div>
<!-- 关闭 -->
<ImgButton
class="absolute right-0 top-0 z-20"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleClose"
/>
</div>
</template>
<style scoped>
.onsite-btn {
@apply relative flex flex-col items-start backdrop-blur-md rounded-2xl px-10 py-8 text-left bg-white/5 hover:bg-white/10 border border-white/10 hover:border-white/30 transition-all duration-200 cursor-pointer;
}
.onsite-btn-title {
@apply text-4xl font-bold mb-4;
}
.onsite-btn-desc {
@apply text-2xl leading-9 opacity-80;
}
</style>
@@ -0,0 +1,51 @@
<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 rhythmVideoUrl = getPublicPath('video/on-site/dygmk_jzxjj_batch.mp4')
// 视频播放状态
const videoPlaying = ref(false)
</script>
<template>
<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="rhythmVideoUrl"
v-model="videoPlaying"
:muted="false"
:volume="1"
:controls="true"
:hideNativeControls="true"
:autoplay="true"
:show-center-play-icon="true"
/>
</div>
<!-- 返回按钮 -->
<ImgButton
class="absolute right-0 top-0 z-20"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleBack"
/>
</div>
</template>
@@ -0,0 +1,141 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
import { Video, QuestionQuiz, type Question } from '@/components'
import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils'
const router = useRouter()
const handleBack = () => {
router.back()
}
// 声音转场视频 URL(暂时用其他视频代替)
const soundTransitionVideoUrl = getPublicPath(
'video/on-site/xianchangpaishe_shengyingzhuanchang1.mp4'
)
// const soundTransitionVideoUrl2 = getPublicPath('video/on-site/xianchangpaishe_yanshipaishe.mp4')
const soundTransitionVideoUrl3 = getPublicPath(
'video/on-site/xianchangpaishe_shengyingzhuanchang3.mp4'
)
// 视频播放状态
const videoEnded = ref(false)
const videoPlaying = ref(false)
const showQuiz = ref(false)
const showVerification = ref(false)
// 视频播放完成
const handleVideoEnded = () => {
videoEnded.value = true
showQuiz.value = true
}
// 交互一 & 交互二:选择题
const questions = ref<Question[]>([
{
id: 1,
title: '画面感受判断',
description: '播放视频,听听看,你觉得这个声音,像是用来结束一段画面,还是开启一段新的内容?',
options: [
{ id: 'a', label: '开始' },
{ id: 'b', label: '结束' }
],
correctOptionId: '', // 引导题,无对错
mediaSrc: soundTransitionVideoUrl
},
{
id: 2,
title: '方法选择',
description: '如果下一个画面是火车相关内容,哪种方式更自然?',
options: [
{ id: 'a', label: '直接切画面' },
{ id: 'b', label: '用相似声音连接' },
{ id: 'c', label: '用字幕说明' }
],
correctOptionId: 'b',
mediaSrc: 'film/on-site/声音转场jh_2.png'
}
])
// 答题完成
const handleQuestionsComplete = () => {
showQuiz.value = false
showVerification.value = true
}
</script>
<template>
<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
v-if="!showVerification"
class="w-full h-full bg-black/40 backdrop-blur-md rounded-3xl border border-white/20"
>
<Video
class="w-full h-full"
:src="soundTransitionVideoUrl"
v-model="videoPlaying"
:muted="false"
:volume="1"
:controls="true"
:hideNativeControls="true"
:autoplay="true"
:show-center-play-icon="true"
@ended="handleVideoEnded"
>
<template #overlay>
</template>
</Video>
</div> -->
<QuestionQuiz
v-if="!showVerification"
:questions="questions"
@complete="handleQuestionsComplete"
/>
<!-- 交互三:结果验证 -->
<div
v-if="showVerification"
class="absolute inset-0 flex items-center justify-center px-20 pointer-events-none"
>
<div
class="max-w-[1100px] w-full bg-black/40 backdrop-blur-md rounded-3xl border border-white/20 p-16 space-y-10 text-white pointer-events-auto"
>
<h2 class="text-4xl font-bold mb-4 leading-relaxed">结果验证</h2>
<div class="rounded-2xl overflow-hidden border border-white/20 bg-black/60 min-h-[500px]">
<Video
class="w-full h-full"
:src="soundTransitionVideoUrl3"
playsinline
controls
:muted="false"
:volume="1"
/>
</div>
<div class="mt-6 p-6 rounded-xl bg-amber-500/20 border border-amber-400/30">
<div class="text-xl font-semibold text-amber-300 mb-2">提示语:</div>
<div class="text-lg text-amber-100 leading-relaxed">
<p>相似的声音,</p>
<p>帮助观众在不知不觉中</p>
<p>完成了场景与叙事的转换。</p>
</div>
</div>
</div>
</div>
<!-- 返回按钮 -->
<ImgButton
class="absolute right-0 top-0 z-20"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleBack"
/>
</div>
</template>
@@ -0,0 +1,59 @@
<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 continuityVideoUrl = getPublicPath('video/on-site/dygmk_jjcnlks_batch.mp4')
// 视频播放状态
const videoEnded = ref(false)
const videoPlaying = ref(false)
// 视频播放完成
const handleVideoEnded = () => {
videoEnded.value = true
}
</script>
<template>
<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="continuityVideoUrl"
v-model="videoPlaying"
:muted="false"
:volume="1"
:controls="true"
:hideNativeControls="true"
:autoplay="true"
:show-center-play-icon="true"
@ended="handleVideoEnded"
>
</Video>
</div>
<!-- 返回按钮 -->
<ImgButton
class="absolute right-0 top-0 z-20"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleBack"
/>
</div>
</template>
@@ -0,0 +1,5 @@
<script lang="ts" setup></script>
<template>
<div></div>
</template>
@@ -0,0 +1,63 @@
<script lang="ts" setup>
import { IMAGE_PATHS, getPublicPath, getStaticImage } from '@/utils'
import { ImgButton } from '@/components/Button'
import { Video } from '@/components'
import { useRouter } from 'vue-router'
const router = useRouter()
const back = () => {
// 返回上一页
router.back()
}
</script>
<template>
<div class="relative w-full h-full overflow-hidden">
<!-- 返回按钮 -->
<ImgButton
class="absolute right-0 top-0 z-20"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="back"
/>
<!-- 背景 -->
<img class="absolute inset-0" :src="getStaticImage('film/img/背景.png')" alt="" />
<!-- 标题 -->
<img class="absolute top-5 z-10" :src="getStaticImage('film/img/音效-标题.png')" alt="" />
<!-- 内容 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] -->
<div class="relative h-full pt-28 overflow-y-scroll">
<img :src="getStaticImage('film/img/音效.png')" alt="" />
<div class="absolute top-[3850px] left-64 w-[1450px] h-[1000px] rounded-2xl overflow-hidden">
<Video
:src="getPublicPath('video/editing/yingxiao_batch.mp4')"
playsinline
controls
:volume="1"
:muted="false"
/>
</div>
<div class="absolute top-[480px] left-[12%] w-[840px]">
<Video
:src="getPublicPath('video/editing/yingxiao_wu.mp4')"
playsinline
controls
:volume="1"
:muted="false"
/>
</div>
<div class="absolute top-[1900px] left-[18.5%] w-[1250px]">
<Video
:src="getPublicPath('video/editing/yingxiao_you.mp4')"
playsinline
controls
:volume="1"
:muted="false"
/>
</div>
</div>
</div>
</template>
+178
View File
@@ -0,0 +1,178 @@
<script lang="tsx" setup>
import { getStaticImage } from '@/utils'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { getBackgroundImage } from '@/utils'
defineOptions({ name: 'FilmMenu' })
/**
* 菜单项 (前期准备、中期拍摄、后期剪辑)
* 前期准备-资源筹备、创意构想、脚本撰写、分镜制作
* 中期拍摄-设备准备、实地取景、现场拍摄
* 后期剪辑-素材管理、剪辑、调色、导出发布
*/
const category = ref([
{
label: '前期准备',
value: 1,
icon: getStaticImage('midterm/准备图标.png')
},
{
label: '中期拍摄',
value: 2,
icon: getStaticImage('midterm/拍摄图标.png')
},
{
label: '后期剪辑',
value: 3,
icon: getStaticImage('midterm/剪辑图标.png')
}
])
const menuItems = ref([
{
label: '资源筹备',
routeName: 'FilmResource',
parentValue: 1,
disabled: false
},
{
label: '创意构想',
routeName: 'FilmCreative',
parentValue: 1,
disabled: false
},
{
label: '脚本撰写',
routeName: 'FilmScriptWriting',
parentValue: 1,
disabled: false
},
{
label: '分镜制作',
routeName: 'FilmStoryboard',
parentValue: 1,
disabled: false
},
{
label: '设备准备',
routeName: 'FilmMidtermEquipment',
parentValue: 2,
disabled: false
},
{
label: '实地取景',
routeName: 'FilmMidtermShooting',
parentValue: 2,
disabled: false
},
{
label: '现场拍摄',
routeName: 'FilmOnsiteShooting',
parentValue: 2,
disabled: false
},
{
label: '素材管理',
routeName: 'Film素材管理',
parentValue: 3,
disabled: true
},
{
label: '剪辑',
routeName: 'FilmEditing',
parentValue: 3,
disabled: false
},
{
label: '调色',
routeName: 'FilmColorPractice',
parentValue: 3,
disabled: false
},
//音效
{
label: '音效',
routeName: 'FilmSoundEffect',
parentValue: 3,
disabled: false
},
{
label: '导出发布',
routeName: 'FilmExportPublish',
parentValue: 3,
disabled: false
}
])
const props = defineProps<{
currentStep: number
}>()
const router = useRouter()
const currentParentValue = computed<number>(() => {
return props.currentStep
}) // 当前选中的父级菜单项
const currentItemValue = ref<string>('') // 当前选中的子级菜单项
const getMenuItems = computed(() => {
return menuItems.value.filter(item => item.parentValue === currentParentValue.value)
})
const handleItemClick = (item: { routeName: string; disabled: boolean }) => {
if (item.disabled) return
currentItemValue.value = item.routeName
router.push({ name: item.routeName })
}
</script>
<template>
<div class="absolute left-20 top-0 bottom-0 w-[520px] pt-28" v-if="currentStep">
<!-- 分类名称 -->
<div class="absolute top-3 left-24 flex items-center space-x-2">
<span class="text-4xl text-[#fbbf24] font-black">{{
category[currentParentValue - 1].label
}}</span>
<img class="filter-blue" :src="category[currentParentValue - 1].icon" alt="" />
</div>
<!-- 背景图片 -->
<div
class="absolute inset-0 bg-cover bg-left bg-no-repeat -z-10"
:style="{ backgroundImage: getBackgroundImage('components/menu-bg.png') }"
></div>
<div class="flex items-center flex-col pl-[60px]">
<template v-for="(item, index) in getMenuItems" :key="item.routeName">
<div
class="relative w-[373px] h-[143px] pl-14 flex items-center justify-center group text-4xl font-black transition-colors"
:class="[
currentItemValue === item.routeName ? 'text-blue-400' : 'text-white',
item.disabled ? 'opacity-20 cursor-not-allowed' : 'cursor-pointer'
]"
:style="{ marginLeft: -index * 40 + 'px' }"
@click="handleItemClick(item)"
>
<div
v-if="!item.disabled"
class="absolute inset-0 bg-cover bg-center bg-no-repeat opacity-0 group-hover:opacity-100 transition-opacity duration-300"
:style="{ backgroundImage: getBackgroundImage('components/menu-hover.png') }"
></div>
<span :class="[!item.disabled ? 'menu-glow-text' : '']">{{ item.label }}</span>
</div>
</template>
</div>
</div>
</template>
<style scoped>
.filter-blue {
filter: brightness(0) saturate(100%) invert(79%) sepia(29%) saturate(1190%) hue-rotate(330deg)
brightness(102%) contrast(98%);
}
.menu-glow-text {
text-shadow:
0 0 40px rgba(0, 180, 255, 0.9),
0 0 20px rgba(0, 180, 255, 0.7);
}
</style>
+49
View File
@@ -0,0 +1,49 @@
<script lang="ts" setup>
import { IMAGE_PATHS, getStaticImage } from '@/utils'
import { ImgButton } from '@/components/Button'
import { useRouter } from 'vue-router'
import { useScale } from '@/composables/useScale'
import { ImagePreview } from '@/components'
const router = useRouter()
const back = () => {
router.back()
}
const { scale, offsetX, offsetY } = useScale()
const handleOpenImages = () => {
ImagePreview({
images: [getStaticImage('film/创意构想-拍摄大纲')],
startIndex: 0,
maskClosable: true,
getScaleMetrics: () => ({ scale: scale.value, offsetX: offsetX.value, offsetY: offsetY.value })
})
}
</script>
<template>
<div class="relative w-full h-full overflow-hidden">
<!-- 返回按钮 -->
<ImgButton
class="absolute right-0 top-0 z-20"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="back"
/>
<!-- 背景 -->
<img class="absolute inset-0" :src="getStaticImage('film/img/背景.png')" alt="" />
<!-- 标题 -->
<img class="absolute top-5 z-10" :src="getStaticImage('film/img/创意构想-标题.png')" alt="" />
<!-- 内容 -->
<div class="relative h-full pt-28 overflow-y-scroll">
<img :src="getStaticImage('film/img/创意构想.png')" alt="" />
<button
class="absolute top-[4060px] left-1/2 -translate-x-1/2 w-[240px] h-[80px]"
@click="handleOpenImages"
/>
</div>
</div>
</template>
+116
View File
@@ -0,0 +1,116 @@
<script lang="tsx" setup>
import { getBackgroundImage, IMAGE_PATHS } from '@/utils'
import { ImgButton } from '@/components/Button'
import { computed, onMounted, ref } from 'vue'
import { ProgressBar, Overlay } from '@/components'
import { useRouter } from 'vue-router'
import { useFilmStore } from '@/stores'
import FilmMenu from './components/menu.vue'
const router = useRouter()
const film = useFilmStore()
const visible = ref(false)
const currentStep = computed(() => film.FilmUnlockProgress)
const handleComplete = () => {
// console.log('完成前置条件')
// 处理完成逻辑
router.push({ name: 'FilmPractice' })
}
const handleStepChange = (step: number) => {
// 处理步骤变化逻辑
film.setFilmUnlockProgress(step)
visible.value = true
}
const handleWorkClick = () => {
console.log('任务取景按钮点击')
// 处理任务取景按钮点击逻辑
router.push({ name: 'FilmWork' })
}
const handleClose = () => {
router.push({ name: 'Action' })
}
onMounted(() => {
if (currentStep.value !== 0 && currentStep.value !== 4) {
visible.value = true
}
})
</script>
<template>
<div class="w-full h-full relative">
<!-- 背景图片-center -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: getBackgroundImage('film/电影模块背景') }"
></div>
<!-- 关闭 -->
<ImgButton
class="absolute right-0 top-0 z-20"
:image="IMAGE_PATHS.COMPONENTS.CLOSE"
@click="handleClose"
/>
<!-- 背景图片 -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.BG) }"
></div>
<!-- 标题 -->
<div class="absolute top-2 left-1/2 -translate-x-1/2">
<ImgButton image="film/电影-创作与表达" />
</div>
<!-- 任务取景按钮 -->
<div
class="absolute left-24 top-24"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.TASK_CAMERA) }"
>
<ImgButton
:image="currentStep === 4 ? IMAGE_PATHS.VLOG.TASK_BTN_OK : IMAGE_PATHS.VLOG.TASK_BTN"
@click="handleWorkClick"
/>
</div>
<!-- 引导任务 -->
<ImgButton class="absolute right-24 bottom-24" :image="IMAGE_PATHS.VLOG.TASK_PERSON" />
<Overlay content-class="w-full h-full" :visible="visible && currentStep < 4">
<!-- 关闭按钮 -->
<ImgButton
class="absolute right-0 top-0 z-20"
:image="IMAGE_PATHS.COMPONENTS.CLOSE"
@click="visible = false"
/>
<!-- 菜单 -->
<FilmMenu :current-step="currentStep" />
<!-- 进度条 -->
<div class="absolute left-0 bottom-0 w-full">
<ProgressBar
module-name="film"
:current-step="currentStep"
@step-change="handleStepChange"
/>
</div>
</Overlay>
<div class="absolute bottom-0 w-full">
<ProgressBar
module-name="film"
:current-step="currentStep"
@complete="handleComplete"
@step-change="handleStepChange"
/>
</div>
</div>
</template>
@@ -0,0 +1,520 @@
<script lang="tsx" setup>
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
const router = useRouter()
const back = () => {
// 跳转设备准备
router.back()
}
const steps = [
{ id: 'step1', content: '先认识 Vlog:点击右侧「1.vlog初识」继续' },
{ id: 'step2', content: '这是主要内容区域的边框' }
]
const stepIndex = ref(0)
const hiddenbar = ref(false)
const currentIndex = ref(0) // 当前显示的图片索引
const selectedMenuIndex = ref(0) // 当前选中的菜单项索引
// 菜单项数据
const menuItems = ref([
{ id: 'phone', name: '相机/摄像机/微单', disabled: false },
{ id: 'phone-stand', name: '滑轨', disabled: true },
{ id: 'bluetooth-remote', name: '三脚架', disabled: true },
{ id: 'stabilizer', name: '稳定器', disabled: true },
{ id: 'props', name: '灯光', disabled: true },
{ id: 'phone-lens', name: '滤镜', disabled: true }
])
const phoneList = ref([
{ phoneImg: getStaticImage('film/equipment/相机产品.png') },
{ phoneImg: getStaticImage('film/equipment/摄像机产品.png') },
{ phoneImg: getStaticImage('film/equipment/微单产品.png') }
])
const platetitleList = ref([
{ title: getStaticImage('film/equipment/相机标题.png') },
{ title: getStaticImage('film/equipment/相机标题.png') },
{ title: getStaticImage('film/equipment/相机标题.png') }
])
const platetextList = ref([
{ text: getStaticImage('film/equipment/相机内容.png') },
{ text: getStaticImage('film/equipment/相机内容.png') },
{ text: getStaticImage('film/equipment/相机内容.png') }
])
const hiddenbarclick = () => {
hiddenbar.value = !hiddenbar.value
}
// 选择菜单项
const selectMenuItem = (index: number, item: { id: string; name: string; disabled: boolean }) => {
if (item.disabled) {
console.log(`菜单项 "${item.name}" 已被禁用`)
return
}
selectedMenuIndex.value = index
}
// 下一张图片
const nextPhone = () => {
if (currentIndex.value < phoneList.value.length - 1) {
currentIndex.value++
}
}
// 上一张图片
const prevPhone = () => {
if (currentIndex.value > 0) {
currentIndex.value--
}
}
interface SimpleStep {
id: string
content: string
}
const handleStepChange = (_step: SimpleStep | null, index: number) => {
stepIndex.value = index
}
const currentHighlight = computed(() =>
stepIndex.value === 0 ? '.img-switch-item--vlog' : '.work-border'
)
</script>
<template>
<div class="equipment-index">
<!-- 背景图片 -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat equipment-bg"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.EQUIPMENT.BG) }"
></div>
<div class="equipment-bar">
<img src="@/assets/images/equipment/侧边导航栏.png" alt="" :hidden="hiddenbar" />
<!-- 内容列表 -->
<div class="equipment-item" :hidden="hiddenbar">
<div class="menu-list">
<div
v-for="(item, index) in menuItems"
:key="item.id"
class="menu-item"
:class="{
'menu-item--active': selectedMenuIndex === index,
'menu-item--disabled': item.disabled
}"
@click="selectMenuItem(index, item)"
>
<div
class="menu-item__indicator"
v-if="selectedMenuIndex === index && !item.disabled"
></div>
<span class="menu-item__text">{{ item.name }}</span>
<div class="menu-item__status" v-if="item.disabled">
<!-- <span class="status-text">未解锁</span> -->
</div>
</div>
</div>
</div>
<!-- 设备准备按钮 -->
<div class="equipment-bt">
<img src="@/assets/images/equipment/按钮选中-设备准备.png" alt="" />
</div>
<div :class="hiddenbar ? 'equipment-unfold1' : 'equipment-unfold2'" @click="hiddenbarclick()">
<img
:src="
hiddenbar
? getStaticImage('equipment/按钮-展开.png')
: getStaticImage('equipment/按钮-收缩.png')
"
alt=""
/>
</div>
</div>
<div class="equipment-layout">
<div class="equipment-home">
<div
class="equipment-leftbutton"
@click="prevPhone"
:style="{ visibility: currentIndex > 0 ? 'visible' : 'hidden' }"
>
<img src="@/assets/images/equipment/按钮-左滑.png" alt="" />
</div>
<div class="equipment-phoneImg">
<img :src="phoneList[currentIndex].phoneImg" alt="" />
</div>
<div
class="equipment-rightbutton"
@click="nextPhone"
:style="{ visibility: currentIndex < phoneList.length - 1 ? 'visible' : 'hidden' }"
>
<img src="@/assets/images/equipment/按钮-右滑.png" alt="" />
</div>
</div>
<div class="equipment-purpose">
<div class="plate">
<img src="@/assets/images/equipment/浮空屏.png" alt="" />
</div>
<div class="title">
<img :src="platetitleList[currentIndex].title" alt="" />
</div>
<div class="plate-content">
<div class="text">
<img :src="platetextList[currentIndex].text" alt="" />
</div>
</div>
</div>
</div>
<ImgButton class="absolute right-0 top-0" :image="IMAGE_PATHS.COMPONENTS.BACK" @click="back" />
<!-- 调试按钮组 -->
<!-- <div class="debug-controls">
<button @click="enableAllMenuItems" class="debug-btn">启用全部</button>
<button @click="disableAllMenuItems" class="debug-btn">禁用全部</button>
<button @click="toggleMenuItem('bluetooth-remote')" class="debug-btn">切换蓝牙遥控器</button>
</div> -->
<!-- 引导步骤 -->
<GuideSteps
:steps="steps"
:highlight-selector="currentHighlight"
:highlight-padding="12"
:highlight-radius="20"
@step-change="handleStepChange"
position-offset-y="85%"
>
<GuideStepsItem
>拍摄工具本身不分好坏,关键在于是否适合当下的拍摄内容和使用习惯。
接下来,我们就一起看看在这个模块中会用到哪些常见、实用的拍摄工具。
</GuideStepsItem>
</GuideSteps>
</div>
</template>
<style lang="less" scoped>
.equipment-index {
position: relative;
width: 100%;
height: 100%;
}
.equipment-layout {
position: relative;
width: 100%;
height: 100%;
}
.equipment-bar {
position: absolute;
top: 0;
left: 0;
z-index: 10;
.equipment-bt {
position: absolute;
top: 0;
left: -135px;
width: 577px;
img {
width: 100%;
height: 100%;
}
}
.equipment-unfold1 {
position: absolute;
top: 490px;
left: -41px;
width: 150px;
img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.equipment-unfold2 {
position: absolute;
top: 490px;
left: 268px;
width: 150px;
img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.equipment-item {
position: absolute;
top: 0;
left: 0;
padding-top: 220px;
padding-left: 20px;
.menu-list {
display: flex;
flex-direction: column;
gap: 8px;
//background: linear-gradient(135deg, #1a2a4a 0%, #0f1a2e 100%);
border-radius: 12px;
padding: 16px 12px;
// box-shadow:
// 0 0 20px rgba(64, 150, 255, 0.3),
// inset 0 0 20px rgba(64, 150, 255, 0.1);
// backdrop-filter: blur(10px);
}
.menu-item {
position: relative;
display: flex;
align-items: center;
padding: 24px 32px;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
min-height: 44px;
&:hover {
// background: rgba(64, 150, 255, 0.1);
transform: translateX(4px);
}
&--active {
// background: rgba(64, 150, 255, 0.2);
// box-shadow: 0 0 15px rgba(64, 150, 255, 0.4);
.menu-item__text {
color: #ffffff;
text-shadow:
0 0 10px rgba(64, 150, 255, 0.8),
0 0 20px rgba(64, 150, 255, 0.6),
0 0 30px rgba(64, 150, 255, 0.4);
font-weight: 600;
}
}
&__indicator {
position: absolute;
left: 0px;
top: 45%;
transform: translateY(-50%);
width: 6px;
height: 38px;
background: #ffffff;
border-radius: 2px;
box-shadow: 0 0 16px rgba(255, 255, 255, 0.8);
}
&__text {
color: #64a5ff;
font-size: 30px;
font-weight: 800;
text-shadow: 0 0 8px rgba(64, 150, 255, 0.6);
transition: all 0.3s ease;
user-select: none;
}
&__status {
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
.status-text {
font-size: 12px;
color: #999;
background: rgba(0, 0, 0, 0.6);
padding: 2px 8px;
border-radius: 4px;
border: 1px solid #333;
}
}
// 禁用状态样式
&--disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: auto; // 保持可点击以显示禁用提示
&:hover {
transform: none;
// background: rgba(255, 0, 0, 0.1);
}
.menu-item__text {
color: #666;
text-shadow: none;
}
.menu-item__indicator {
display: none;
}
}
}
}
.guide-ui {
top: 80% !important;
}
.dragon-character {
top: 80% !important;
}
}
.equipment-home {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
.equipment-leftbutton {
position: absolute;
top: 511px;
left: 350px;
width: 200px;
height: 200px;
cursor: pointer;
z-index: 10;
}
.equipment-phoneImg {
position: absolute;
top: 313px;
left: 500px;
width: 446px;
height: 484px;
img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.equipment-rightbutton {
position: absolute;
top: 511px;
right: 770px;
width: 200px;
height: 200px;
cursor: pointer;
z-index: 10;
}
}
.equipment-purpose {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
.plate {
position: absolute;
top: 30px;
left: 1200px;
width: 600px;
/* 调整为你需要的宽度 */
height: 1000px;
/* 调整为你需要的高度 */
img {
width: 100%;
height: 100%;
}
}
.plate-content {
position: absolute;
top: 144px;
right: 100px;
width: 600px;
height: 797px;
overflow: hidden;
/* 隐藏超出部分 */
object-fit: contain;
// display: flex;
flex-direction: column;
align-items: center;
overflow-y: auto;
/* 允许垂直滚动 */
scrollbar-width: none;
/* Firefox隐藏滚动条 */
-ms-overflow-style: none;
/* IE和Edge隐藏滚动条 */
perspective: 1000px;
&::-webkit-scrollbar {
display: none;
/* Chrome, Safari隐藏滚动条 */
}
}
.title {
// margin-top: 47px;
width: 400px;
z-index: 2;
position: absolute;
top: 36px;
left: 1304px;
}
.text {
position: absolute;
top: -9px;
left: 8px;
z-index: 2;
transform: rotate3d(0, 1, 0, -20deg);
img {
width: 100%;
height: auto;
}
}
}
// 调试控制按钮样式
.debug-controls {
position: absolute;
top: 20px;
left: 20px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 1000;
.debug-btn {
background: rgba(0, 0, 0, 0.8);
color: white;
border: 1px solid #333;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: background 0.3s ease;
&:hover {
background: rgba(0, 0, 0, 0.9);
}
}
}
</style>
@@ -0,0 +1,113 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { getBackgroundImage, IMAGE_PATHS } from '@/utils'
import { ImgButton } from '@/components/Button'
const router = useRouter()
const handleClose = () => {
router.back()
}
const goTimeLapse = () => {
router.push({ name: 'FilmOnsiteTimeLapse' })
}
const goSlider = () => {
router.push({ name: 'FilmOnsiteSlider' })
}
const goSlowShutterDay = () => {
router.push({ name: 'FilmOnsiteSlowShutterDay' })
}
const goSlowShutterNight = () => {
router.push({ name: 'FilmOnsiteSlowShutterNight' })
}
const goLowAngle = () => {
router.push({ name: 'FilmOnsiteLowAngle' })
}
const goRandom = () => {
router.push({ name: 'FilmOnsiteRandom' })
}
</script>
<template>
<div class="relative w-full h-full overflow-hidden">
<!-- 背景 -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.SHOOTING.BG) }"
></div>
<!-- 内容卡片 + 按钮 -->
<div class="absolute inset-0 flex items-center justify-center px-16 pointer-events-none">
<div
class="max-w-screen-2xl w-full bg-black/35 rounded-3xl border border-white/15 p-16 space-y-10 text-white pointer-events-auto"
>
<div class="flex items-baseline justify-between gap-6">
<h1 class="text-5xl font-extrabold tracking-wide">现场拍摄</h1>
<span class="text-3xl opacity-80">从不同维度练习你的镜头语言</span>
</div>
<p class="text-2xl leading-8 opacity-90">
选择下面任意一个练习模块,进入对应的现场拍摄场景,按提示完成小任务,逐步建立自己的拍摄手感和创作节奏。
</p>
<div class="grid grid-cols-3 gap-10">
<button type="button" class="onsite-btn" @click="goTimeLapse">
<span class="onsite-btn-title">延时拍摄</span>
<span class="onsite-btn-desc">记录时间流动,让平凡场景变得有节奏、有变化。</span>
</button>
<button type="button" class="onsite-btn" @click="goSlider">
<span class="onsite-btn-title">滑轨拍摄</span>
<span class="onsite-btn-desc">通过平稳移动镜头,营造空间感和叙事推进感。</span>
</button>
<button type="button" class="onsite-btn" @click="goSlowShutterDay">
<span class="onsite-btn-title">慢门拍摄 · 白天</span>
<span class="onsite-btn-desc">在白天环境下练习慢门,营造水流、车流等运动轨迹。</span>
</button>
<button type="button" class="onsite-btn" @click="goSlowShutterNight">
<span class="onsite-btn-title">慢门拍摄 · 夜晚</span>
<span class="onsite-btn-desc">利用城市灯光,拍出具有节奏感和氛围感的夜景画面。</span>
</button>
<button type="button" class="onsite-btn" @click="goLowAngle">
<span class="onsite-btn-title">低角度拍摄</span>
<span class="onsite-btn-desc">从低视角观察世界,突出主体气势,改变日常视角。</span>
</button>
<button type="button" class="onsite-btn" @click="goRandom">
<span class="onsite-btn-title">创作随机性</span>
<span class="onsite-btn-desc">跟随直觉自由取景,捕捉那些意料之外的画面惊喜。</span>
</button>
</div>
</div>
</div>
<!-- 关闭 -->
<ImgButton
class="absolute right-0 top-0 z-20"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleClose"
/>
</div>
</template>
<style scoped>
.onsite-btn {
@apply relative flex flex-col items-start backdrop-blur-md rounded-2xl px-10 py-8 text-left bg-white/5 hover:bg-white/10 border border-white/10 hover:border-white/30 transition-all duration-200 cursor-pointer;
}
.onsite-btn-title {
@apply text-4xl font-bold mb-4;
}
.onsite-btn-desc {
@apply text-2xl leading-9 opacity-80;
}
</style>
@@ -0,0 +1,109 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
import { QuestionQuiz, type Question } from '@/components'
import { getBackgroundImage, getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
const router = useRouter()
const handleBack = () => {
router.back()
}
// 交互一 & 交互二:选择题
const questions = ref<Question[]>([
{
id: 1,
title: '画面感受判断',
description: '如果你身处滨江岸边,看到一位正在打太极的老人,你会选择哪个角度拍摄?',
options: [
{ id: 'a', label: '平视拍摄' },
{ id: 'b', label: '俯拍' },
{ id: 'c', label: '低角度仰拍' }
],
correctOptionId: '', // 引导题,没有正确答案,任意选择都可进入下一题
mediaSrc: 'film/on-site/低角度拍摄jh_1.png'
},
{
id: 2,
title: '结果理解',
description: '这个画面通过低角度拍摄,主要解决了哪一个画面问题?',
options: [
{ id: 'a', label: '放大了人物的真实身高' },
{ id: 'b', label: '强化了人物动作的力量感与气势' },
{ id: 'c', label: '让背景元素显得更加丰富' },
{ id: 'd', label: '让画面信息量变得更多' }
],
correctOptionId: 'b',
mediaSrc: getPublicPath('video/on-site/xianchangpaishe_dijiaodu.mp4')
}
])
const showSummaryStep = ref(false)
// 答题完成,进入交互三(总结)
const handleQuestionsComplete = () => {
showSummaryStep.value = true
}
</script>
<template>
<div class="relative w-full h-full overflow-hidden">
<!-- 背景 -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.SHOOTING.BG) }"
></div>
<!-- 交互一 & 交互二:选择题 -->
<QuestionQuiz
v-if="!showSummaryStep"
:questions="questions"
@complete="handleQuestionsComplete"
/>
<!-- 交互三:总结 -->
<div
v-if="showSummaryStep"
class="absolute inset-0 flex items-center justify-center px-20 pointer-events-none"
>
<div
class="max-w-screen-2xl w-full bg-black/40 backdrop-blur-md rounded-3xl border border-white/20 p-16 space-y-10 text-white pointer-events-auto"
>
<div class="flex gap-8">
<!-- 总结文本内容 -->
<div class="w-3/6 text-2xl leading-10 opacity-90 space-y-3">
<h2 class="text-4xl font-bold mb-4 leading-relaxed">总结</h2>
<p>
<span>像案例中这样以蹲姿或趴在地上的姿势进行低角度拍摄,</span>
<span>并不会改变人物本身,</span>
<span>却能借助降低机位的操作,</span>
<span>赋予画面里的人物更强的力量感与气势。</span>
</p>
<p>
<span>同时,低角度也减少了,</span>
<span>背景中杂乱元素进入画面的可能性,</span>
<span>让观众的注意力更集中在人物的动作本身。</span>
</p>
</div>
<!-- 总结图片 -->
<div class="flex-1 rounded-2xl overflow-hidden">
<img
class="w-full object-cover"
:src="getStaticImage('film/on-site/低角度拍摄jh_3.png')"
alt="低角度拍摄总结"
/>
</div>
</div>
</div>
</div>
<!-- 返回按钮 -->
<ImgButton
class="absolute right-0 top-0 z-20"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleBack"
/>
</div>
</template>
@@ -0,0 +1,50 @@
<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 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>
@@ -0,0 +1,93 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
import { Video, QuestionQuiz, type Question } from '@/components'
import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils'
const router = useRouter()
const handleBack = () => {
router.back()
}
// 滑轨拍摄视频 URL
const sliderVideoUrl = getPublicPath('video/on-site/dygmk_hgydjt_batch.mp4')
// 视频播放状态
const videoEnded = ref(false)
const videoPlaying = ref(false)
const showQuiz = ref(false) // 控制是否显示答题窗口
// 视频播放完成
const handleVideoEnded = () => {
videoEnded.value = true
showQuiz.value = true // 视频播放完成后显示答题窗口
}
// 交互一 & 交互二:选择题
const questions = ref<Question[]>([
{
id: 1,
title: '滑轨拍摄',
description: '下面哪一种拍摄情境,最适合使用滑轨拍摄?',
options: [
{
id: 'a',
label: '人物正对镜头说话,背景简单,镜头静止人物正对镜头说话,背景简单,镜头静止'
},
{ id: 'b', label: '人物站在窗边,前景有栏杆或墙体,镜头缓慢横向移动' },
{ id: 'c', label: '人物快速奔跑,摄影师跟着移动拍摄' }
],
correctOptionId: 'b',
explanation:
'滑轨镜头的价值,不在"动",而在稳定、克制地交代空间关系。当前景—人物—背景同时存在时,缓慢的横向移动,会让画面产生层次和叙事感,而不是分散注意力。'
}
])
// 答题完成
const handleQuestionsComplete = () => {
showQuiz.value = false // 答题成功后关闭答题窗口
}
</script>
<template>
<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="sliderVideoUrl"
v-model="videoPlaying"
:muted="false"
:volume="1"
:controls="true"
:hideNativeControls="true"
:autoplay="true"
@ended="handleVideoEnded"
>
<template #overlay>
<QuestionQuiz
v-if="showQuiz"
:show-question-number="false"
:questions="questions"
@complete="handleQuestionsComplete"
/>
</template>
</Video>
</div>
<!-- 返回按钮 -->
<ImgButton
class="absolute right-0 top-0 z-20"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleBack"
/>
</div>
</template>
@@ -0,0 +1,110 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
import { QuestionQuiz, type Question } from '@/components'
import { getBackgroundImage, getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
const router = useRouter()
const handleBack = () => {
router.back()
}
// 交互一 & 交互二:选择题
const questions = ref<Question[]>([
{
id: 1,
title: '画面判断',
description: '在这个白天场景中,画面里的运动变化,是否容易被直接观察到?',
options: [
{ id: 'a', label: '是,变化本身已经很清楚' },
{ id: 'b', label: '否,变化存在,但不容易被完整感知' }
],
correctOptionId: 'b',
mediaSrc: 'film/on-site/白天慢门jh_1.png',
explanation: '白天光照条件下,许多运动变化存在于连续时间中,并不容易被单帧画面完整呈现。'
},
{
id: 2,
title: '拍摄方式选择',
description:
'在光照较强的环境中,如果希望拉长画面中的运动轨迹,拍出视频中的效果,以下哪种方式更合适?',
options: [
{ id: 'a', label: '提高 ISO' },
{ id: 'b', label: '加装 ND 镜片,降低快门速度' },
{ id: 'c', label: '使用连拍模式' }
],
correctOptionId: 'b',
mediaSrc: getPublicPath('video/on-site/xianchangpaishe_baitianmanmen.mp4')
}
])
const showSummaryStep = ref(false)
// 答题完成,进入交互三(总结)
const handleQuestionsComplete = () => {
showSummaryStep.value = true
}
</script>
<template>
<div class="relative w-full h-full overflow-hidden">
<!-- 背景 -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.SHOOTING.BG) }"
></div>
<!-- 交互一 & 交互二:选择题 -->
<QuestionQuiz
v-if="!showSummaryStep"
:questions="questions"
@complete="handleQuestionsComplete"
/>
<!-- 交互三:总结 -->
<div
v-if="showSummaryStep"
class="absolute inset-0 flex items-center justify-center px-20 pointer-events-none"
>
<div
class="max-w-screen-2xl w-full bg-black/40 backdrop-blur-md rounded-3xl border border-white/20 p-16 space-y-10 text-white pointer-events-auto"
>
<div class="flex gap-8">
<!-- 总结文本内容 -->
<div class="w-2/6 text-2xl leading-10 opacity-90 space-y-3">
<h2 class="text-4xl font-bold mb-4 leading-relaxed">总结</h2>
<p>
<span>通过ND 镜片控制进光量,</span>
<span>可以在白天环境下安全降低快门速度,</span>
<span>为慢门拍摄创造条件。</span>
</p>
<p>
<span>人为扩大"静止"与"运动"之间的视觉差异,</span>
<span>让观众在同一个画面中同时感知,</span>
<span>人的运动节奏与空间结构关系。</span>
</p>
</div>
<!-- 总结图片 -->
<div
class="flex-1 rounded-2xl overflow-hidden border border-white/20 bg-black/60 min-h-[500px]"
>
<img
class="w-full h-full object-cover"
:src="getStaticImage('film/on-site/白天慢门jh_3.png')"
alt="ND镜片"
/>
</div>
</div>
</div>
</div>
<!-- 返回按钮 -->
<ImgButton
class="absolute right-0 top-0 z-20"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleBack"
/>
</div>
</template>
@@ -0,0 +1,50 @@
<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 slowShutterNightVideoUrl = getPublicPath('video/on-site/dygmk_mmps_batch.mp4')
// 视频播放状态
const videoPlaying = ref(false)
</script>
<template>
<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="slowShutterNightVideoUrl"
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>
@@ -0,0 +1,119 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
import { Video, QuestionQuiz, type Question } from '@/components'
import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils'
const router = useRouter()
// 延时拍摄视频 URL(占位,后续可替换)
const timeLapseVideoUrl = getPublicPath('video/on-site/xianchangpaishe_yanshipaishe.mp4')
const handleBack = () => {
router.back()
}
// 交互一 & 交互二:选择题
const questions = ref<Question[]>([
{
id: 1,
title: '观察判断',
description: '如果你只用一条普通视频来拍这个场景,你觉得画面最难被感知的是什么?',
options: [
{ id: 'a', label: '建筑的外形结构' },
{ id: 'b', label: '天空的颜色' },
{ id: 'c', label: '时间的变化' },
{ id: 'd', label: '画面的清晰度' }
],
correctOptionId: 'c',
mediaSrc: 'film/on-site/延时拍摄jh_1.png' // 占位:请按需替换为真实图片
},
{
id: 2,
title: '方法选择',
description: '如果希望在很短时间内,让观众明显感知“时间正在流动”,你会选择哪种拍摄方式?',
options: [
{ id: 'a', label: '手持拍摄' },
{ id: 'b', label: '慢动作拍摄' },
{ id: 'c', label: '延时拍摄' },
{ id: 'd', label: '推拉镜头' }
],
correctOptionId: 'c',
mediaSrc: 'film/on-site/延时拍摄jh_1.png' // 占位:请按需替换为真实图片
}
])
const showObserveStep = ref(false)
const videoPlaying = ref(false) // 视频播放状态
// 答题完成,进入交互三(视频)
const handleQuestionsComplete = () => {
showObserveStep.value = true
}
</script>
<template>
<div class="relative w-full h-full overflow-hidden">
<!-- 背景 -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.SHOOTING.BG) }"
></div>
<!-- 交互一 & 交互二:选择题 -->
<QuestionQuiz
v-if="!showObserveStep"
:questions="questions"
@complete="handleQuestionsComplete"
/>
<!-- 交互三:观察型 - 播放视频 -->
<div
v-if="showObserveStep"
class="absolute inset-0 flex items-center justify-center px-20 pointer-events-none"
>
<div
class="max-w-screen-2xl w-full bg-black/40 backdrop-blur-md rounded-3xl border border-white/20 p-16 space-y-10 text-white pointer-events-auto"
>
<div class="flex gap-8">
<!-- 总结文本内容 -->
<div class="w-2/6 text-2xl leading-10 opacity-90 space-y-3">
<h2 class="text-4xl font-bold mb-4 leading-relaxed">总结</h2>
<p>在这个延时画面中,注意天空变化与光影移动的节奏,它们在短时间内被连续呈现出来。</p>
<p><span>延时拍摄的主要目的</span>,<span>是交代时间流逝与环境状态</span>。</p>
<p>
<span>这里的拍摄对象是江边的建筑群与天空</span
>。<span>通过固定机位的连续拍摄</span>,<span>将原本需要较长时间才能观察到的</span
><span>天空变化和光影移动</span>,<span>压缩到一个较短的镜头中呈现</span>。
</p>
<p>
<span>这种处理方式</span
>,<span>非常适合放置在视频片头阶段</span>,<span>用于快速建立时间感与空间环境</span>,<span>而不会占用过多时长</span>。
</p>
</div>
<div
class="flex-1 rounded-2xl overflow-hidden border border-white/20 bg-black/60 min-h-[500px]"
>
<Video
class="w-full h-full"
:src="timeLapseVideoUrl"
v-model="videoPlaying"
:muted="false"
:volume="1"
:controls="true"
:hideNativeControls="true"
/>
</div>
</div>
</div>
</div>
<!-- 返回按钮 -->
<ImgButton
class="absolute right-0 top-0 z-20"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleBack"
/>
</div>
</template>
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { ImgButton } from '@/components/Button'
import { getStaticImage } from '@/utils'
defineEmits<{
start: []
}>()
const props = defineProps<{
scene: 'a' | 'b'
}>()
</script>
<template>
<div class="absolute bottom-0">
<div class="relative">
<img
:src="
getStaticImage(
props.scene === 'a'
? 'film/shooting/龙美术馆拍摄.png'
: 'film/shooting/油罐艺术公园拍摄.png'
)
"
alt=""
/>
<ImgButton
class="absolute bottom-[46%] left-[49%] -translate-x-1/2"
image="shooting/按钮-开始取景.png"
@click="$emit('start')"
/>
</div>
</div>
</template>
@@ -0,0 +1,77 @@
import { ref } from 'vue'
interface CaptureOptions {
width: number
height: number
scale?: number
delay?: number // 截图前的等待时间(用于 UI 更新)
resultDelay?: number // 截图后显示结果的延迟
onDraw: (ctx: CanvasRenderingContext2D) => void | Promise<void>
onComplete?: () => void
}
export function useCanvasCapture() {
const isLoading = ref(false)
const showModal = ref(false)
const capturedImageUrl = ref('')
const capture = async (options: CaptureOptions) => {
if (isLoading.value) return
const { width, height, scale = 2, delay = 50, resultDelay = 0, onDraw, onComplete } = options
isLoading.value = true
try {
// 等待 UI 更新
if (delay > 0) {
await new Promise(resolve => setTimeout(resolve, delay))
}
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('Cannot get canvas context')
}
// 设置画布大小(高清)
canvas.width = width * scale
canvas.height = height * scale
// 填充白色背景(防止透明)
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, canvas.width, canvas.height)
// 应用缩放
ctx.scale(scale, scale)
// 执行自定义绘制逻辑
await onDraw(ctx)
capturedImageUrl.value = canvas.toDataURL('image/png')
// 延迟显示结果
if (resultDelay > 0) {
setTimeout(() => {
isLoading.value = false
showModal.value = true
onComplete?.()
}, resultDelay)
} else {
isLoading.value = false
showModal.value = true
onComplete?.()
}
} catch (error) {
console.error('Capture failed:', error)
isLoading.value = false
}
}
return {
isLoading,
showModal,
capturedImageUrl,
capture
}
}
@@ -0,0 +1,330 @@
<script lang="ts" setup>
import { ref, computed } from 'vue'
import { getBackgroundImage } from '@/utils'
import { ImgButton } from '@/components/Button'
import { Overlay } from '@/components/Overlay'
import { useRouter } from 'vue-router'
import { useViewfinderA } from './useViewfinderA'
import { useCanvasCapture } from '../composables/useCanvasCapture'
import StartOverlay from '../components/StartOverlay.vue'
import LoadingOverlay from '@/components/Loading/LoadingOverlay.vue'
const router = useRouter()
const start = ref(false)
// 基础逻辑
const back = () => router.back()
const handleFinish = () => router.push({ name: 'FilmFilmingLocationB' })
// 截图功能
const { isLoading, showModal, capturedImageUrl, capture } = useCanvasCapture()
const handleModalClose = () => {
showModal.value = false
}
// 手机模式取景
const {
currentZoom,
isCapturing,
isDragging,
setZoom,
startDrag,
onDrag,
endDrag,
getTransform,
currentOffsetX,
currentOffsetY,
handleWheel
} = useViewfinderA()
// 刻度盘配置常量
const DIAL_STEP_DEG = 30 // 1x 到 2x 之间的角度差(度)
const ZOOM_MIN = 1
const ZOOM_MAX = 3
const ZOOM_CENTER = 2
const TICK_STEP = 0.1 // 刻度步长
const MAJOR_TICK_INTERVAL = 5 // 主刻度间隔(0.5x)
const INTEGER_TICK_INTERVAL = 10 // 整数刻度间隔(1.0x)
// 刻度数据类型
interface DialTick {
zoom: number
angle: number
isMajor: boolean
isInteger: boolean
label: string
}
// 计算圆盘旋转角度
// 目标:将当前 zoom 对应的刻度旋转到 0 度位置
// 3x 在上方(-30deg),1x 在下方(+30deg)
const dialRotation = computed(() => {
return (currentZoom.value - ZOOM_CENTER) * DIAL_STEP_DEG
})
// 格式化缩放倍数显示
const formattedZoom = computed(() => {
return currentZoom.value.toFixed(1) + 'x'
})
// 生成刻度数据
const dialTicks = computed<DialTick[]>(() => {
const ticks: DialTick[] = []
const minTick = ZOOM_MIN / TICK_STEP
const maxTick = ZOOM_MAX / TICK_STEP
for (let z = minTick; z <= maxTick; z += 1) {
const zoom = z * TICK_STEP
// 刻度相对于中心(2x)的角度
// 3x -> -30deg(上方),1x -> +30deg(下方)
const angle = -(zoom - ZOOM_CENTER) * DIAL_STEP_DEG
const isMajor = z % MAJOR_TICK_INTERVAL === 0
const isInteger = z % INTEGER_TICK_INTERVAL === 0
ticks.push({
zoom,
angle,
isMajor,
isInteger,
label: isInteger ? zoom + 'x' : ''
})
}
return ticks
})
const photoImage = ref<HTMLImageElement | null>(null)
// Canvas 截图配置常量
const CANVAS_WIDTH = 871
const CANVAS_HEIGHT = 420
const CANVAS_SCALE = 2 // 2倍高清
const FLASH_DURATION = 100 // 闪光动画时长(ms)
const UI_UPDATE_DELAY = 50 // UI 更新延迟(ms)
const RESULT_DISPLAY_DELAY = 300 // 结果显示延迟(ms)
/**
* 拍照功能
* 使用 Canvas 手动绘制图片,避免 DOM 截图的兼容性问题
*/
/**
* 拍照功能
* 使用 Canvas 手动绘制图片,避免 DOM 截图的兼容性问题
*/
const capturePhoto = async () => {
if (isLoading.value) return
isCapturing.value = true
await new Promise(resolve => setTimeout(resolve, FLASH_DURATION))
await capture({
width: CANVAS_WIDTH,
height: CANVAS_HEIGHT,
scale: CANVAS_SCALE,
delay: UI_UPDATE_DELAY,
resultDelay: RESULT_DISPLAY_DELAY,
onDraw: ctx => {
if (!photoImage.value) {
throw new Error('Photo image not found')
}
// 计算变换
// 1. 移动到中心
ctx.translate(CANVAS_WIDTH / 2, CANVAS_HEIGHT / 2)
// 2. 应用拖拽偏移
ctx.translate(currentOffsetX.value, currentOffsetY.value)
// 3. 应用缩放
ctx.scale(currentZoom.value, currentZoom.value)
// 4. 移回左上角绘制
ctx.drawImage(
photoImage.value,
-CANVAS_WIDTH / 2,
-CANVAS_HEIGHT / 2,
CANVAS_WIDTH,
CANVAS_HEIGHT
)
},
onComplete: () => {
isCapturing.value = false
}
})
}
</script>
<template>
<div class="w-full h-full">
<div
class="relative w-full h-full bg-cover bg-center bg-no-repeat shooting-bg"
:style="{ backgroundImage: getBackgroundImage('film/shooting/龙美术馆模糊底图.png') }"
>
<ImgButton image="shooting/标题-取景地a.png" class="title-button absolute left-0" />
<ImgButton image="shooting/返回.png" class="back-button absolute right-0" @click="back" />
<p
class="absolute left-1/2 -translate-x-1/2 top-[260px] text-red-500 text-4xl leading-normal font-bold"
>
操作说明:在手机取景框区域,滚动鼠标滚轮可缩放画面,按住鼠标左键拖动可调整画面位置。
</p>
<StartOverlay scene="a" v-if="!start" @start="start = true" />
<!-- 手机拍照界面 -->
<div class="absolute bottom-0 w-full h-[766px]" v-else>
<img
class="phone-frame absolute w-full h-[766px] z-10 pointer-events-none"
src="@/assets/images/shooting/shoot.png"
alt=""
/>
<!-- 拍摄区域 -->
<div
class="photo-wrapper absolute pointer-events-none top-[19.2%] left-[48.3%] -translate-x-1/2 w-[871px] h-[420px]"
>
<div class="photo-container w-full h-full overflow-hidden relative">
<!-- 拍摄图片 -->
<img
ref="photoImage"
class="absolute pointer-events-auto w-[871px] h-[420px] ease-out select-none cursor-move touch-none"
:class="{
'transition-transform duration-300': !isDragging,
'cursor-grab': !isDragging,
'cursor-grabbing': isDragging
}"
:style="{ transform: getTransform() }"
src="@/assets/images/film/shooting/龙美术馆清晰图.png"
alt=""
crossorigin="anonymous"
@mousedown="startDrag"
@mousemove="onDrag"
@mouseup="endDrag"
@mouseleave="endDrag"
@wheel="handleWheel"
@touchstart="startDrag"
@touchmove="onDrag"
@touchend="endDrag"
draggable="false"
/>
<!-- 拍照闪光效果 -->
<div
v-if="isCapturing"
class="flash-overlay absolute inset-0 bg-white animate-flash"
></div>
</div>
</div>
<div
class="shooting-decor relative pointer-events-none top-[19.2%] left-[48.3%] -translate-x-1/2 w-[871px] h-[420px] bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: getBackgroundImage('shooting/拍摄装饰.png') }"
>
<!-- 仿苹果半圆盘缩放控件 -->
<div
class="zoom-dial-container absolute pointer-events-auto w-[150px] h-[300px] top-1/2 -translate-y-1/2 -rotate-2 right-[179px] overflow-hidden select-none mask-gradient"
>
<!-- 指示器 (固定在中心) -->
<div
class="absolute top-1/2 left-0 w-full -translate-y-1/2 z-20 flex items-center pointer-events-none"
>
<div
class="w-0 h-0 border-t-[8px] border-t-transparent border-l-[14px] border-l-yellow-400 border-b-[8px] border-b-transparent ml-3"
></div>
<div
class="ml-3 bg-black/70 backdrop-blur-md text-yellow-400 font-bold px-3 py-1 rounded text-lg shadow-lg"
>
{{ formattedZoom }}
</div>
</div>
<!-- 旋转圆盘 -->
<!-- 圆心在容器右侧外部,形成左侧弧形 -->
<div
class="dial-wheel absolute top-1/2 right-[-480px] w-[600px] h-[600px] rounded-full bg-black/30 backdrop-blur-md border-[5px] border-white/20 transition-transform duration-100 ease-out origin-center cursor-grab active:cursor-grabbing"
:style="{ transform: `translateY(-50%) rotate(${dialRotation}deg)` }"
>
<div
v-for="tick in dialTicks"
:key="tick.zoom"
class="absolute top-1/2 left-0 w-full h-0 flex justify-start items-center origin-center pointer-events-auto"
:style="{ transform: `translate(0, -50%) rotate(${tick.angle}deg)` }"
@click.stop="setZoom(tick.zoom)"
>
<!-- 刻度线本体 (位于圆盘左边缘) -->
<div
class="absolute left-3 bg-white/90 transition-all duration-200"
:class="{
'w-10 h-1 opacity-90': tick.isInteger,
'w-6 h-0.5 opacity-60': !tick.isInteger && tick.isMajor,
'w-3 h-[1px] opacity-40': !tick.isMajor
}"
></div>
<!-- 刻度文字 -->
<div
v-if="tick.isInteger"
class="absolute left-[52px] text-white/90 font-bold text-lg drop-shadow-md"
:style="{ transform: `rotate(${-tick.angle - dialRotation}deg)` }"
>
<span
:class="{ 'text-yellow-400 scale-110': currentZoom === tick.zoom }"
class="block transition-all duration-200"
>
{{ tick.label }}
</span>
</div>
</div>
</div>
</div>
<!-- 拍摄按钮 -->
<ImgButton
image="shooting/按钮-拍摄.png"
class="capture-button absolute pointer-events-auto right-14 top-1/2 -translate-y-1/2 transition-transform active:scale-95"
@click="capturePhoto"
/>
</div>
</div>
<!-- 结果弹窗 -->
<Overlay :visible="showModal" @close="handleModalClose">
<div class="relative w-[1000px] h-[600px] flex items-center justify-center">
<!-- 截图内容 -->
<div v-if="capturedImageUrl" class="bg-white p-2 rounded-md -rotate-6">
<img :src="capturedImageUrl" alt="拍摄结果" />
</div>
<!-- 操作按钮 -->
<div class="absolute bottom-1/2 -right-1/3 translate-y-1/2">
<ImgButton image="shooting/按钮-前往下一个取景地" @click="handleFinish" />
</div>
</div>
</Overlay>
</div>
<LoadingOverlay :visible="isLoading" text="正在出片中..." />
</div>
</template>
<style lang="less">
@keyframes flash {
0% {
opacity: 0;
}
50% {
opacity: 0.8;
}
100% {
opacity: 0;
}
}
.animate-flash {
animation: flash 0.3s ease-out;
}
.mask-gradient {
mask-image: linear-gradient(to bottom, transparent, black 15%, black 85%, transparent);
-webkit-mask-image: linear-gradient(to bottom, transparent, black 15%, black 85%, transparent);
}
</style>
@@ -0,0 +1,149 @@
import { ref } from 'vue'
import { getTouchDistance, getClientPosition } from '../utils/touchGestures'
/**
* 手机拍照模式的取景逻辑
* 支持缩放和拖拽功能
*/
export function useViewfinderA() {
// 缩放限制
const MIN_ZOOM = 1
const MAX_ZOOM = 3
// 缩放级别 (保留用于点击按钮)
const zoomLevels = [3, 2, 1]
const currentZoom = ref(1)
const isCapturing = ref(false) // 拍照闪光效果
// 拖拽相关状态
const isDragging = ref(false)
const dragStartX = ref(0)
const dragStartY = ref(0)
const offsetX = ref(0)
const offsetY = ref(0)
const currentOffsetX = ref(0)
const currentOffsetY = ref(0)
// 双指缩放相关
const isPinching = ref(false)
const pinchStartDistance = ref(0)
const pinchStartZoom = ref(1)
// 计算边界限制
const limitOffset = () => {
const containerWidth = 871
const containerHeight = 420
const scale = currentZoom.value
// 计算图片缩放后的尺寸
const scaledWidth = containerWidth * scale
const scaledHeight = containerHeight * scale
// 计算最大偏移量(图片边缘不能超出容器中心)
const maxOffsetX = (scaledWidth - containerWidth) / 2
const maxOffsetY = (scaledHeight - containerHeight) / 2
// 限制偏移量
currentOffsetX.value = Math.max(-maxOffsetX, Math.min(maxOffsetX, currentOffsetX.value))
currentOffsetY.value = Math.max(-maxOffsetY, Math.min(maxOffsetY, currentOffsetY.value))
}
// 设置缩放级别
const setZoom = (zoom: number) => {
currentZoom.value = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom))
limitOffset()
}
// 鼠标滚轮缩放
const handleWheel = (e: WheelEvent) => {
e.preventDefault()
// 滚轮向下是正数(缩小),向上是负数(放大)
const delta = -e.deltaY * 0.001
const newZoom = currentZoom.value + delta
setZoom(newZoom)
}
// 开始拖拽 / 触摸
const startDrag = (e: MouseEvent | TouchEvent) => {
// 双指缩放
if ('touches' in e && e.touches.length === 2) {
isPinching.value = true
isDragging.value = false
pinchStartDistance.value = getTouchDistance(e.touches[0], e.touches[1])
pinchStartZoom.value = currentZoom.value
e.preventDefault()
return
}
// 单指拖拽
if ('touches' in e && e.touches.length !== 1) return
isDragging.value = true
isPinching.value = false
const { x: clientX, y: clientY } = getClientPosition(e)
dragStartX.value = clientX
dragStartY.value = clientY
offsetX.value = currentOffsetX.value
offsetY.value = currentOffsetY.value
}
// 拖拽中 / 缩放中
const onDrag = (e: MouseEvent | TouchEvent) => {
// 处理双指缩放
if (isPinching.value && 'touches' in e && e.touches.length === 2) {
const currentDistance = getTouchDistance(e.touches[0], e.touches[1])
const scale = currentDistance / pinchStartDistance.value
setZoom(pinchStartZoom.value * scale)
e.preventDefault()
return
}
if (!isDragging.value) return
const { x: clientX, y: clientY } = getClientPosition(e)
const deltaX = clientX - dragStartX.value
const deltaY = clientY - dragStartY.value
currentOffsetX.value = offsetX.value + deltaX
currentOffsetY.value = offsetY.value + deltaY
limitOffset()
// 防止滚动
if (e.cancelable) {
e.preventDefault()
}
}
// 结束拖拽
const endDrag = () => {
isDragging.value = false
isPinching.value = false
}
// 计算transform样式
const getTransform = () => {
return `translate(${currentOffsetX.value}px, ${currentOffsetY.value}px) scale(${currentZoom.value})`
}
return {
// 状态
zoomLevels,
currentZoom,
isCapturing,
isDragging,
currentOffsetX,
currentOffsetY,
// 方法
setZoom,
startDrag,
onDrag,
endDrag,
handleWheel,
getTransform
}
}
@@ -0,0 +1,287 @@
<script lang="ts" setup>
import { ref } from 'vue'
import { getBackgroundImage, getStaticImage } from '@/utils'
import { ImgButton } from '@/components/Button'
import { Overlay } from '@/components/Overlay'
import { useViewfinderB } from './useViewfinderB'
import { useCanvasCapture } from '../composables/useCanvasCapture'
import StartOverlay from '../components/StartOverlay.vue'
import LoadingOverlay from '@/components/Loading/LoadingOverlay.vue'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { useRouter } from 'vue-router'
import { useFilmStore } from '@/stores'
const router = useRouter()
const start = ref(false)
const showInstruction = ref<boolean>(false)
type ResizeDirection = 'nw' | 'ne' | 'sw' | 'se'
// 基础逻辑
const back = (): void => router.back()
const hideInstruction = (): void => {
showInstruction.value = false
}
const handleStart = (): void => {
start.value = true
showInstruction.value = true
}
// 截图功能
const { isLoading, showModal, capturedImageUrl, capture } = useCanvasCapture()
const handleModalClose = () => {
showModal.value = false
}
// 自由取景框模式
const { viewfinderContainer, viewfinderBox, viewfinder, startDrag, startResize } = useViewfinderB()
const handleStartDrag = (event: MouseEvent | TouchEvent): void => {
hideInstruction()
startDrag(event)
}
const handleStartResize = (direction: ResizeDirection, event: MouseEvent | TouchEvent): void => {
hideInstruction()
startResize(direction, event)
}
const showGuide = ref(false)
const backgroundImage = ref<HTMLImageElement | null>(null)
// Canvas 截图配置常量
const UI_UPDATE_DELAY = 50 // UI 更新延迟(ms)
const CANVAS_SCALE = 2 // Canvas 缩放比例(2倍高清)
const GUIDE_SHOW_DELAY = 1000 // 引导显示延迟(ms)
/**
* 截取场景
* 使用 Canvas 手动绘制,确保移动端兼容性
*/
/**
* 截取场景
* 使用 Canvas 手动绘制,确保移动端兼容性
*/
const captureScene = async (): Promise<void> => {
if (isLoading.value) return
hideInstruction()
await capture({
width: viewfinder.value.width,
height: viewfinder.value.height,
scale: CANVAS_SCALE,
delay: UI_UPDATE_DELAY,
onDraw: ctx => {
if (!backgroundImage.value) {
throw new Error('Background image not found')
}
// 计算图片实际尺寸和设计稿尺寸的缩放比例
// viewfinder 的坐标是基于设计稿尺寸(1920x1080)
// 但 backgroundImage 的实际尺寸可能不同
const imageScaleX = backgroundImage.value.naturalWidth / 1920
const imageScaleY = backgroundImage.value.naturalHeight / 1080
// 绘制背景图片的裁剪区域
// 将设计稿坐标转换为图片实际坐标
ctx.drawImage(
backgroundImage.value,
viewfinder.value.x * imageScaleX, // 源图 x(转换为实际坐标)
viewfinder.value.y * imageScaleY, // 源图 y(转换为实际坐标)
viewfinder.value.width * imageScaleX, // 源图 width(转换为实际坐标)
viewfinder.value.height * imageScaleY, // 源图 height(转换为实际坐标)
0, // 目标 x
0, // 目标 y
viewfinder.value.width, // 目标 width
viewfinder.value.height // 目标 height
)
},
onComplete: () => {
// 延迟显示引导
setTimeout(() => {
showGuide.value = true
}, GUIDE_SHOW_DELAY)
}
})
}
/**
* 完成引导,跳转到 Vlog 页面
*/
const handleComplete = () => {
showGuide.value = false
useFilmStore().setFilmUnlockProgress(3)
router.push({
name: 'Film'
})
}
</script>
<template>
<div class="w-full h-full">
<div
class="relative w-full h-full bg-cover bg-center bg-no-repeat shooting-bg"
:style="{
backgroundImage: getBackgroundImage('film/shooting/油罐艺术公园模糊底图.png')
}"
>
<!-- 标题 -->
<ImgButton image="shooting/标题-取景地b.png" class="title-button absolute left-0" />
<!-- 返回按钮 -->
<ImgButton
image="shooting/返回.png"
class="back-button absolute right-0 z-50"
@click="back"
/>
<p
v-if="start && showInstruction"
class="absolute left-1/2 -translate-x-1/2 top-[220px] z-30 text-red-500 text-4xl leading-normal font-bold"
>
操作说明:拖动取景框四角调整裁剪范围,点击“取景”键就完成啦!
</p>
<StartOverlay scene="b" v-if="!start" @start="handleStart" />
<!-- 取景 -->
<div class="absolute bottom-0 w-full h-full" v-else>
<!-- 隐藏的背景图片,用于 Canvas 绘制 -->
<img
ref="backgroundImage"
class="absolute inset-0 w-full h-full object-cover pointer-events-none opacity-0"
:src="getStaticImage('film/shooting/龙美术馆清晰图.png')"
alt=""
crossorigin="anonymous"
/>
<!-- 取景框容器 -->
<div ref="viewfinderContainer" class="relative w-full h-full">
<!-- 四个方向的遮罩层 -->
<!-- 上遮罩 -->
<div
class="mask-layer absolute left-0 right-0 bg-black/60 pointer-events-none z-10"
:style="{
top: 0,
height: viewfinder.y + 'px'
}"
></div>
<!-- 下遮罩 -->
<div
class="mask-layer absolute left-0 right-0 bg-black/60 pointer-events-none z-10"
:style="{
top: viewfinder.y + viewfinder.height + 'px',
bottom: 0
}"
></div>
<!-- 左遮罩 -->
<div
class="mask-layer absolute left-0 bg-black/60 pointer-events-none z-10"
:style="{
top: viewfinder.y + 'px',
width: viewfinder.x + 'px',
height: viewfinder.height + 'px'
}"
></div>
<!-- 右遮罩 -->
<div
class="mask-layer absolute right-0 bg-black/60 pointer-events-none z-10"
:style="{
top: viewfinder.y + 'px',
left: viewfinder.x + viewfinder.width + 'px',
height: viewfinder.height + 'px'
}"
></div>
<!-- 可拖动调整的取景框 -->
<div
ref="viewfinderBox"
class="viewfinder-frame absolute border-4 border-white cursor-move z-20"
:style="{
left: viewfinder.x + 'px',
top: viewfinder.y + 'px',
width: viewfinder.width + 'px',
height: viewfinder.height + 'px'
}"
@mousedown="handleStartDrag"
@touchstart="handleStartDrag"
>
<!-- 九宫格辅助线 -->
<div class="grid-lines absolute inset-0 grid grid-cols-3 grid-rows-3">
<div
v-for="i in 9"
:key="i"
class="border border-white/40"
:class="{
'border-t-0': i <= 3,
'border-b-0': i >= 7,
'border-l-0': (i - 1) % 3 === 0,
'border-r-0': i % 3 === 0
}"
></div>
</div>
<!-- 四个角的调整手柄 -->
<div
class="resize-handle absolute w-6 h-6 bg-white rounded-full -left-3 -top-3 cursor-nwse-resize"
@mousedown.stop="handleStartResize('nw', $event)"
@touchstart.stop="handleStartResize('nw', $event)"
></div>
<div
class="resize-handle absolute w-6 h-6 bg-white rounded-full -right-3 -top-3 cursor-nesw-resize"
@mousedown.stop="handleStartResize('ne', $event)"
@touchstart.stop="handleStartResize('ne', $event)"
></div>
<div
class="resize-handle absolute w-6 h-6 bg-white rounded-full -left-3 -bottom-3 cursor-nesw-resize"
@mousedown.stop="handleStartResize('sw', $event)"
@touchstart.stop="handleStartResize('sw', $event)"
></div>
<div
class="resize-handle absolute w-6 h-6 bg-white rounded-full -right-3 -bottom-3 cursor-nwse-resize"
@mousedown.stop="handleStartResize('se', $event)"
@touchstart.stop="handleStartResize('se', $event)"
></div>
</div>
</div>
<!-- 底部取景按钮 - 跟随取景框 -->
<div
class="capture-button-container absolute z-20 -translate-x-1/2 -translate-y-1/2"
:style="{
left: viewfinder.x + viewfinder.width / 2 + 'px',
top: viewfinder.y + viewfinder.height + 'px'
}"
>
<ImgButton class="w-36" image="shooting/按钮-取景" @click="captureScene" />
</div>
</div>
<!-- 结果弹窗 -->
<Overlay :visible="showModal" @close="handleModalClose">
<div class="relative w-[1000px] h-[600px] flex items-center justify-center">
<!-- 截图内容 -->
<div v-if="capturedImageUrl" class="bg-white p-2 rounded-md -rotate-6">
<img :src="capturedImageUrl" alt="拍摄结果" />
</div>
<!-- 操作按钮 -->
<!-- <div class="absolute bottom-1/2 -right-1/3 translate-y-1/2">
<ImgButton image="shooting/按钮-前往下一个取景地" @click="handleFinish" />
</div> -->
</div>
</Overlay>
</div>
<LoadingOverlay :visible="isLoading" text="正在出片中..." />
<!-- 引导步骤 -->
<GuideSteps v-if="showGuide" positionOffsetY="80%" @complete="handleComplete">
<GuideStepsItem> 实地取景已全部完成,现在跟着我走进现场开始正式的拍摄吧~ </GuideStepsItem>
</GuideSteps>
</div>
</template>
@@ -0,0 +1,291 @@
import { ref, onMounted, onUnmounted } from 'vue'
import { getTouchDistance, getTouchCenter, getClientPosition } from '../utils/touchGestures'
// 设计稿尺寸常量
const DESIGN_WIDTH = 1920
const DESIGN_HEIGHT = 1080
/**
* 自由取景框模式的逻辑
* 支持拖动、四角调整大小、双指缩放
*/
export function useViewfinderB() {
// 取景框相关
const viewfinderContainer = ref<HTMLElement>()
const viewfinderBox = ref<HTMLElement>()
const viewfinder = ref({
x: 660, // (1920 - 600) / 2 = 660,居中
y: 340, // (1080 - 400) / 2 = 340,居中
width: 600,
height: 400
})
// 拖动相关状态
const isDragging = ref(false)
const dragStart = ref({ x: 0, y: 0 })
// 调整大小相关状态
const isResizing = ref(false)
const resizeCorner = ref<'nw' | 'ne' | 'sw' | 'se' | null>(null)
const resizeStart = ref({
x: 0,
y: 0,
boxX: 0,
boxY: 0,
boxWidth: 0,
boxHeight: 0
})
// 双指缩放相关状态
const isPinching = ref(false)
const pinchStart = ref({
distance: 0,
centerX: 0,
centerY: 0,
boxWidth: 0,
boxHeight: 0,
boxX: 0,
boxY: 0
})
// 将屏幕坐标转换为缩放后的本地坐标
const convertClientToLocal = (clientX: number, clientY: number) => {
if (!viewfinderContainer.value) return { x: clientX, y: clientY }
const containerRect = viewfinderContainer.value.getBoundingClientRect()
// 计算缩放比例
const scaleX = containerRect.width / DESIGN_WIDTH
const scaleY = containerRect.height / DESIGN_HEIGHT
// 计算相对于容器的坐标,并转换为设计稿坐标系
const localX = (clientX - containerRect.left) / scaleX
const localY = (clientY - containerRect.top) / scaleY
return { x: localX, y: localY }
}
// 开始拖动取景框
const startDrag = (e: MouseEvent | TouchEvent) => {
if (isResizing.value || isPinching.value) return
// 检测双指触摸
if ('touches' in e && e.touches.length === 2) {
// 双指缩放
isPinching.value = true
const touch1 = e.touches[0]
const touch2 = e.touches[1]
pinchStart.value = {
distance: getTouchDistance(touch1, touch2),
centerX: (touch1.clientX + touch2.clientX) / 2,
centerY: (touch1.clientY + touch2.clientY) / 2,
boxWidth: viewfinder.value.width,
boxHeight: viewfinder.value.height,
boxX: viewfinder.value.x,
boxY: viewfinder.value.y
}
e.preventDefault()
return
}
// 单指拖动
if ('touches' in e && e.touches.length !== 1) return
isDragging.value = true
const { x: clientX, y: clientY } = getClientPosition(e)
const localPos = convertClientToLocal(clientX, clientY)
dragStart.value = {
x: localPos.x - viewfinder.value.x,
y: localPos.y - viewfinder.value.y
}
e.preventDefault()
}
// 开始调整大小
const startResize = (corner: 'nw' | 'ne' | 'sw' | 'se', e: MouseEvent | TouchEvent) => {
isResizing.value = true
resizeCorner.value = corner
const { x: clientX, y: clientY } = getClientPosition(e)
const localPos = convertClientToLocal(clientX, clientY)
resizeStart.value = {
x: localPos.x,
y: localPos.y,
boxX: viewfinder.value.x,
boxY: viewfinder.value.y,
boxWidth: viewfinder.value.width,
boxHeight: viewfinder.value.height
}
e.preventDefault()
}
// 处理移动
const handleMove = (e: MouseEvent | TouchEvent) => {
if (!isDragging.value && !isResizing.value && !isPinching.value) return
// 处理双指缩放
if (isPinching.value && 'touches' in e && e.touches.length === 2) {
const touch1 = e.touches[0]
const touch2 = e.touches[1]
const currentDistance = getTouchDistance(touch1, touch2)
const scale = currentDistance / pinchStart.value.distance
// 计算新的尺寸
let newWidth = pinchStart.value.boxWidth * scale
let newHeight = pinchStart.value.boxHeight * scale
const minSize = 200
// 限制最小和最大尺寸
newWidth = Math.max(minSize, Math.min(newWidth, DESIGN_WIDTH))
newHeight = Math.max(minSize, Math.min(newHeight, DESIGN_HEIGHT))
// 保持中心点不变,计算新的位置
const center = getTouchCenter(touch1, touch2)
const localCenter = convertClientToLocal(center.x, center.y)
let newX = localCenter.x - newWidth / 2
let newY = localCenter.y - newHeight / 2
// 边界检测
newX = Math.max(0, Math.min(newX, DESIGN_WIDTH - newWidth))
newY = Math.max(0, Math.min(newY, DESIGN_HEIGHT - newHeight))
viewfinder.value.width = newWidth
viewfinder.value.height = newHeight
viewfinder.value.x = newX
viewfinder.value.y = newY
e.preventDefault()
return
}
const { x: clientX, y: clientY } = getClientPosition(e)
const localPos = convertClientToLocal(clientX, clientY)
if (isDragging.value && viewfinderContainer.value) {
// 拖动取景框
let newX = localPos.x - dragStart.value.x
let newY = localPos.y - dragStart.value.y
// 边界检测 - 使用设计稿尺寸
newX = Math.max(0, Math.min(newX, DESIGN_WIDTH - viewfinder.value.width))
newY = Math.max(0, Math.min(newY, DESIGN_HEIGHT - viewfinder.value.height))
viewfinder.value.x = newX
viewfinder.value.y = newY
} else if (isResizing.value && viewfinderContainer.value) {
// 调整取景框大小
const deltaX = localPos.x - resizeStart.value.x
const deltaY = localPos.y - resizeStart.value.y
const minSize = 200 // 最小尺寸
if (resizeCorner.value === 'se') {
// 右下角 - 使用设计稿尺寸限制
viewfinder.value.width = Math.max(
minSize,
Math.min(resizeStart.value.boxWidth + deltaX, DESIGN_WIDTH - viewfinder.value.x)
)
viewfinder.value.height = Math.max(
minSize,
Math.min(resizeStart.value.boxHeight + deltaY, DESIGN_HEIGHT - viewfinder.value.y)
)
} else if (resizeCorner.value === 'sw') {
// 左下角 - 使用设计稿尺寸限制
const newWidth = resizeStart.value.boxWidth - deltaX
const newX = resizeStart.value.boxX + deltaX
if (newWidth >= minSize && newX >= 0) {
viewfinder.value.x = newX
viewfinder.value.width = newWidth
}
viewfinder.value.height = Math.max(
minSize,
Math.min(resizeStart.value.boxHeight + deltaY, DESIGN_HEIGHT - viewfinder.value.y)
)
} else if (resizeCorner.value === 'ne') {
// 右上角 - 使用设计稿尺寸限制
const newHeight = resizeStart.value.boxHeight - deltaY
const newY = resizeStart.value.boxY + deltaY
if (newHeight >= minSize && newY >= 0) {
viewfinder.value.y = newY
viewfinder.value.height = newHeight
}
viewfinder.value.width = Math.max(
minSize,
Math.min(resizeStart.value.boxWidth + deltaX, DESIGN_WIDTH - viewfinder.value.x)
)
} else if (resizeCorner.value === 'nw') {
// 左上角 - 使用设计稿尺寸限制
const newWidth = resizeStart.value.boxWidth - deltaX
const newHeight = resizeStart.value.boxHeight - deltaY
const newX = resizeStart.value.boxX + deltaX
const newY = resizeStart.value.boxY + deltaY
if (newWidth >= minSize && newX >= 0) {
viewfinder.value.x = newX
viewfinder.value.width = newWidth
}
if (newHeight >= minSize && newY >= 0) {
viewfinder.value.y = newY
viewfinder.value.height = newHeight
}
}
}
e.preventDefault()
}
// 结束拖动或调整
const handleEnd = () => {
isDragging.value = false
isResizing.value = false
isPinching.value = false
resizeCorner.value = null
}
// 挂载时添加全局事件监听
onMounted(() => {
document.addEventListener('mousemove', handleMove)
document.addEventListener('mouseup', handleEnd)
document.addEventListener('touchmove', handleMove)
document.addEventListener('touchend', handleEnd)
})
// 卸载时移除事件监听
onUnmounted(() => {
document.removeEventListener('mousemove', handleMove)
document.removeEventListener('mouseup', handleEnd)
document.removeEventListener('touchmove', handleMove)
document.removeEventListener('touchend', handleEnd)
})
return {
// Refs
viewfinderContainer,
viewfinderBox,
// 状态
viewfinder,
isDragging,
isResizing,
isPinching,
// 方法
startDrag,
startResize,
// 常量
DESIGN_WIDTH,
DESIGN_HEIGHT
}
}
@@ -0,0 +1,210 @@
<script lang="ts" setup>
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { Overlay } from '@/components/Overlay'
import { ImgButton } from '@/components/Button'
import { getBackgroundImage, IMAGE_PATHS } from '@/utils'
import shootingtitle from '@/assets/images/shooting/标题-实地取景.png'
import videoSrc from '/video/xinshou_jiuzhonggoutufa.mp4'
import KnowledgeCard from '@/components/KnowledgeCard/FilmKnowledgeCard.vue'
type Btn = {
key: string
name: string
btnImage: string
images: string[]
desc: string
}
const router = useRouter()
const showModal = ref(false)
const showModal2 = ref(false)
const showModal3 = ref(false)
const currentItem = ref<Btn>({
key: '',
name: '',
btnImage: '',
images: [],
desc: ''
})
const btnList = ref<Btn[]>([
{
key: '1',
name: '引导线构图',
btnImage: 'film/shooting/框架构图',
desc: '引导线构图就是主体放在画面中心,让画面更稳定主体更突出,拍摄时需避免选取杂乱的背景,可以用大光圈或者长焦距让背景虚化使主体从背景中跳出来适合拍摄单个主体,操作比较简单,非常适合新手使用',
images: ['film/shooting/05引导线构图_底图', 'film/shooting/06引导线构图_底图']
},
{
key: '2',
name: '对称构图',
btnImage: 'film/shooting/对称构图',
desc: '对称构图又称九宫格构图,就是把拍摄主体置于九宫格的任意一条三分位置,三分构图法是比较常见的构图法可以视觉上让整体画面更加生动,使用三分构图法拍摄人像时,尽量占据画面的下2格比例和重心会更协调',
images: ['film/shooting/03对称构图_底图', 'film/shooting/04对称构图_底图']
},
{
key: '3',
name: '框架构图',
btnImage: 'film/shooting/引导线构图',
desc: '框架构图属于引导线构图的一种,所以也是借助引导线的视觉引导效果来突出强调主体。另外对角线构图在画面中制造出对角线的走向即可。',
images: ['film/shooting/01框架构图_底图', 'film/shooting/02框架构图_底图']
}
])
const handleModalClose = () => {
showModal.value = false
}
const handleClick = (item: any) => {
currentItem.value = item
showModal2.value = true
}
const currentCompositionKey = computed(() => {
const k = currentItem.value.key
if (k === '1' || k === '2' || k === '3') return k
return '1'
})
const handleClickA = () => {
// 跳转设备准备
router.push({ name: 'FilmFilmingLocationA' })
}
const handleClickB = () => {
// 跳转设备准备
router.push({ name: 'FilmFilmingLocationB' })
}
const back = () => {
// 跳转设备准备
router.back()
}
</script>
<template>
<div class="relative w-full h-full">
<!-- 背景图片 -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat shooting-bg"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.SHOOTING.BG) }"
></div>
<div class="shooting-title">
<img :src="shootingtitle" alt="" />
</div>
<div class="shooting-return">
<ImgButton image="shooting/返回.png" @click="back" />
</div>
<div class="absolute left-[12%] top-1/2 -translate-y-[calc(50%-70px)]">
<ImgButton image="film/shooting/龙美术馆入口.png" @click="handleClickA" />
</div>
<div class="absolute right-[12%] top-1/2 -translate-y-[calc(50%-70px)]">
<ImgButton image="film/shooting/油罐艺术公园入口.png" @click="handleClickB" />
</div>
<div class="shooting-composition">
<ImgButton image="shooting/构图指南.png" @click="showModal = true" />
</div>
<!-- 引导步骤 -->
<GuideSteps
:highlight-selector="'.mybutton'"
:highlight-padding="12"
:highlight-radius="20"
position-offset-y="85%"
>
<GuideStepsItem> 哇,徐汇滨江真的超适合取景!一起看看可以运用哪些构图方法吧~ </GuideStepsItem>
</GuideSteps>
<!-- 保存图片弹窗 -->
<Overlay
:visible="showModal"
:clickable="false"
:customStyle="{
backgroundImage: getBackgroundImage('film/shooting/构图指南底图')
}"
customClass="inset-0 bg-cover bg-center bg-no-repeat"
contentClass="relative w-full h-full"
>
<!-- 返回 -->
<ImgButton
class="absolute right-0 top-0 z-10"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleModalClose"
/>
<!-- 构图指南 -->
<ImgButton class="absolute left-0 top-0 z-10" image="shooting/标题-构图指南" />
<!-- 新手导航视频 -->
<ImgButton
class="absolute left-28 top-[calc(50%-40px)] -translate-y-1/2"
image="shooting/新手导航-视频回顾"
@click="showModal3 = true"
/>
<div class="absolute top-[30%] right-10">
<ImgButton
v-for="item in btnList"
:key="item.key"
:image="item.btnImage"
@click="handleClick(item)"
/>
</div>
<!-- 知识卡片 -->
<KnowledgeCard
v-if="showModal2 && currentItem.key"
:visible="showModal2"
:title="currentItem.name"
:desc="currentItem.desc"
:images="currentItem.images"
:compositionKey="currentCompositionKey"
@close="showModal2 = false"
/>
<!-- 视频 -->
<Overlay :visible="showModal3" :clickable="false">
<video :src="videoSrc" controls></video>
<!-- 返回 -->
<ImgButton
class="absolute right-0 top-0 z-10"
:image="IMAGE_PATHS.COMPONENTS.CLOSE"
@click="showModal3 = false"
/>
</Overlay>
</Overlay>
</div>
</template>
<style lang="less" scoped>
.shooting-title {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
.shooting-return {
position: absolute;
right: 0;
top: 0;
}
.shooting-left {
position: absolute;
left: 16%;
top: 50%;
}
.shooting-right {
position: absolute;
right: 16%;
top: 50%;
}
.shooting-composition {
position: absolute;
left: 2rem;
bottom: 1rem;
}
</style>
@@ -0,0 +1,47 @@
/**
* 触摸手势工具函数
* 用于处理触摸事件的通用计算
*/
/**
* 计算两个触摸点之间的距离
* @param touch1 第一个触摸点
* @param touch2 第二个触摸点
* @returns 两点之间的欧氏距离
*/
export const getTouchDistance = (touch1: Touch, touch2: Touch): number => {
const dx = touch1.clientX - touch2.clientX
const dy = touch1.clientY - touch2.clientY
return Math.sqrt(dx * dx + dy * dy)
}
/**
* 计算两个触摸点的中心坐标
* @param touch1 第一个触摸点
* @param touch2 第二个触摸点
* @returns 中心点坐标 {x, y}
*/
export const getTouchCenter = (touch1: Touch, touch2: Touch) => {
return {
x: (touch1.clientX + touch2.clientX) / 2,
y: (touch1.clientY + touch2.clientY) / 2
}
}
/**
* 从鼠标或触摸事件中获取客户端坐标
* @param e 鼠标事件或触摸事件
* @returns 客户端坐标 {x, y}
*/
export const getClientPosition = (e: MouseEvent | TouchEvent) => {
if ('touches' in e && e.touches.length > 0) {
return {
x: e.touches[0].clientX,
y: e.touches[0].clientY
}
}
return {
x: (e as MouseEvent).clientX,
y: (e as MouseEvent).clientY
}
}
+349
View File
@@ -0,0 +1,349 @@
<script lang="ts" setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
import { ImgButton } from '@/components/Button'
import { StarRating } from '@/components/Rating'
import { useRouter } from 'vue-router'
import BackToTopIcon from '@/assets/svg/back-to-top.svg'
const router = useRouter()
const TOTAL_PAGES = 3
const PAGE_HEIGHT = 1080
const SCROLL_DURATION = 1000
const currentPage = ref<number>(0)
const worksScrollIndex = ref<number>(0)
const animatingToPage = ref<number>(0)
const containerRef = ref<HTMLElement | null>(null)
const isAnimating = ref<boolean>(false)
/** 是否为最后一页 */
const isLastPage = computed<boolean>(() => currentPage.value >= TOTAL_PAGES - 1)
/** 是否正在从第二页回到第一页 */
const isScrollingBackToFirst = computed<boolean>(() => {
return isAnimating.value && currentPage.value === 1 && animatingToPage.value === 0
})
/** 第一页坐着的人物是否显示 */
const showSittingPerson = computed<boolean>(() => {
// 当前在第一页,且不是正在回滚中
return currentPage.value === 0 && !isScrollingBackToFirst.value
})
/** 他人实战作品列表 */
interface WorkItem {
id: number
image: string
title: string
stars: number
path: string
description: string
}
const worksList = ref<WorkItem[]>([
{
id: 1,
image: 'https://picsum.photos/400/300?random=1',
title: '作品名称',
stars: 4,
path: '/video/practice/film_anli1.mp4',
description:
'作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介'
}
])
/** 左滑动作品列表 */
const scrollWorksLeft = (): void => {
if (worksScrollIndex.value > 0) {
worksScrollIndex.value--
}
}
/** 右滑动作品列表 */
const scrollWorksRight = (): void => {
if (worksScrollIndex.value < worksList.value.length - 3) {
worksScrollIndex.value++
}
}
const handleClose = (): void => {
router.back()
}
/** 自定义平滑滚动动画 */
const smoothScrollTo = (toPage: number): void => {
const container = containerRef.value
if (!container || isAnimating.value) return
isAnimating.value = true
animatingToPage.value = toPage
const startTop = container.scrollTop
const targetTop = toPage * PAGE_HEIGHT
const distance = targetTop - startTop
const startTime = performance.now()
/** easeInOutQuart 缓动函数 - 更平滑 */
const easeInOutQuart = (t: number): number => {
return t < 0.5 ? 8 * t * t * t * t : 1 - Math.pow(-2 * t + 2, 4) / 2
}
const animateScroll = (currentTime: number): void => {
const elapsed = currentTime - startTime
const progress = Math.min(elapsed / SCROLL_DURATION, 1)
const easeProgress = easeInOutQuart(progress)
container.scrollTop = startTop + distance * easeProgress
if (progress < 1) {
requestAnimationFrame(animateScroll)
} else {
isAnimating.value = false
currentPage.value = toPage
}
}
requestAnimationFrame(animateScroll)
}
/** 滚动到指定页 */
const scrollToPage = (page: number): void => {
const targetPage = Math.max(0, Math.min(page, TOTAL_PAGES - 1))
if (targetPage !== currentPage.value && !isAnimating.value) {
smoothScrollTo(targetPage)
}
}
/** 滚动到下一页 */
const scrollToNextPage = (): void => {
scrollToPage(currentPage.value + 1)
}
/** 滚动到上一页 */
const scrollToPrevPage = (): void => {
scrollToPage(currentPage.value - 1)
}
/** 回到顶部 */
const scrollToTop = (): void => {
scrollToPage(0)
}
/** 处理鼠标滚轮事件 */
const handleWheel = (event: WheelEvent): void => {
event.preventDefault()
if (isAnimating.value) return
if (event.deltaY > 0) {
scrollToNextPage()
} else if (event.deltaY < 0) {
scrollToPrevPage()
}
}
onMounted(() => {
containerRef.value?.addEventListener('wheel', handleWheel, { passive: false })
})
onUnmounted(() => {
containerRef.value?.removeEventListener('wheel', handleWheel)
})
</script>
<template>
<div ref="containerRef" class="w-full h-full overflow-auto">
<div class="relative min-h-full overflow-hidden">
<!-- 关闭 -->
<ImgButton
class="absolute right-0 top-0 z-50"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleClose"
/>
<!-- 实战操作台 -->
<div
class="w-full h-[1080px] bg-cover bg-no-repeat relative"
:style="{ backgroundImage: getBackgroundImage('film/practice/实战操作台首页- 电影感') }"
>
<ImgButton
class="absolute left-40 bottom-28 z-10"
image="vlog/practice/查看任务要求"
@click="scrollToNextPage"
/>
<!-- img -->
<img
class="absolute right-0"
draggable="false"
:src="getStaticImage('vlog/practice/背景1')"
alt=""
/>
</div>
<!-- 任务要求 -->
<div
class="w-full h-[1080px] bg-cover bg-no-repeat relative overflow-hidden"
:style="{ backgroundImage: getBackgroundImage('vlog/practice/背景2') }"
>
<ImgButton
class="absolute right-20 bottom-0 z-99"
image="vlog/practice/我的创作笔记"
path="/film/practice/my-notes"
/>
<div class="task-panel w-[757px] h-full ml-10 overflow-y-auto" @wheel.stop>
<img :src="getStaticImage('film/practice/滑动任务面板-电影感')" alt="" />
</div>
<img
class="absolute right-0 bottom-0 pointer-events-none"
:src="getStaticImage('vlog/practice/摄像机')"
alt=""
/>
<!-- 站着的人物 - 第二页固定在此(不在回滚动画中) -->
<Transition name="fade">
<img
v-show="currentPage === 1 && !isScrollingBackToFirst"
class="absolute right-8 bottom-0 max-h-[90%] pointer-events-none"
draggable="false"
:src="getStaticImage('vlog/practice/摄像机-人')"
alt=""
/>
</Transition>
</div>
<!-- 他人实战作品 -->
<div
class="w-full h-[1080px] bg-cover bg-no-repeat pt-44 px-16"
:style="{ backgroundImage: getBackgroundImage('vlog/practice/他人实战作品') }"
>
<!-- 作品列表 -->
<div class="w-full overflow-hidden">
<div
class="flex gap-6 transition-transform duration-500"
:style="{ transform: `translateX(-${worksScrollIndex * 509}px)` }"
>
<div
v-for="work in worksList"
:key="work.id"
class="w-[503px] h-[722px] flex-shrink-0 rounded-[40px] overflow-hidden bg-[#FFF8F0] py-8 px-6"
>
<!-- 作品图片 -->
<div class="w-full h-[280px] rounded-2xl overflow-hidden">
<img class="w-full h-full object-cover" :src="work.image" :alt="work.title" />
</div>
<!-- 作品信息 -->
<div class="flex flex-col items-center pt-8 space-y-4">
<h3 class="text-5xl font-bold text-gray-800 mb-2">{{ work.title }}</h3>
<!-- 星星评分 -->
<StarRating :score="work.stars" size="lg" readonly />
<!-- 简介 -->
<p class="h-[110px] text-2xl text-gray-600 mb-3 line-clamp-3 leading-relaxed">
{{ work.description }}
</p>
<!-- 查看按钮 -->
<ImgButton
image="vlog/practice/查看"
:path="`/film/practice/video/${encodeURIComponent(work.path)}`"
/>
</div>
</div>
</div>
</div>
<!-- 指示按钮 -->
<div class="absolute bottom-5 left-1/2 -translate-x-1/2 flex gap-16">
<img
class="cursor-pointer hover:scale-110 transition-transform"
:class="{ 'opacity-50 cursor-not-allowed': worksScrollIndex === 0 }"
:src="getStaticImage('vlog/practice/滑动左')"
alt="向左"
@click="scrollWorksLeft"
/>
<img
class="cursor-pointer hover:scale-110 transition-transform rotate-180"
:class="{ 'opacity-50 cursor-not-allowed': worksScrollIndex >= worksList.length - 3 }"
:src="getStaticImage('vlog/practice/滑动左')"
alt="向右"
@click="scrollWorksRight"
/>
</div>
</div>
</div>
<!-- 下滑按钮 - 最后一页隐藏 -->
<ImgButton
v-show="!isLastPage"
class="absolute bottom-0 left-1/2 -translate-x-1/2"
:image="'vlog/practice/下滑'"
@click="scrollToNextPage"
/>
<!-- 回到顶部按钮 - 最后一页显示 -->
<img
v-show="isLastPage"
class="absolute bottom-10 right-10 cursor-pointer transition-all duration-300 hover:scale-105"
:src="BackToTopIcon"
@click="scrollToTop"
/>
<!-- 跨页人物动画 -->
<div v-show="showSittingPerson || isScrollingBackToFirst" class="character-container">
<!-- 坐着的人物 - 第一页 -->
<Transition name="fade">
<img
v-show="showSittingPerson"
class="character-img right-20 bottom-10"
draggable="false"
:src="getStaticImage('vlog/practice/背景1-人')"
alt=""
/>
</Transition>
<!-- 站着的人物 - 回滚动画中 -->
<Transition name="fade">
<img
v-show="isScrollingBackToFirst"
class="character-img right-8 bottom-0"
draggable="false"
:src="getStaticImage('vlog/practice/摄像机-人')"
alt=""
/>
</Transition>
</div>
</div>
</template>
<style scoped>
/* 隐藏滚动条但保持可滚动 - 兼容主流浏览器 */
.task-panel {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
.task-panel::-webkit-scrollbar {
display: none; /* Chrome/Safari/Opera */
}
/* 第一页人物动画容器 */
.character-container {
position: fixed;
right: 0;
top: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 20;
}
.character-img {
position: absolute;
max-height: 90%;
}
/* 淡入淡出动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 1s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
+170
View File
@@ -0,0 +1,170 @@
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { getBackgroundImage, IMAGE_PATHS } from '@/utils'
import { ImgButton } from '@/components/Button'
import { Overlay } from '@/components/Overlay'
import { StarRating } from '@/components/Rating'
const router = useRouter()
/** 笔记内容 */
const noteContent = ref<string>('')
/** 保存弹窗 */
const showModal = ref<boolean>(false)
/** 返回上一页 */
const handleBack = (): void => {
router.back()
}
const disabled = computed<boolean>(() => noteContent.value.trim() === '')
interface EvaluationItem {
/** 标题 */
title: string
/** 描述 */
description: string
/** 评分 */
score: number
}
/** 评估项目列表 */
const evaluationItems = ref<EvaluationItem[]>([
{
title: '画面氛围',
description: '画面具有明确情绪或空间气质,整体风格统一',
score: 4
},
{
title: '拍摄方法',
description: '拍摄方式选择合理,镜头语言服务于画面表达',
score: 2
},
{
title: '剪辑逻辑',
description: '剪辑节奏清晰,镜头衔接自然,有整体感',
score: 5
},
{
title: '创作意识',
description: '能体现主动构思,而非随拍拼接',
score: 0
},
{
title: '综合观感',
description: '作品具有“电影感”,完成度高',
score: 5
}
])
const handleSave = (): void => {
console.log('保存笔记')
handleBack()
}
</script>
<template>
<div
class="w-full h-full bg-no-repeat bg-cover px-8 pt-[400px]"
:style="{ backgroundImage: getBackgroundImage('film/practice/创作笔记底图-电影感') }"
>
<ImgButton
class="absolute top-0 right-0"
:image="IMAGE_PATHS.COMPONENTS.CLOSE"
@click="handleBack"
/>
<textarea
v-model="noteContent"
class="w-full h-[300px]"
name="myNotes"
placeholder="点击输入创作笔记"
></textarea>
<div class="absolute bottom-24 left-1/2 -translate-x-1/2 flex gap-60">
<ImgButton image="vlog/practice/保存" @click="handleSave" />
<ImgButton image="vlog/practice/自评" :disabled="disabled" @click="showModal = true" />
</div>
<!-- 保存图片弹窗 -->
<Overlay :visible="showModal" :clickable="false">
<div
class="w-[1795px] h-[987px] bg-no-repeat bg-cover p-10 pt-[100px]"
:style="{ backgroundImage: getBackgroundImage('film/practice/评分底板-电影感') }"
>
<ImgButton
class="absolute -top-8 -right-14 z-10"
:image="IMAGE_PATHS.COMPONENTS.CLOSE"
@click="showModal = false"
/>
<div class="relative h-full overflow-y-scroll px-8">
<div class="pl-10 space-y-10 mt-10">
<p class="glow-text">
评分时要关注5个维度哦~每个维度满分5分,能打1-5分,记得对照作品表现来打分呀~
</p>
<div
v-for="(item, index) in evaluationItems"
:key="index"
class="relative w-full h-[182px] bg-no-repeat bg-cover px-12 py-3 flex justify-between"
:style="{ backgroundImage: getBackgroundImage('vlog/practice/评分条') }"
>
<div class="pr-8 space-y-10">
<h4 class="text-3xl font-bold text-white mb-3">
{{ `0${index + 1}` }} {{ item.title }}
</h4>
<p class="text-3xl text-gray-800 font-bold leading-relaxed">
{{ item.description }}
</p>
</div>
<StarRating class="absolute -bottom-5 right-10" :score="item.score" size="lg" />
</div>
<p class="glow-text">
己评价的实战作品,从封面入口就能直接看到具体评分!打了多少分一目了然,不用再点进详情页翻找啦~
</p>
<div class="w-full flex justify-center">
<ImgButton image="vlog/practice/保存" @click="showModal = false" />
</div>
</div>
</div>
</div>
</Overlay>
</div>
</template>
<style scoped>
textarea {
border: none;
outline: none;
resize: none;
font-size: 36px;
background-color: transparent;
}
textarea::placeholder {
color: #5d5b5b;
font-weight: 700;
}
.glow-text {
color: white;
font-size: 2rem;
font-weight: bold;
text-shadow:
/* 描边效果 - 多层阴影模拟描边 */
-1px -1px 0 #0066ff,
1px -1px 0 #0066ff,
-1px 1px 0 #0066ff,
1px 1px 0 #0066ff,
-2px -2px 0 #0066ff,
2px -2px 0 #0066ff,
-2px 2px 0 #0066ff,
2px 2px 0 #0066ff,
/* 发光效果 - 蓝色光晕 */ 0 0 10px #0066ff,
0 0 20px #0066ff,
0 0 30px #0066ff,
0 0 40px #0066ff,
0 0 50px #0066ff;
}
</style>
+129
View File
@@ -0,0 +1,129 @@
<script lang="ts" setup>
import { useRouter } from 'vue-router'
import { Video } from '@/components'
import { ImgButton } from '@/components/Button'
import { StarRating } from '@/components/Rating'
import { IMAGE_PATHS, getBackgroundImage } from '@/utils'
import { Overlay } from '@/components/Overlay'
import { ref } from 'vue'
const router = useRouter()
const props = defineProps({
path: {
type: String,
required: true
}
})
interface EvaluationItem {
/** 标题 */
title: string
/** 描述 */
description: string
/** 评分 */
score: number
}
/** 评估项目列表 */
const evaluationItems = ref<EvaluationItem[]>([
{
title: '主题与内容',
description: '主题明确聚焦"身边美好",内容真实、有感染力,能让观众感受到日常闪光',
score: 3
},
{
title: '拍摄技巧',
description: '运镜自然、光线运用合理、画面清晰饱和度、能辅助主题表达(至少1种运镜+1种光线)',
score: 4
},
{
title: '剪辑与包装',
description: '素材拼接流畅,调色/滤镜适配、字幕清晰、音乐不盖人声、整体风格统一',
score: 5
},
{
title: '创意与独特性',
description: '视角新颖、有个人观察,或用特殊细节/情节展现“美好”,避免平淡复刻',
score: 4
},
{
title: '综合感受',
description: '视角新颖、有个人观察,或用特殊细节/情节展现“美好”避免平淡复刻',
score: 5
}
])
/** 保存弹窗 */
const showModal = ref<boolean>(false)
/** 返回上一页 */
const handleBack = (): void => {
router.back()
}
</script>
<template>
<div class="relative h-full">
<ImgButton
class="absolute right-0 top-0 z-50"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleBack"
/>
<ImgButton
class="absolute right-0 bottom-10 z-50"
image="vlog/practice/评分"
@click="showModal = true"
/>
<Video
:src="decodeURIComponent(props.path)"
:volume="1"
:muted="false"
:controls="true"
:loop="false"
/>
<!-- 保存图片弹窗 -->
<Overlay :visible="showModal" :clickable="false">
<div
class="w-[1795px] h-[987px] bg-no-repeat bg-cover p-10 pt-[80px]"
:style="{ backgroundImage: getBackgroundImage('vlog/practice/评分底板') }"
>
<ImgButton
class="absolute -top-8 -right-14 z-10"
:image="IMAGE_PATHS.COMPONENTS.CLOSE"
@click="showModal = false"
/>
<div class="relative h-full overflow-y-scroll px-8">
<div
class="w-full bg-no-repeat bg-cover bg-left py-44"
:style="{ backgroundImage: getBackgroundImage('vlog/practice/滑动表内文字') }"
>
<div class="pl-10 space-y-16">
<div
v-for="(item, index) in evaluationItems"
:key="index"
class="relative w-full h-[182px] bg-no-repeat bg-cover px-12 py-3 flex justify-between"
:style="{ backgroundImage: getBackgroundImage('vlog/practice/评分条') }"
>
<div class="pr-8 space-y-10">
<h4 class="text-3xl font-bold text-white mb-3">
{{ `0${index + 1}` }} {{ item.title }}
</h4>
<p class="text-3xl text-gray-800 font-bold leading-relaxed">
{{ item.description }}
</p>
</div>
<StarRating class="absolute -bottom-5 right-10" :score="item.score" size="lg" />
</div>
<div class="w-full flex justify-center">
<ImgButton image="vlog/practice/保存" @click="showModal = false" />
</div>
</div>
</div>
</div>
</div>
</Overlay>
</div>
</template>
+31
View File
@@ -0,0 +1,31 @@
<script lang="ts" setup>
import { IMAGE_PATHS, getStaticImage } from '@/utils'
import { ImgButton } from '@/components/Button'
import { useRouter } from 'vue-router'
const router = useRouter()
const back = () => {
router.back()
}
</script>
<template>
<div class="relative w-full h-full overflow-hidden">
<!-- 返回按钮 -->
<ImgButton
class="absolute right-0 top-0 z-20"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="back"
/>
<!-- 背景 -->
<img class="absolute inset-0" :src="getStaticImage('film/img/背景.png')" alt="" />
<!-- 标题 -->
<img class="absolute top-5 z-10" :src="getStaticImage('film/img/资源筹备-标题.png')" alt="" />
<!-- 内容 -->
<div class="relative h-full pt-28 overflow-y-scroll">
<img :src="getStaticImage('film/img/资源筹备.png')" alt="" />
</div>
</div>
</template>
+60
View File
@@ -0,0 +1,60 @@
<script lang="ts" setup>
import { IMAGE_PATHS, getPublicPath, getStaticImage } from '@/utils'
import { ImgButton } from '@/components/Button'
import { Video } from '@/components'
import { useRouter } from 'vue-router'
import { ImagePreview } from '@/components'
import { useScale } from '@/composables/useScale'
const router = useRouter()
const back = () => {
// 跳转设备准备
router.back()
}
const { scale, offsetX, offsetY } = useScale()
const handleOpenImages = () => {
ImagePreview({
images: [getStaticImage('film/脚本编写-拍摄脚本')],
startIndex: 0,
maskClosable: true,
getScaleMetrics: () => ({ scale: scale.value, offsetX: offsetX.value, offsetY: offsetY.value })
})
}
</script>
<template>
<div class="relative w-full h-full overflow-hidden">
<!-- 返回按钮 -->
<ImgButton
class="absolute right-0 top-0 z-20"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="back"
/>
<!-- 背景 -->
<img class="absolute inset-0" :src="getStaticImage('film/img/背景.png')" alt="" />
<!-- 标题 -->
<img class="absolute top-5 z-10" :src="getStaticImage('film/img/脚本编写-标题.png')" alt="" />
<!-- 内容 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] -->
<div class="relative h-full pt-28 overflow-y-scroll">
<img :src="getStaticImage('film/img/脚本编写.png')" alt="" />
<div class="absolute top-60 left-36 w-[1000px] h-[700px] rounded-2xl overflow-hidden">
<Video
:src="getPublicPath('video/jiaoben.mp4')"
playsinline
controls
:volume="1"
:muted="false"
/>
</div>
<button
class="absolute top-[1480px] left-[calc(50%+55px)] -translate-x-1/2 w-[240px] h-[80px]"
@click="handleOpenImages"
/>
</div>
</div>
</template>
+180
View File
@@ -0,0 +1,180 @@
<script lang="tsx" setup>
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ImagePreview } from '@/components'
import { useScale } from '@/composables/useScale'
import { useFilmStore } from '@/stores'
const router = useRouter()
const showDoubao = ref(false)
const film = useFilmStore()
const steps = [
{ id: 'step1', content: '先认识 Vlog:点击右侧「1.vlog初识」继续' },
{ id: 'step2', content: '这是主要内容区域的边框' }
]
const stepIndex = ref(1)
const back = () => {
router.back()
}
interface SimpleStep {
id: string
content: string
}
const handleStepChange = (_step: SimpleStep | null, index: number) => {
stepIndex.value = index
}
const currentHighlight = computed(() =>
stepIndex.value === 0 ? '.img-switch-item--vlog' : '.work-border'
)
const clickdoubao = () => {
film.setFilmUnlockProgress(2)
router.push({
name: 'Film'
})
}
function openImages(urls: string[], startIndex = 0) {
ImagePreview({
images: urls,
startIndex,
maskClosable: true,
getScaleMetrics: () => ({ scale: scale.value, offsetX: offsetX.value, offsetY: offsetY.value }),
onClose: () => {
showDoubao.value = true
}
})
}
const { scale, offsetX, offsetY } = useScale()
onMounted(() => {
openImages([getStaticImage('vlog/脚本切图/我的志愿者手记拍摄脚本.png')])
})
</script>
<template>
<div class="writing-index">
<!-- 背景图片 -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat writing-bg"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.BIAOGEBG) }"
>
<div>
<div class="writing-title">
<img src="@/assets/images/vlog/标题栏-中间.png" alt="" class="titledibu" />
<img
src="@/assets/images/vlog/制作变焦镜/文字标题/《我的志愿者手记》分镜头脚本.png"
alt=""
class="titletext"
/>
</div>
<div class="doubao" @click="clickdoubao">
<img
src="@/assets/images/vlog/制作变焦镜/ai脚本视频占位图.png"
alt=""
class="doubao-img"
/>
</div>
<div class="shooting-return">
<img src="@/assets/images/shooting/返回.png" alt="" @click="back" />
</div>
</div>
<!-- <div v-else>
<div class="writing-title">
<img src="@/assets/images/vlog/标题栏-中间.png" alt="" class="titledibu" />
<img
src="@/assets/images/vlog/制作变焦镜/文字标题/《我的志愿者手记》分镜头脚本.png"
alt=""
class="titletext"
/>
</div>
<div class="biaogehome" @click="deletebiaoge()">
<img
src="@/assets/images/vlog/脚本切图/我的志愿者手记拍摄脚本.png"
alt=""
class="biaoge-img"
/>
<img src="@/assets/images/midterm/叉.png" alt="" class="biaoge-x" />
</div>
<div class="shooting-return">
<img src="@/assets/images/shooting/返回.png" alt="" @click="back" />
</div>
</div> -->
<!-- 引导步骤 -->
<GuideSteps
v-if="showDoubao"
:steps="steps"
:highlight-selector="currentHighlight"
:highlight-padding="12"
:highlight-radius="20"
@step-change="handleStepChange"
position-offset-y="85%"
>
<GuideStepsItem>
<img src="@/assets/images/components/引导/ai脚本工具引导.png" alt="" />
</GuideStepsItem>
</GuideSteps>
</div>
</div>
</template>
<style lang="less" scoped>
.writing-index {
width: 100%;
height: 100%;
}
.shooting-return {
position: absolute;
right: -90%;
top: 0;
width: 50%;
height: 50%;
}
.doubao .doubao-img {
width: 100%;
height: 100%;
}
.biaoge-x {
position: absolute;
top: -20px;
right: 0px;
width: 70px;
cursor: pointer;
z-index: 9999999;
}
.writing-title {
.titledibu {
position: absolute;
top: 17px;
left: 30%;
}
.titletext {
position: absolute;
top: 30px;
left: 40%;
}
}
.biaogehome {
position: absolute;
top: 165px;
left: 5%;
width: 94%;
}
.biaogehome .biaoge-img {
height: 896px !important;
width: 1800px;
display: block;
}
</style>
+61
View File
@@ -0,0 +1,61 @@
<template>
<div class="writing-index">
<!-- 背景图片 -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat writing-bg"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.SCRIPTING) }"
>
<div class="geshi-title">
<img src="@/assets/images/vlog/标题栏-中间.png" alt="" class="titledibu" />
<img
src="@/assets/images/vlog/制作变焦镜/文字标题/脚本的概念和基本要素.png"
alt=""
class="titletext"
/>
</div>
<video :src="fourth" controls></video>
<!-- <div class="shooting-return">
<img src="@/assets/images/shooting/返回.png" alt="" @click="back" />
</div> -->
<!-- 引导步骤 -->
<!-- <GuideSteps :steps="steps" :highlight-selector="currentHighlight" :highlight-padding="12"
:highlight-radius="20" @step-change="handleStepChange" position-offset-y="85%" hidden:false>
<GuideStepsItem>
<img src="@/assets/images/equipment/设备准备引导1.png" alt="" />
</GuideStepsItem>
</GuideSteps> -->
</div>
</div>
</template>
<script lang="tsx" setup>
import { getBackgroundImage, IMAGE_PATHS } from '@/utils'
import fourth from '/video/vlog_renwuqujing_jiaobencehua.mp4'
</script>
<style lang="less" scoped>
.writing-index {
width: 100%;
height: 100%;
}
.geshi-title {
.titledibu {
position: absolute;
top: 17px;
left: 30%;
}
.titletext {
position: absolute;
top: 30px;
left: 45%;
}
}
.shooting-return {
position: absolute;
right: -90%;
top: 0;
width: 100%;
height: 100%;
}
</style>
+49
View File
@@ -0,0 +1,49 @@
<script lang="ts" setup>
import { IMAGE_PATHS, getStaticImage } from '@/utils'
import { ImgButton } from '@/components/Button'
import { useRouter } from 'vue-router'
import { useScale } from '@/composables/useScale'
import { ImagePreview } from '@/components'
const { scale, offsetX, offsetY } = useScale()
const router = useRouter()
const back = () => {
router.back()
}
const handleOpenImages = () => {
ImagePreview({
images: [getStaticImage('film/分镜制作-拍摄分镜')],
startIndex: 0,
maskClosable: true,
getScaleMetrics: () => ({ scale: scale.value, offsetX: offsetX.value, offsetY: offsetY.value })
})
}
</script>
<template>
<div class="relative w-full h-full overflow-hidden">
<!-- 返回按钮 -->
<ImgButton
class="absolute right-0 top-0 z-20"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="back"
/>
<!-- 背景 -->
<img class="absolute inset-0" :src="getStaticImage('film/img/背景.png')" alt="" />
<!-- 标题 -->
<img class="absolute top-5 z-10" :src="getStaticImage('film/img/分镜制作-标题.png')" alt="" />
<!-- 内容 -->
<div class="relative h-full pt-28 overflow-y-scroll">
<img :src="getStaticImage('film/img/分镜制作.png')" alt="" />
<button
class="absolute top-[2510px] left-[calc(50%+22px)] -translate-x-1/2 w-[240px] h-[80px]"
@click="handleOpenImages"
/>
</div>
</div>
</template>
+316
View File
@@ -0,0 +1,316 @@
<script lang="ts" setup>
import { computed, nextTick, onMounted, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue'
import { GuideSteps, StepNavigation } from '@/components/Guide'
import { Video, QuestionQuiz, type Question } from '@/components'
import { getStaticImage, IMAGE_PATHS } from '@/utils'
import second from '/video/vlog_renwuqujing_jichujingbieyunyong.mp4'
import third from '/video/vlog_renwuqujing_huamiangoutu.mp4'
import fourth from '/video/vlog_renwuqujing_jiaobencehua.mp4'
import five from '/video/vlog_renwuqujing_zhuapai.mp4'
import six from '/video/vlog_renwuqujing_jianjipingjie.mp4'
import { useFilmStore } from '@/stores'
const router = useRouter()
const route = useRoute()
const film = useFilmStore()
// 受控播放开关(用于切换到下一个视频时自动播放)
const playing = ref(false)
const status = ref(false)
const showQuiz = ref(false) // 控制是否显示答题窗口
//引导步骤
const steps = ref([
{
id: 'step1',
content: '试着用这些方法记录身边的小事,你会发现,原来平凡的日子里有这么多值得捕捉的瞬间。'
}
])
// 步骤导航数据
const navigationSteps = ref([
{
id: 'step1',
label: '',
content: '遮幅',
type: 'shooting' as const
},
{
id: 'step2',
label: '',
content: '光线',
type: 'shooting' as const
},
{
id: 'step3',
label: '',
content: '虚实',
type: 'planning' as const
},
{
id: 'step4',
label: '',
content: '特写镜头',
type: 'shooting' as const
},
{
id: 'step5',
label: '',
content: '分镜头',
type: 'post' as const
},
{
id: 'step5',
label: '',
content: '滤镜',
type: 'post' as const
},
{
id: 'step5',
label: '',
content: '同期声',
type: 'post' as const
}
])
// 步骤导航相关状态
const showStepNavigation = ref(false)
const currentNavigationStep = ref(1) // 对应视频索引,从0开始
// 视频列表
const videoList = [second, third, fourth, five, six, five, six]
const currentVideoIndex = ref(0)
// 题目数据:虚实(index 2)和分镜头(index 4)
const questions = ref<Question[]>([])
// 根据当前视频索引获取对应的题目
const getQuestionsForVideo = (index: number): Question[] => {
// 虚实(index 2)
if (index === 2) {
return [
{
id: 1,
title: '交互题 1',
description: '下面两个画面,哪一个更具电影叙事感?',
options: [
{
id: 'a',
label: '前景、人物、背景全部清晰'
},
{
id: 'b',
label: '前景虚化,人物清晰,背景有层次'
}
],
correctOptionId: 'b',
explanation:
'电影感的画面,往往会主动模糊一部分信息。这是为了把观众的注意力,留给正在发生的情绪。'
}
]
}
// 分镜头(index 4)
if (index === 4) {
return [
{
id: 2,
title: '交互题2',
description: '下面哪一组镜头顺序,更符合电影叙事逻辑?',
options: [
{
id: 'a',
label: '特写→中景→ 全景'
},
{
id: 'b',
label: '全景→ 中景→ 特写'
}
],
correctOptionId: 'b',
explanation: '电影里的镜头, 往往是从环境进入人物,再进入情绪。'
}
]
}
return []
}
//按顺序播放
const currentVideo = computed(() => videoList[currentVideoIndex.value])
const resolvedSrc = computed(() => {
if (key.value === 'case') return '/video/anlishangxi_meishi_xiaoyuanzi.mp4'
if (key.value === 'profession') return currentVideo.value
return ''
})
const handleVideoChange = () => {
if (currentVideoIndex.value < videoList.length - 1) {
currentVideoIndex.value++
currentNavigationStep.value = currentVideoIndex.value
} else {
status.value = true
steps.value = [
{
id: 'step1',
content: '恭喜你,已完成专业剖析的学习,点击【查看脚本】回顾视频脚本吧!'
}
]
return
}
nextTick(() => {
// 使用受控播放来触发下一个视频自动播放
playing.value = true
})
}
const handleClose = () => {
router.back()
}
const image = computed(() => route.query.image as string)
const key = computed(() => route.query.key as string)
// const label = computed(() => route.query.label as string)
const handleClick = () => {}
const handlePlay = () => {
console.log(1)
}
const onEnded = () => {
if (key.value === 'profession') {
// 检查是否是"虚实"(index 2)或"分镜头"(index 4)
if (currentVideoIndex.value === 2 || currentVideoIndex.value === 4) {
// 暂停播放并显示答题窗口
playing.value = false
questions.value = getQuestionsForVideo(currentVideoIndex.value)
showQuiz.value = true
} else {
// 其他视频正常切换
handleVideoChange()
}
}
}
const handleComplete = () => {
if (key.value === 'profession') {
if (!film.isFilmUnlock) film.setIsFilmUnlock(true)
router.push({
name: 'FilmWork'
})
} else {
if (film.FilmUnlockProgress == 0) film.setFilmUnlockProgress(1)
router.push({
name: 'Film'
})
}
}
// 步骤导航事件处理
const handleStepClick = (index: number) => {
// 关闭答题窗口(如果打开)
showQuiz.value = false
currentVideoIndex.value = index
currentNavigationStep.value = index
nextTick(() => {
playing.value = true
})
}
const handleStepNavigationClose = () => {
showStepNavigation.value = false
}
// 答题完成后的处理
const handleQuestionsComplete = () => {
showQuiz.value = false
// 答题完成后继续播放下一个视频
handleVideoChange()
}
onMounted(() => {
if (key.value === 'idea') {
status.value = true
}
})
</script>
<template>
<div class="w-full h-full relative bg-white/10" @click="handleClick">
<template v-if="key !== 'case' && key !== 'profession'">
<ImgButton
class="absolute right-0 top-0 z-10"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleClose"
/>
</template>
<!-- 步骤导航按钮 -->
<button
v-if="key === 'profession'"
class="absolute right-4 top-1/2 z-10 -translate-y-1/2 w-12 h-12 bg-blue-500/80 hover:bg-blue-500 rounded-full flex items-center justify-center text-white transition-all duration-200"
@click="showStepNavigation = true"
title="步骤导航"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
d="M3 4H17M3 8H17M3 12H17M3 16H17"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</button>
<Video
v-if="key === 'case' || key === 'profession'"
:src="resolvedSrc"
v-model="playing"
:muted="false"
:volume="1"
:controls="true"
:hideNativeControls="true"
@play="handlePlay"
@ended="onEnded"
>
<template #overlay>
<ImgButton
class="absolute right-0 top-0 z-10 pointer-events-auto"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleClose"
/>
<QuestionQuiz
v-if="showQuiz && key === 'profession'"
:show-question-number="false"
:questions="questions"
@complete="handleQuestionsComplete"
/>
</template>
</Video>
<div v-else-if="key === 'idea'" :src="image">
<img :src="getStaticImage('film/思路提点全屏')" alt="" />
</div>
</div>
<!-- 引导与高亮 -->
<GuideSteps v-if="status" :steps="steps" position-offset-y="85%" @complete="handleComplete" />
<!-- 步骤导航抽屉 -->
<StepNavigation
:visible="showStepNavigation && key === 'profession'"
:background-image="getStaticImage('work/vlog背景全屏待定')"
:steps="navigationSteps"
:current-step-index="currentNavigationStep"
@step-click="handleStepClick"
@close="handleStepNavigationClose"
/>
</template>
<style scoped>
video {
width: 100%;
height: 100%;
/* 核心:保持视频比例,多余部分被裁剪 */
object-fit: cover;
}
</style>
@@ -0,0 +1,66 @@
<template>
<ImageSwitchList
v-model="innerValue"
:items="items"
direction="vertical"
:item-class="itemClass"
@change="onChange"
/>
</template>
<script setup lang="ts">
import { ImageSwitchList } from '@/components/Switcher'
import { computed } from 'vue'
type WorkTab = 'case' | 'profession' | 'idea'
interface Props {
modelValue?: WorkTab
itemClass?: string
}
interface Emits {
(e: 'update:modelValue', value: WorkTab): void
(e: 'change', value: WorkTab): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: 'case',
itemClass: 'w-[260px] h-[64px] rounded-[18px]'
})
const emit = defineEmits<Emits>()
const innerValue = computed({
get: () => props.modelValue as WorkTab,
set: (val: WorkTab) => emit('update:modelValue', val)
})
const items = [
// { key: 'vlog', activeImage: 'work/vlog初识-当前', inactiveImage: 'work/vlog初识-未选中' },
{
key: 'case',
label: '案例赏析',
activeImage: 'work/选中_带三角',
inactiveImage: 'work/未选中_带三角'
},
{
key: 'profession',
label: '专业剖析',
activeImage: 'work/选中_带三角',
inactiveImage: 'work/未选中_带三角'
},
{
key: 'idea',
label: '思路提点',
activeImage: 'work/选中_带三角',
inactiveImage: 'work/未选中_带三角'
}
]
const onChange = (val: string) => emit('change', val as WorkTab)
defineExpose({
items
})
</script>
+194
View File
@@ -0,0 +1,194 @@
<script lang="tsx" name="Work" setup>
defineOptions({ name: 'Work' })
import { ref, computed } from 'vue'
import { getBackgroundImage, IMAGE_PATHS, getStaticImage } from '@/utils'
import { ImgButton } from '@/components/Button'
import WorkSideSwitcher from './components/WorkSideSwitcher.vue'
import { GuideSteps } from '@/components/Guide'
import { useRouter } from 'vue-router'
import { useScale } from '@/composables/useScale'
import { ImagePreview } from '@/components'
import { useFilmStore } from '@/stores'
const router = useRouter()
const film = useFilmStore()
type WorkTab = 'case' | 'profession' | 'idea'
const selected = ref<WorkTab>('case')
const stepIndex = ref(0)
const workSideSwitcherRef = ref<InstanceType<typeof WorkSideSwitcher>>()
const isGuideFilmTask = computed(() => film.isGuideFilmTask)
const isFilmUnlock = computed(() => film.isFilmUnlock)
//引导步骤
const steps = [
{
id: 'step1',
content: '经常听到有人赞叹:“这个镜头真高级。”'
},
{ id: 'step2', content: '那么什么样的镜头才算是好镜头?又是什么让画面显得更高级?' }
]
//切换图片
const sideSwitcherItems = ref([
{
key: 'case',
name: '案例赏析',
description: '物件春秋-上海钟表文化科普馆',
image: 'film/案件赏析小屏'
},
{
key: 'profession',
name: '专业剖析',
description: '物件春秋-上海钟表文化科普馆',
image: 'film/案件赏析小屏'
},
{
key: 'idea',
name: '思路提点',
description: '物件春秋-上海钟表文化科普馆',
image: 'film/思路提点小屏'
}
])
//当前选中的item
const currentItem = computed(() => {
return sideSwitcherItems.value.find(item => item.key === selected.value)
})
const currentHighlight = computed(() =>
stepIndex.value === 0 ? '.img-switch-item--case' : '.work-border'
)
interface SimpleStep {
id: string
content: string
}
const handleStepChange = (_step: SimpleStep | null, index: number) => {
stepIndex.value = index
}
const handleSideSwitcherChange = (value: WorkTab) => {
if (selected.value === value) return
}
const handleImageClick = () => {
const currentItem = workSideSwitcherRef.value?.items.find(item => item.key === selected.value)
router.push({
name: 'FilmVideo',
query: {
image: currentItem?.activeImage || '',
key: selected.value,
label: currentItem?.label || ''
}
})
}
const handleClose = () => {
router.push({ name: 'Film' })
}
const handleWorkClick = () => {
if (!isFilmUnlock.value) return
openImages([getStaticImage('work/桂花酒酿小圆子')])
}
const handleGuideComplete = () => {
film.setIsGuideFilmTask(false)
}
const handleOpenGuide = () => {
stepIndex.value = 0
film.setIsGuideFilmTask(true)
}
const { scale, offsetX, offsetY } = useScale()
function openImages(urls: string[], startIndex = 0) {
ImagePreview({
images: urls,
startIndex,
maskClosable: true,
getScaleMetrics: () => ({ scale: scale.value, offsetX: offsetX.value, offsetY: offsetY.value })
})
}
</script>
<template>
<div class="w-full h-full relative">
<!-- 背景图片-center -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: getBackgroundImage('film/任务取景框背景') }"
></div>
<!-- 关闭 -->
<ImgButton
class="absolute right-0 top-0"
:image="IMAGE_PATHS.COMPONENTS.CLOSE"
@click="handleClose"
/>
<!-- border图片 -->
<div
class="work-border absolute top-[140px] left-24 inset-0 bg-cover bg-center bg-no-repeat z-10 w-[1360px] h-[800px] p-10"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.WORK.BORDER) }"
>
<div
class="flex items-center pl-16 w-[1357px] h-[265px] absolute -bottom-16 left-0 bg-cover bg-center bg-no-repeat z-20"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.WORK.TITLE) }"
>
<span
class="mt-24 text-white font-bold text-6xl [text-shadow:0_2px_4px_rgba(0,0,0,0.2),0_0_6px_rgba(0,207,255,0.9),0_0_14px_rgba(0,207,255,0.7)]"
>
{{ currentItem?.description }}</span
>
</div>
<img
@click="handleImageClick"
class="rounded-[62px] cursor-pointer"
:src="getStaticImage(currentItem?.image || '')"
alt=""
/>
</div>
<!-- 左侧选项切换 -->
<div class="absolute right-4 top-56 z-20">
<WorkSideSwitcher
ref="workSideSwitcherRef"
v-model="selected"
@change="handleSideSwitcherChange"
:item-class="'w-[359px] h-[96px]'"
/>
</div>
<!-- 任务取景按钮 -->
<ImgButton
class="absolute right-14 bottom-10"
:image="isFilmUnlock ? IMAGE_PATHS.WORK.TASK_BTN_OK : IMAGE_PATHS.WORK.TASK_BTN"
@click="handleWorkClick"
/>
<!-- 引导与高亮 -->
<GuideSteps
v-if="isGuideFilmTask"
:steps="steps"
:highlight-selector="currentHighlight"
:highlight-padding="12"
:highlight-radius="20"
@step-change="handleStepChange"
@complete="handleGuideComplete"
@skip="handleGuideComplete"
position-offset-y="85%"
/>
<!-- 引导词按钮 -->
<ImgButton
class="absolute w-[120px] bottom-0 right-0 z-10"
image="components/long"
@click="handleOpenGuide"
/>
</div>
</template>
-11
View File
@@ -1,11 +0,0 @@
<template>
<div class="guide-page w-full h-full relative"></div>
</template>
<script setup lang="ts"></script>
<style scoped>
.guide-page {
@apply bg-black;
}
</style>
+210 -16
View File
@@ -1,25 +1,29 @@
<script lang="ts" setup>
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
import { getBackgroundImage, IMAGE_PATHS, getStaticImage } from '@/utils'
import { ImgButton } from '@/components/Button'
import { ModuleSwitcher } from './components/ModuleSwitcher'
import { computed, ref, onMounted } from 'vue'
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores'
const router = useRouter()
const user = useUserStore()
// 响应式状态
const status = ref(false)
const guideShow = ref(false)
const selectedModule = ref<'vlog' | 'film' | 'interview'>('vlog')
const selectedModule = ref<'vlog' | 'film' | 'interview'>('film')
const isImageLoading = ref(false)
// 预加载图片映射
const imageMap = {
vlog: IMAGE_PATHS.ACTION.VIDEO1,
film: IMAGE_PATHS.ACTION.VIDEO3,
interview: IMAGE_PATHS.ACTION.VIDEO2
film: IMAGE_PATHS.ACTION.VIDEO2,
interview: IMAGE_PATHS.ACTION.VIDEO3
}
//获取新手导航引导状态
const isGuideNewUser = computed(() => user.isGuideNewUser)
// 预加载所有图片
const preloadImages = () => {
const imageUrls = Object.values(imageMap).map(path => getBackgroundImage(path))
@@ -36,7 +40,9 @@ const handleOpenGuide = () => {
const handleCloseGuide = () => {
guideShow.value = false
status.value = true
if (isGuideNewUser.value) {
user.setIsGuideNewUser(false)
}
}
const handleModuleChange = (module: 'vlog' | 'film' | 'interview') => {
@@ -52,6 +58,7 @@ const handleModuleChange = (module: 'vlog' | 'film' | 'interview') => {
}
const handleClick = () => {
console.log(selectedModule.value.charAt(0).toUpperCase() + selectedModule.value.slice(1))
router.push({
//首字母大写
name: selectedModule.value.charAt(0).toUpperCase() + selectedModule.value.slice(1)
@@ -69,10 +76,86 @@ const handleSkill = () => {
})
}
// 新手导航数据
const nodes = [
{
id: 1,
num: '1',
label: '三大基础要素',
x: 20,
y: 30,
hasChildren: true,
children: [
{ id: 1, label: '构图', icon: 'home/action/新手导航构图', routeName: 'Composition' },
{ id: 2, label: '光', icon: 'home/action/新手导航光', routeName: 'Light' },
{ id: 3, label: '运镜', icon: 'home/action/新手导航运镜', routeName: 'CameraMovement' }
]
},
{
id: 2,
num: '2',
label: '基础拍摄技巧',
x: 50,
y: 60,
hasChildren: false,
routeName: 'BasicShootingSkills'
},
{
id: 3,
num: '3',
label: '短视频剪辑技巧',
x: 80,
y: 30,
hasChildren: false,
routeName: 'ShortVideoEditingSkills'
}
]
// 显示子节点的节点ID
const showChildrenNodeId = ref<number | null>(null)
// 处理节点点击
const handleNodeClick = (node: any, event?: MouseEvent) => {
if (event) {
event.stopPropagation()
}
if (node.hasChildren) {
// 如果点击的是有子节点的节点,切换显示状态
if (showChildrenNodeId.value === node.id) {
showChildrenNodeId.value = null
} else {
showChildrenNodeId.value = node.id
}
} else {
// 如果点击的是没有子节点的节点,直接跳转
if (node.routeName) {
router.push({ name: node.routeName })
}
}
}
// 处理子节点点击
const handleChildNodeClick = (childNode: any, event: MouseEvent) => {
event.stopPropagation()
if (childNode.routeName) {
router.push({ name: childNode.routeName })
}
}
// 点击外部关闭子节点
const handleClickOutside = () => {
showChildrenNodeId.value = null
}
// 生命周期
onMounted(() => {
preloadImages()
})
onUnmounted(() => {
// 清理工作
})
</script>
<template>
@@ -98,14 +181,14 @@ onMounted(() => {
<!-- 技能树 按钮 -->
<ImgButton
v-if="status"
v-if="!isGuideNewUser"
@click="handleSkill"
:image="IMAGE_PATHS.ACTION.SKILLS_BTN"
class="absolute bottom-0 right-0 z-30"
/>
<!-- 新手指导遮罩 -->
<div v-if="!status" class="absolute inset-0 bg-black/50 z-10"></div>
<div v-if="isGuideNewUser" class="absolute inset-0 bg-black/50 z-10"></div>
<!-- 新手指导 -->
<div
@@ -120,20 +203,131 @@ onMounted(() => {
</div>
<div
class="cursor-pointer absolute bottom-0 left-0 w-full h-[911px] bg-cover bg-center bg-no-repeat z-40"
class="absolute bottom-0 left-0 w-full h-[911px] bg-cover bg-center bg-no-repeat z-40 flex flex-col items-center justify-center pt-40"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.ACTION.FOOTER_BG) }"
v-else
>
<ImgButton
:image="IMAGE_PATHS.ACTION.DOWN"
class="absolute top-60 right-1/2 translate-x-1/2"
class="absolute top-60 right-1/2 translate-x-1/2 z-50"
@click="handleCloseGuide"
/>
<img
class="absolute bottom-0 left-1/2 -translate-x-1/2"
:src="getStaticImage('home/新手导航')"
alt=""
/>
<!-- 新手导航内容 -->
<div class="relative w-[880px] h-[400px] mt-20">
<div class="w-full h-full relative" @click="handleClickOutside">
<!-- 标题区域 -->
<div class="text-center mt-10 mb-8">
<h2 class="text-5xl font-bold mb-3 text-[#7bfffd] tracking-wider drop-shadow-lg">
新手导航
</h2>
<p class="text-4xl text-white font-bold drop-shadow-md">点击节点即可开始学习</p>
</div>
<!-- 节点图表 -->
<div class="absolute inset-0 top-[60px]">
<!-- 连线 (SVG) -->
<svg class="absolute inset-0 w-full h-full pointer-events-none">
<!-- 左侧装饰线 -> 节点1 -->
<line
x1="0%"
y1="45%"
:x2="nodes[0].x + '%'"
:y2="nodes[0].y + '%'"
stroke="#7bfffd"
stroke-width="5"
stroke-linecap="round"
/>
<!-- 节点1 -> 节点2 -->
<line
:x1="nodes[0].x + '%'"
:y1="nodes[0].y + '%'"
:x2="nodes[1].x + '%'"
:y2="nodes[1].y + '%'"
stroke="#7bfffd"
stroke-width="5"
stroke-linecap="round"
/>
<!-- 节点2 -> 节点3 -->
<line
:x1="nodes[1].x + '%'"
:y1="nodes[1].y + '%'"
:x2="nodes[2].x + '%'"
:y2="nodes[2].y + '%'"
stroke="#7bfffd"
stroke-width="5"
stroke-linecap="round"
/>
<!-- 节点3 -> 右侧装饰线 -->
<line
:x1="nodes[2].x + '%'"
:y1="nodes[2].y + '%'"
x2="100%"
y2="45%"
stroke="#7bfffd"
stroke-width="5"
stroke-linecap="round"
/>
</svg>
<!-- 节点 -->
<div
v-for="node in nodes"
:key="node.id"
class="absolute transform -translate-x-1/2 -translate-y-1/2"
:style="{ left: node.x + '%', top: node.y + '%' }"
@click="handleNodeClick(node, $event)"
>
<div
class="cursor-pointer flex flex-col items-center group transition-all duration-300 hover:scale-110"
>
<!-- 圆圈 -->
<div
class="w-24 h-24 rounded-full flex items-center justify-center text-5xl font-bold text-white z-10 relative transition-all duration-300 group-hover:bg-[#ff4d8c]"
:class="
showChildrenNodeId === node.id
? 'bg-[#ff4d8c] shadow-[0_0_20px_rgba(255,77,140,0.6)]'
: 'bg-[#60c8db] shadow-[0_0_20px_rgba(96, 200, 219,0.6)]'
"
>
{{ node.num }}
<!-- 选中状态的光晕 -->
<div
v-if="showChildrenNodeId === node.id"
class="absolute inset-0 rounded-full border-4 border-white/20 animate-pulse"
></div>
</div>
<!-- 标签 -->
<div
class="mt-2 text-4xl font-bold text-white tracking-wider drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)] whitespace-nowrap"
>
{{ node.label }}
</div>
</div>
<!-- 子节点图标(当点击三大基础要素时显示) -->
<div
v-if="node.hasChildren && showChildrenNodeId === node.id"
class="absolute -top-40 left-1/2 transform -translate-x-1/2 flex z-20"
>
<div
v-for="childNode in node.children"
:key="childNode.id"
class="flex flex-col items-center cursor-pointer"
@click.stop="handleChildNodeClick(childNode, $event)"
>
<!-- 图标 -->
<div
class="w-36 flex items-center justify-center transition-all duration-300 hover:scale-110"
>
<img :src="getStaticImage(childNode.icon)" :alt="childNode.label" />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -67,7 +67,7 @@ const moduleConfigs: ModuleConfig[] = [
}
},
{
type: 'film',
type: 'interview',
normalImage: IMAGE_PATHS.ACTION.MODULE_BTN2,
selectedImage: IMAGE_PATHS.ACTION.SELECTED_BTN2,
className: 'w-[1087px] h-[822px] left-[calc(50%+50px)] -translate-x-1/2 -bottom-4',
@@ -86,7 +86,7 @@ const moduleConfigs: ModuleConfig[] = [
}
},
{
type: 'interview',
type: 'film',
normalImage: IMAGE_PATHS.ACTION.MODULE_BTN3,
selectedImage: IMAGE_PATHS.ACTION.SELECTED_BTN3,
className: 'w-[943px] h-[822px] right-[320px] -bottom-4',
@@ -7,7 +7,7 @@ import { IMAGE_PATHS } from '@/utils'
const router = useRouter()
// 导入视频文件
import videoadjustmentColor from '/video/Vlog模块_制作变焦镜_后期剪辑_调色.mp4'
import videoadjustmentColor from '/video/vlog_renwuqujing_tiaose.mp4'
const videoList = [videoadjustmentColor]
const videoRef = ref<HTMLVideoElement>()
+304
View File
@@ -0,0 +1,304 @@
<script lang="ts" setup>
import router from '@/router'
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
import { ImgButton } from '@/components/Button'
import { Overlay } from '@/components/Overlay'
import { ref } from 'vue'
import ImageFilter from '@/components/ImageFilter/ImageFilter.vue'
import { useInterviewStore } from '@/stores'
const selected = ref<number | undefined>(1)
const btnState = ref(false)
const screenshot = ref()
const showModal = ref(false)
const activeTab = ref<'color' | 'filter'>('color') // 'color' | 'filter'
const adjustmentList = ref([
{ name: '亮度', value: 0, min: -100, max: 100 },
{ name: '对比度', value: 0, min: -100, max: 100 },
{ name: '饱和度', value: 0, min: -100, max: 100 },
{ name: '曝光', value: 0, min: -100, max: 100 },
{ name: '色调', value: 0, min: 0, max: 100 },
{ name: '锐化', value: 0, min: 0, max: 100 },
{ name: '模糊', value: 0, min: 0, max: 100 }
])
const colorBarList = ref([
{ id: 1, name: '原图', filter: 'none' },
{ id: 2, name: '暖阳', filter: 'sepia(0.3) contrast(1.1) brightness(1.1)' },
{ id: 3, name: '冷调', filter: 'hue-rotate(180deg) contrast(0.9)' },
{ id: 4, name: '复古', filter: 'sepia(0.6) contrast(1.2) brightness(0.9)' },
{ id: 5, name: '黑白', filter: 'grayscale(1)' },
{ id: 6, name: '鲜艳', filter: 'saturate(1.5) contrast(1.1)' }
])
// 获取指定参数的值
// 获取指定参数的值
const imageFilterRef = ref<InstanceType<typeof ImageFilter> | null>(null)
// 获取指定参数的值
const getVal = (name: string) => {
const val = adjustmentList.value.find(item => item.name === name)?.value || 0
return Number(val)
}
// 更新滤镜效果
const updateFilter = () => {
if (!imageFilterRef.value) return
// 如果开启了对比模式(显示原图),则不应用任何滤镜
if (btnState.value) {
imageFilterRef.value.setOptions({
brightness: 0,
contrast: 0,
saturation: 0,
exposure: 0,
hue: 0,
blur: 0,
sharpen: 0,
filter: 'none'
})
return
}
const selectedFilter = colorBarList.value.find(v => v.id === selected.value)?.filter || 'none'
imageFilterRef.value.setOptions({
brightness: getVal('亮度'),
contrast: getVal('对比度'),
saturation: getVal('饱和度'),
exposure: getVal('曝光'),
hue: getVal('色调'),
blur: getVal('模糊'),
sharpen: getVal('锐化'),
filter: selectedFilter
})
}
const changeOriginalImgae = () => {
btnState.value = !btnState.value
updateFilter()
}
// Tab 切换处理
const handleTabChange = (tab: 'color' | 'filter') => {
activeTab.value = tab
// 切换时重置所有参数
handleReset()
}
// 重置所有参数
const handleReset = () => {
adjustmentList.value.forEach(item => (item.value = 0))
selected.value = 1
updateFilter()
}
// 生成图片
const handleGenerate = async () => {
if (!imageFilterRef.value) return
try {
const dataUrl = await imageFilterRef.value.generate()
screenshot.value = dataUrl
showModal.value = true
} catch (error) {
console.error('生成图片失败:', error)
}
}
const handleModalClose = () => {
showModal.value = false
screenshot.value = null
}
const handleTryOther = () => {
showModal.value = false
}
const handleFinish = () => {
useInterviewStore().setInterviewUnlockProgress(4)
router.push({
name: 'Interview'
})
}
const handleClose = () => {
router.back()
}
const changeColor = (item: { id: number; name: string }) => {
selected.value = item.id
updateFilter()
}
</script>
<template>
<div class="w-full h-full relative pt-44">
<!-- 返回 -->
<ImgButton
class="absolute right-0 top-0 z-10"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleClose"
/>
<!-- 背景图片-center -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat -z-10"
:style="{
backgroundImage: getBackgroundImage('after-editing/color/底图')
}"
></div>
<div class="flex h-[759px] justify-center space-x-12">
<!-- 效果图 -->
<div
class="relative w-[1273px] h-[732px] bg-no-repeat bg-cover mt-10 p-2 pb-[26px] pr-[26px]"
:style="{ backgroundImage: getBackgroundImage('after-editing/color/图片框.png') }"
>
<ImageFilter ref="imageFilterRef" :src="getStaticImage('after-editing/color/图片')" />
<!-- 开启对比 -->
<ImgButton
class="absolute top-2 right-6 flex-[0_0_200px]"
@click="changeOriginalImgae"
:image="
btnState
? 'after-editing/color/开启对比 - 可开启'
: 'after-editing/color/开启对比 - 不可开启'
"
></ImgButton>
</div>
<!-- 滤镜/参数配置 -->
<div>
<div
class="w-[445px] h-[759px] bg-no-repeat bg-cover p-2 rounded-xl"
:style="{ backgroundImage: getBackgroundImage('after-editing/color/小底板') }"
>
<!-- tab切换 -->
<header class="flex h-[75px] items-center justify-center space-x-4">
<div @click="handleTabChange('color')">
<ImgButton
:image="
activeTab !== 'color'
? 'after-editing/color/调色-暗'
: 'after-editing/color/调色-亮'
"
/>
</div>
<div @click="handleTabChange('filter')">
<ImgButton
:image="
activeTab !== 'filter'
? 'after-editing/color/滤镜-暗'
: 'after-editing/color/滤镜-亮'
"
/>
</div>
</header>
<!-- tab-content -->
<div class="px-4 h-[calc(100%-75px)]">
<!-- 调色内容 -->
<div
v-show="activeTab === 'color'"
class="space-y-6 h-full overflow-y-auto custom-scrollbar pt-4"
>
<div v-for="item in adjustmentList" :key="item.name" class="px-2">
<div class="flex justify-between text-[#648ab2] text-2xl font-bold mb-2">
<span>{{ item.name }}</span>
<span>{{ item.value }}</span>
</div>
<input
type="range"
v-model.number="item.value"
:min="item.min"
:max="item.max"
@change="updateFilter"
class="w-full h-3 bg-[#b8cfe7] rounded-lg appearance-none cursor-pointer slider-thumb"
/>
</div>
</div>
<!-- 滤镜内容 -->
<div
v-show="activeTab === 'filter'"
class="space-y-10 h-full overflow-y-auto custom-scrollbar pt-10"
>
<div
v-for="item in colorBarList"
:key="item.id"
:style="{
backgroundImage:
selected == item.id
? getBackgroundImage('after-editing/color/滤镜-选中')
: getBackgroundImage('after-editing/color/滤镜-未选中')
}"
:class="[
'mx-auto w-[373px] h-[62px] text-white text-2xl font-bold flex items-center justify-center cursor-pointer bg-no-repeat bg-contain bg-center transition-all hover:scale-105',
selected !== item.id && 'text-gray-700'
]"
@click="changeColor(item)"
>
<span>{{ item.name }}</span>
</div>
</div>
</div>
</div>
<div class="flex justify-around mt-4">
<!-- 重置 -->
<ImgButton image="after-editing/color/重置" @click="handleReset" />
<!-- 生成 -->
<ImgButton image="after-editing/color/生成" @click="handleGenerate" />
</div>
</div>
</div>
<!-- title -->
<img class="absolute top-0 left-0" :src="getStaticImage('after-editing/color/标题-调色实践')" />
<!-- 保存图片弹窗 -->
<Overlay :visible="showModal" @close="handleModalClose">
<div class="relative w-[1000px] h-[600px] flex items-center justify-center">
<!-- 截图内容 -->
<div class="bg-white p-2 rounded-md -rotate-6">
<img :src="screenshot" alt="调色结果" />
</div>
<!-- <img :src="screenshot" alt="调色结果" class="max-w-full max-h-full object-contain rotate-2" /> -->
<!-- 操作按钮 -->
<div class="absolute bottom-1/2 -left-1/3 translate-y-1/2">
<ImgButton image="after-editing/color/按钮-试试其他调色" @click="handleTryOther" />
</div>
<div class="absolute bottom-1/2 -right-1/3 translate-y-1/2">
<ImgButton image="after-editing/color/按钮-结束调色实践" @click="handleFinish" />
</div>
</div>
</Overlay>
</div>
</template>
<style scoped>
/* 自定义滑块样式 */
.slider-thumb::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 34px;
height: 34px;
background: #92c6f2;
border: 3px solid #ffffff;
border-radius: 4px;
cursor: pointer;
box-shadow: 4px 4px 2px rgba(0, 0, 0, 0.4);
}
.slider-thumb::-moz-range-thumb {
width: 34px;
height: 34px;
background: #92c6f2;
border: 3px solid #ffffff;
border-radius: 4px;
cursor: pointer;
box-shadow: 4px 4px 2px rgba(0, 0, 0, 0.4);
}
</style>
@@ -7,10 +7,10 @@ import { IMAGE_PATHS } from '@/utils'
const router = useRouter()
// 导入视频文件
import videoBasic from '/video/Vlog模块_制作变焦镜_后期剪辑_基础剪辑操作.mp4'
import videoSoundeffect from '/video/Vlog模块_制作变焦镜_后期剪辑_音效处理.mp4'
import videoPackaging from '/video/Vlog模块_制作变焦镜_后期剪辑_包装(AI 字幕生成与样式调整).mp4'
import videoCopyright from '/video/Vlog模块_制作变焦镜_后期剪辑_版权伦理规范.mp4'
import videoBasic from '/video/vlog_zhizuobianjiaojing_jichujianji.mp4'
import videoSoundeffect from '/video/vlog_renwuqujing_yingxiaochuli.mp4'
import videoPackaging from '/video/vlog_renwuqujing_baozhuang.mp4'
import videoCopyright from '/video/vlog_renwuqujing_banquanguifan.mp4'
const videoList = [videoBasic, videoSoundeffect, videoPackaging, videoCopyright]
const videoRef = ref<HTMLVideoElement>()
+171
View File
@@ -0,0 +1,171 @@
<script lang="tsx" setup>
import { getStaticImage } from '@/utils'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { getBackgroundImage } from '@/utils'
defineOptions({ name: 'VlogMenu' })
/**
* 菜单项 (前期准备、中期拍摄、后期剪辑)
* 前期准备-资源筹备、创意构想、脚本撰写、分镜制作
* 中期拍摄-设备准备、实地取景、现场拍摄
* 后期剪辑-素材管理、剪辑、调色、导出发布
*/
const category = ref([
{
label: '前期准备',
value: 1,
icon: getStaticImage('midterm/准备图标.png')
},
{
label: '中期拍摄',
value: 2,
icon: getStaticImage('midterm/拍摄图标.png')
},
{
label: '后期剪辑',
value: 3,
icon: getStaticImage('midterm/剪辑图标.png')
}
])
const menuItems = ref([
{
label: '资源筹备',
routeName: 'InterviewResource',
parentValue: 1,
disabled: true
},
{
label: '创意构想',
routeName: 'InterviewCreative',
parentValue: 1,
disabled: true
},
{
label: '脚本撰写',
routeName: 'InterviewScriptWriting',
parentValue: 1,
disabled: false
},
{
label: '分镜制作',
routeName: 'InterviewStoryboard',
parentValue: 1,
disabled: true
},
{
label: '设备准备',
routeName: 'InterviewMidtermEquipment',
parentValue: 2,
disabled: false
},
{
label: '实地取景',
routeName: 'InterviewMidtermShooting',
parentValue: 2,
disabled: false
},
{
label: '现场拍摄',
routeName: 'Interview现场拍摄',
parentValue: 2,
disabled: true
},
{
label: '素材管理',
routeName: 'Interview素材管理',
parentValue: 3,
disabled: true
},
{
label: '剪辑',
routeName: 'InterviewEditing',
parentValue: 3,
disabled: false
},
{
label: '调色',
routeName: 'InterviewColor',
parentValue: 3,
disabled: false
},
{
label: '导出发布',
routeName: 'Interview导出发布',
parentValue: 3,
disabled: true
}
])
const props = defineProps<{
currentStep: number
}>()
const router = useRouter()
const currentParentValue = computed<number>(() => {
return props.currentStep
}) // 当前选中的父级菜单项
const currentItemValue = ref<string>('') // 当前选中的子级菜单项
const getMenuItems = computed(() => {
return menuItems.value.filter(item => item.parentValue === currentParentValue.value)
})
const handleItemClick = (item: { routeName: string; disabled: boolean }) => {
if (item.disabled) return
currentItemValue.value = item.routeName
router.push({ name: item.routeName })
}
</script>
<template>
<div class="absolute left-20 top-0 bottom-0 w-[520px] pt-28" v-if="currentStep">
<!-- 分类名称 -->
<div class="absolute top-3 left-24 flex items-center space-x-2">
<span class="text-4xl text-[#fbbf24] font-black">{{
category[currentParentValue - 1].label
}}</span>
<img class="filter-blue" :src="category[currentParentValue - 1].icon" alt="" />
</div>
<!-- 背景图片 -->
<div
class="absolute inset-0 bg-cover bg-left bg-no-repeat -z-10"
:style="{ backgroundImage: getBackgroundImage('components/menu-bg.png') }"
></div>
<div class="flex items-center flex-col pl-[60px]">
<template v-for="(item, index) in getMenuItems" :key="item.routeName">
<div
class="relative w-[373px] h-[143px] pl-14 flex items-center justify-center group text-4xl font-black transition-colors"
:class="[
currentItemValue === item.routeName ? 'text-blue-400' : 'text-white',
item.disabled ? 'opacity-20 cursor-not-allowed' : 'cursor-pointer'
]"
:style="{ marginLeft: -index * 40 + 'px' }"
@click="handleItemClick(item)"
>
<div
v-if="!item.disabled"
class="absolute inset-0 bg-cover bg-center bg-no-repeat opacity-0 group-hover:opacity-100 transition-opacity duration-300"
:style="{ backgroundImage: getBackgroundImage('components/menu-hover.png') }"
></div>
<span :class="[!item.disabled ? 'menu-glow-text' : '']">{{ item.label }}</span>
</div>
</template>
</div>
</div>
</template>
<style scoped>
.filter-blue {
filter: brightness(0) saturate(100%) invert(79%) sepia(29%) saturate(1190%) hue-rotate(330deg)
brightness(102%) contrast(98%);
}
.menu-glow-text {
text-shadow:
0 0 40px rgba(0, 180, 255, 0.9),
0 0 20px rgba(0, 180, 255, 0.7);
}
</style>
+111
View File
@@ -0,0 +1,111 @@
<script lang="tsx" setup>
import { getBackgroundImage, IMAGE_PATHS } from '@/utils'
import { ImgButton } from '@/components/Button'
import { onMounted, ref } from 'vue'
import { ProgressBar, Overlay } from '@/components'
import { useRouter } from 'vue-router'
import { useInterviewStore } from '@/stores'
import InterviewMenu from './components/menu.vue'
const router = useRouter()
const interview = useInterviewStore()
const visible = ref(false)
const currentStep = ref(interview.InterviewUnlockProgress)
const handleComplete = () => {
// console.log('完成前置条件')
// 处理完成逻辑
router.push({ name: 'InterviewPractice' })
}
const handleStepChange = (step: number) => {
// 处理步骤变化逻辑
currentStep.value = step
visible.value = true
}
const handleWorkClick = () => {
console.log('任务取景按钮点击')
// 处理任务取景按钮点击逻辑
router.push({ name: 'InterviewWork' })
}
const handleClose = () => {
router.push({ name: 'Action' })
}
onMounted(() => {
if (currentStep.value !== 0 && currentStep.value !== 4) {
visible.value = true
}
})
</script>
<template>
<div class="w-full h-full relative">
<!-- 背景图片-center -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.BG_CENTER) }"
></div>
<!-- 关闭 -->
<ImgButton
class="absolute right-0 top-0 z-20"
:image="IMAGE_PATHS.COMPONENTS.CLOSE"
@click="handleClose"
/>
<!-- 背景图片 -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.BG) }"
></div>
<!-- 标题 -->
<div class="absolute top-2 left-1/2 -translate-x-1/2">
<ImgButton :image="IMAGE_PATHS.VLOG.TITLE" />
</div>
<!-- 任务取景按钮 -->
<div
class="absolute left-24 top-24"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.TASK_CAMERA) }"
>
<ImgButton
:image="currentStep === 4 ? IMAGE_PATHS.VLOG.TASK_BTN_OK : IMAGE_PATHS.VLOG.TASK_BTN"
@click="handleWorkClick"
/>
</div>
<!-- 引导任务 -->
<ImgButton class="absolute right-24 bottom-24" :image="IMAGE_PATHS.VLOG.TASK_PERSON" />
<Overlay content-class="w-full h-full" :visible="visible && currentStep < 4">
<!-- 关闭按钮 -->
<ImgButton
class="absolute right-0 top-0 z-20"
:image="IMAGE_PATHS.COMPONENTS.CLOSE"
@click="visible = false"
/>
<!-- 菜单 -->
<InterviewMenu :current-step="currentStep" />
<!-- 进度条 -->
<div class="absolute left-0 bottom-0 w-full">
<ProgressBar :current-step="currentStep" @step-change="handleStepChange" />
</div>
</Overlay>
<div class="absolute bottom-0 w-full">
<ProgressBar
:current-step="currentStep"
@complete="handleComplete"
@step-change="handleStepChange"
/>
</div>
</div>
</template>
@@ -1,3 +1,89 @@
<script lang="tsx" setup>
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button'
const router = useRouter()
const back = () => {
// 跳转设备准备
router.back()
}
const steps = [
{ id: 'step1', content: '先认识 Vlog:点击右侧「1.vlog初识」继续' },
{ id: 'step2', content: '这是主要内容区域的边框' }
]
const stepIndex = ref(0)
const hiddenbar = ref(false)
const currentIndex = ref(0) // 当前显示的图片索引
const selectedMenuIndex = ref(0) // 当前选中的菜单项索引
// 菜单项数据
const menuItems = ref([
{ id: 'phone', name: '手机', disabled: false },
{ id: 'phone-stand', name: '手机支架', disabled: true },
{ id: 'bluetooth-remote', name: '蓝牙遥控器', disabled: true },
{ id: 'stabilizer', name: '稳定器', disabled: true },
{ id: 'props', name: '常用道具', disabled: true },
{ id: 'phone-lens', name: '手机辅助箱头', disabled: true }
])
const phoneList = ref([
{ phoneImg: getStaticImage('equipment/设备-手机1.png') },
{ phoneImg: getStaticImage('equipment/设备-手机2.png') }
])
const platetitleList = ref([
{ title: getStaticImage('equipment/标题-苹果.png') },
{ title: getStaticImage('equipment/标题-安卓小米.png') }
])
const platetextList = ref([
{ text: getStaticImage('equipment/内容-苹果.png') },
{ text: getStaticImage('equipment/内容-安卓小米.png') }
])
const hiddenbarclick = () => {
hiddenbar.value = !hiddenbar.value
}
// 选择菜单项
const selectMenuItem = (index: number, item: { id: string; name: string; disabled: boolean }) => {
if (item.disabled) {
console.log(`菜单项 "${item.name}" 已被禁用`)
return
}
selectedMenuIndex.value = index
}
// 下一张图片
const nextPhone = () => {
if (currentIndex.value < phoneList.value.length - 1) {
currentIndex.value++
}
}
// 上一张图片
const prevPhone = () => {
if (currentIndex.value > 0) {
currentIndex.value--
}
}
interface SimpleStep {
id: string
content: string
}
const handleStepChange = (_step: SimpleStep | null, index: number) => {
stepIndex.value = index
}
const currentHighlight = computed(() =>
stepIndex.value === 0 ? '.img-switch-item--vlog' : '.work-border'
)
</script>
<template>
<div class="equipment-index">
<!-- 背景图片 -->
@@ -83,10 +169,8 @@
</div>
</div>
</div>
<div class="absolute right-0 top-0">
<img src="@/assets/images/shooting/返回.png" alt="" @click="back" />
</div>
<ImgButton class="absolute right-0 top-0" :image="IMAGE_PATHS.COMPONENTS.BACK" @click="back" />
<!-- 调试按钮组 -->
<!-- <div class="debug-controls">
<button @click="enableAllMenuItems" class="debug-btn">启用全部</button>
@@ -109,134 +193,6 @@
</GuideSteps>
</div>
</template>
<script lang="tsx" setup>
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const back = () => {
// 跳转设备准备
router.back()
}
const steps = [
{ id: 'step1', content: '先认识 Vlog:点击右侧「1.vlog初识」继续' },
{ id: 'step2', content: '这是主要内容区域的边框' }
]
const stepIndex = ref(0)
const hiddenbar = ref(false)
const currentIndex = ref(0) // 当前显示的图片索引
const selectedMenuIndex = ref(0) // 当前选中的菜单项索引
// 菜单项数据
const menuItems = ref([
{ id: 'phone', name: '手机', disabled: false },
{ id: 'phone-stand', name: '手机支架', disabled: true },
{ id: 'bluetooth-remote', name: '蓝牙遥控器', disabled: true },
{ id: 'stabilizer', name: '稳定器', disabled: true },
{ id: 'props', name: '常用道具', disabled: true },
{ id: 'phone-lens', name: '手机辅助箱头', disabled: true }
])
const phoneList = ref([
{ phoneImg: getStaticImage('equipment/设备-手机1.png') },
{ phoneImg: getStaticImage('equipment/设备-手机2.png') }
])
const platetitleList = ref([
{ title: getStaticImage('equipment/标题-苹果.png') },
{ title: getStaticImage('equipment/标题-安卓小米.png') }
])
const platetextList = ref([
{ text: getStaticImage('equipment/内容-苹果.png') },
{ text: getStaticImage('equipment/内容-安卓小米.png') }
])
const hiddenbarclick = () => {
hiddenbar.value = !hiddenbar.value
}
// 选择菜单项
const selectMenuItem = (index: number, item: { id: string; name: string; disabled: boolean }) => {
if (item.disabled) {
console.log(`菜单项 "${item.name}" 已被禁用`)
return
}
selectedMenuIndex.value = index
}
// 启用菜单项
const enableMenuItem = (id: string) => {
const item = menuItems.value.find(item => item.id === id)
if (item) {
item.disabled = false
console.log(`菜单项 "${item.name}" 已启用`)
}
}
// 禁用菜单项
const disableMenuItem = (id: string) => {
const item = menuItems.value.find(item => item.id === id)
if (item) {
item.disabled = true
console.log(`菜单项 "${item.name}" 已禁用`)
}
}
// 切换菜单项状态
const toggleMenuItem = (id: string) => {
const item = menuItems.value.find(item => item.id === id)
if (item) {
item.disabled = !item.disabled
console.log(`菜单项 "${item.name}" 状态已切换为: ${item.disabled ? '禁用' : '启用'}`)
}
}
// 启用所有菜单项
const enableAllMenuItems = () => {
menuItems.value.forEach(item => {
item.disabled = false
})
console.log('所有菜单项已启用')
}
// 禁用所有菜单项
const disableAllMenuItems = () => {
menuItems.value.forEach(item => {
item.disabled = true
})
console.log('所有菜单项已禁用')
}
// 下一张图片
const nextPhone = () => {
if (currentIndex.value < phoneList.value.length - 1) {
currentIndex.value++
}
}
// 上一张图片
const prevPhone = () => {
if (currentIndex.value > 0) {
currentIndex.value--
}
}
interface SimpleStep {
id: string
content: string
}
const handleStepChange = (_step: SimpleStep | null, index: number) => {
stepIndex.value = index
}
const currentHighlight = computed(() =>
stepIndex.value === 0 ? '.img-switch-item--vlog' : '.work-border'
)
</script>
<style lang="less" scoped>
.equipment-index {
position: relative;
@@ -1,39 +1,40 @@
<script lang="tsx" setup>
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
import { ImgButton } from '@/components/Button'
import { onMounted, ref } from 'vue'
import { ref } from 'vue'
import { ProgressBar } from '@/components'
import { useRoute, useRouter } from 'vue-router'
import { useRouter } from 'vue-router'
const router = useRouter()
const route = useRoute()
// 当前步骤
const currentStep = ref(3)
const highlightShow = ref(true)
const currentStep = ref(2)
const handleComplete = () => {
console.log('完成前置条件')
window.open('http://101.132.152.157/dspdr/vlog.html', '_self')
// 处理完成逻辑
}
const handleStepChange = (step: number) => {
console.log('步骤变化:', step)
// 处理步骤变化逻辑
}
const handleWorkClick = () => {
console.log('任务取景按钮点击')
// 处理任务取景按钮点击逻辑
router.push({ name: 'Work' })
}
const navigateTo = (routeName: string) => {
console.log('任务取景按钮点击')
router.push({ name: routeName })
const handleClick = () => {
// 跳转设备准备
router.push({ name: 'equipment' })
}
onMounted(() => {
// 页面加载时的逻辑
currentStep.value = Number(route.query.step) || 3
})
const shooting = () => {
// 跳转实地取景
router.push({ name: 'shooting' })
}
const highlightShow = ref(true)
</script>
<template>
@@ -62,12 +63,10 @@ onMounted(() => {
>
<ImgButton :image="IMAGE_PATHS.VLOG.TASK_BTN" @click="handleWorkClick" />
</div>
<!-- 中期拍摄底板 -->
<div v-if="highlightShow" class="absolute right-90 ml-[135px]" style="z-index: 998">
<img :src="getStaticImage(IMAGE_PATHS.AFTEREDITING.MIDTERM)" />
<img :src="getStaticImage(IMAGE_PATHS.VLOG.Midterm)" />
</div>
<!-- 进度百分比 -->
<div
v-if="highlightShow"
@@ -80,33 +79,27 @@ onMounted(() => {
<ImgButton :image="IMAGE_PATHS.VLOG.Percentage" />
</div>
</div>
<!-- 后期准备按钮文字 -->
<!-- 中期准备按钮文字 -->
<div
v-if="highlightShow"
class="absolute left-[21%] top-[9%] -translate-x-1/2 space-y-[-30px]"
style="z-index: 998; margin-top: 120px"
class="absolute left-[21%] top-[9%] -translate-x-1/2"
style="z-index: 998"
>
<ImgButton
:image="IMAGE_PATHS.AFTEREDITING.EDITING"
@click="navigateTo('editing')"
:image="IMAGE_PATHS.VLOG.MidtermEquipment"
@click="handleClick"
class="ml-[60px]"
/>
<ImgButton
:image="IMAGE_PATHS.AFTEREDITING.ADJUSTMENTCOLOR"
@click="navigateTo('Color')"
class="ml-[40px]"
/>
<ImgButton :image="IMAGE_PATHS.VLOG.MidtermFraming" @click="shooting" class="ml-[40px]" />
<ImgButton :image="IMAGE_PATHS.VLOG.MidtermShoot" class="ml-[20px]" />
</div>
<!-- 遮罩层 -->
<div class="Shadow">
<div class="Shadow" v-if="highlightShow">
<div>
<img src="" alt="" />
</div>
<img src="@/assets/images/midterm/引导遮罩.png" alt="" />
</div>
<!-- 叉 -->
<div
v-if="highlightShow"
@@ -116,6 +109,8 @@ onMounted(() => {
>
<img src="@/assets/images/midterm/叉.png" alt="" />
</div>
<!-- 引导任务 -->
<!-- <ImgButton class="absolute right-24 bottom-24" :image="IMAGE_PATHS.VLOG.TASK_PERSON" /> -->
<div class="absolute bottom-0 w-full bright">
<ProgressBar
@@ -126,8 +121,7 @@ onMounted(() => {
</div>
</div>
</template>
<style lang="less" scoped>
<style lang="less" scopeds>
.Shadow {
width: 100%;
height: 100%;
@@ -0,0 +1,20 @@
<template>
<div class="absolute bottom-0">
<div class="relative">
<img src="@/assets/images/shooting/开始取景.png" alt="" />
<ImgButton
class="absolute bottom-[46%] left-[49%] -translate-x-1/2"
image="shooting/按钮-开始取景.png"
@click="$emit('start')"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ImgButton } from '@/components/Button'
defineEmits<{
start: []
}>()
</script>
@@ -0,0 +1,77 @@
import { ref } from 'vue'
interface CaptureOptions {
width: number
height: number
scale?: number
delay?: number // 截图前的等待时间(用于 UI 更新)
resultDelay?: number // 截图后显示结果的延迟
onDraw: (ctx: CanvasRenderingContext2D) => void | Promise<void>
onComplete?: () => void
}
export function useCanvasCapture() {
const isLoading = ref(false)
const showModal = ref(false)
const capturedImageUrl = ref('')
const capture = async (options: CaptureOptions) => {
if (isLoading.value) return
const { width, height, scale = 2, delay = 50, resultDelay = 0, onDraw, onComplete } = options
isLoading.value = true
try {
// 等待 UI 更新
if (delay > 0) {
await new Promise(resolve => setTimeout(resolve, delay))
}
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('Cannot get canvas context')
}
// 设置画布大小(高清)
canvas.width = width * scale
canvas.height = height * scale
// 填充白色背景(防止透明)
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, canvas.width, canvas.height)
// 应用缩放
ctx.scale(scale, scale)
// 执行自定义绘制逻辑
await onDraw(ctx)
capturedImageUrl.value = canvas.toDataURL('image/png')
// 延迟显示结果
if (resultDelay > 0) {
setTimeout(() => {
isLoading.value = false
showModal.value = true
onComplete?.()
}, resultDelay)
} else {
isLoading.value = false
showModal.value = true
onComplete?.()
}
} catch (error) {
console.error('Capture failed:', error)
isLoading.value = false
}
}
return {
isLoading,
showModal,
capturedImageUrl,
capture
}
}
@@ -0,0 +1,324 @@
<script lang="ts" setup>
import { ref, computed } from 'vue'
import { getBackgroundImage } from '@/utils'
import { ImgButton } from '@/components/Button'
import { Overlay } from '@/components/Overlay'
import { useRouter } from 'vue-router'
import { useViewfinderA } from './useViewfinderA'
import { useCanvasCapture } from '../composables/useCanvasCapture'
import StartOverlay from '../components/StartOverlay.vue'
import LoadingOverlay from '@/components/Loading/LoadingOverlay.vue'
const router = useRouter()
const start = ref(false)
// 基础逻辑
const back = () => router.back()
const handleFinish = () => router.push({ name: 'InterviewFilmingLocationB' })
// 截图功能
const { isLoading, showModal, capturedImageUrl, capture } = useCanvasCapture()
const handleModalClose = () => {
showModal.value = false
}
// 手机模式取景
const {
currentZoom,
isCapturing,
isDragging,
setZoom,
startDrag,
onDrag,
endDrag,
getTransform,
currentOffsetX,
currentOffsetY,
handleWheel
} = useViewfinderA()
// 刻度盘配置常量
const DIAL_STEP_DEG = 30 // 1x 到 2x 之间的角度差(度)
const ZOOM_MIN = 1
const ZOOM_MAX = 3
const ZOOM_CENTER = 2
const TICK_STEP = 0.1 // 刻度步长
const MAJOR_TICK_INTERVAL = 5 // 主刻度间隔(0.5x)
const INTEGER_TICK_INTERVAL = 10 // 整数刻度间隔(1.0x)
// 刻度数据类型
interface DialTick {
zoom: number
angle: number
isMajor: boolean
isInteger: boolean
label: string
}
// 计算圆盘旋转角度
// 目标:将当前 zoom 对应的刻度旋转到 0 度位置
// 3x 在上方(-30deg),1x 在下方(+30deg)
const dialRotation = computed(() => {
return (currentZoom.value - ZOOM_CENTER) * DIAL_STEP_DEG
})
// 格式化缩放倍数显示
const formattedZoom = computed(() => {
return currentZoom.value.toFixed(1) + 'x'
})
// 生成刻度数据
const dialTicks = computed<DialTick[]>(() => {
const ticks: DialTick[] = []
const minTick = ZOOM_MIN / TICK_STEP
const maxTick = ZOOM_MAX / TICK_STEP
for (let z = minTick; z <= maxTick; z += 1) {
const zoom = z * TICK_STEP
// 刻度相对于中心(2x)的角度
// 3x -> -30deg(上方),1x -> +30deg(下方)
const angle = -(zoom - ZOOM_CENTER) * DIAL_STEP_DEG
const isMajor = z % MAJOR_TICK_INTERVAL === 0
const isInteger = z % INTEGER_TICK_INTERVAL === 0
ticks.push({
zoom,
angle,
isMajor,
isInteger,
label: isInteger ? zoom + 'x' : ''
})
}
return ticks
})
const photoImage = ref<HTMLImageElement | null>(null)
// Canvas 截图配置常量
const CANVAS_WIDTH = 871
const CANVAS_HEIGHT = 420
const CANVAS_SCALE = 2 // 2倍高清
const FLASH_DURATION = 100 // 闪光动画时长(ms)
const UI_UPDATE_DELAY = 50 // UI 更新延迟(ms)
const RESULT_DISPLAY_DELAY = 300 // 结果显示延迟(ms)
/**
* 拍照功能
* 使用 Canvas 手动绘制图片,避免 DOM 截图的兼容性问题
*/
/**
* 拍照功能
* 使用 Canvas 手动绘制图片,避免 DOM 截图的兼容性问题
*/
const capturePhoto = async () => {
if (isLoading.value) return
isCapturing.value = true
await new Promise(resolve => setTimeout(resolve, FLASH_DURATION))
await capture({
width: CANVAS_WIDTH,
height: CANVAS_HEIGHT,
scale: CANVAS_SCALE,
delay: UI_UPDATE_DELAY,
resultDelay: RESULT_DISPLAY_DELAY,
onDraw: ctx => {
if (!photoImage.value) {
throw new Error('Photo image not found')
}
// 计算变换
// 1. 移动到中心
ctx.translate(CANVAS_WIDTH / 2, CANVAS_HEIGHT / 2)
// 2. 应用拖拽偏移
ctx.translate(currentOffsetX.value, currentOffsetY.value)
// 3. 应用缩放
ctx.scale(currentZoom.value, currentZoom.value)
// 4. 移回左上角绘制
ctx.drawImage(
photoImage.value,
-CANVAS_WIDTH / 2,
-CANVAS_HEIGHT / 2,
CANVAS_WIDTH,
CANVAS_HEIGHT
)
},
onComplete: () => {
isCapturing.value = false
}
})
}
</script>
<template>
<div class="w-full h-full">
<div
class="relative w-full h-full bg-cover bg-center bg-no-repeat shooting-bg"
:style="{ backgroundImage: getBackgroundImage('shooting/取景地模糊背景.png') }"
>
<ImgButton image="shooting/标题-取景地a.png" class="title-button absolute left-0" />
<ImgButton image="shooting/返回.png" class="back-button absolute right-0" @click="back" />
<StartOverlay v-if="!start" @start="start = true" />
<!-- 手机拍照界面 -->
<div class="absolute bottom-0 w-full h-[766px]" v-else>
<img
class="phone-frame absolute w-full h-[766px] z-10 pointer-events-none"
src="@/assets/images/shooting/shoot.png"
alt=""
/>
<!-- 拍摄区域 -->
<div
class="photo-wrapper absolute pointer-events-none top-[19.2%] left-[48.3%] -translate-x-1/2 w-[871px] h-[420px]"
>
<div class="photo-container w-full h-full overflow-hidden relative">
<!-- 拍摄图片 -->
<img
ref="photoImage"
class="absolute pointer-events-auto w-[871px] h-[420px] ease-out select-none cursor-move touch-none"
:class="{
'transition-transform duration-300': !isDragging,
'cursor-grab': !isDragging,
'cursor-grabbing': isDragging
}"
:style="{ transform: getTransform() }"
src="@/assets/images/shooting/取景大图.png"
alt=""
crossorigin="anonymous"
@mousedown="startDrag"
@mousemove="onDrag"
@mouseup="endDrag"
@mouseleave="endDrag"
@wheel="handleWheel"
@touchstart="startDrag"
@touchmove="onDrag"
@touchend="endDrag"
draggable="false"
/>
<!-- 拍照闪光效果 -->
<div
v-if="isCapturing"
class="flash-overlay absolute inset-0 bg-white animate-flash"
></div>
</div>
</div>
<div
class="shooting-decor relative pointer-events-none top-[19.2%] left-[48.3%] -translate-x-1/2 w-[871px] h-[420px] bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: getBackgroundImage('shooting/拍摄装饰.png') }"
>
<!-- 仿苹果半圆盘缩放控件 -->
<div
class="zoom-dial-container absolute pointer-events-auto w-[150px] h-[300px] top-1/2 -translate-y-1/2 -rotate-2 right-[179px] overflow-hidden select-none mask-gradient"
>
<!-- 指示器 (固定在中心) -->
<div
class="absolute top-1/2 left-0 w-full -translate-y-1/2 z-20 flex items-center pointer-events-none"
>
<div
class="w-0 h-0 border-t-[8px] border-t-transparent border-l-[14px] border-l-yellow-400 border-b-[8px] border-b-transparent ml-3"
></div>
<div
class="ml-3 bg-black/70 backdrop-blur-md text-yellow-400 font-bold px-3 py-1 rounded text-lg shadow-lg"
>
{{ formattedZoom }}
</div>
</div>
<!-- 旋转圆盘 -->
<!-- 圆心在容器右侧外部,形成左侧弧形 -->
<div
class="dial-wheel absolute top-1/2 right-[-480px] w-[600px] h-[600px] rounded-full bg-black/30 backdrop-blur-md border-[5px] border-white/20 transition-transform duration-100 ease-out origin-center cursor-grab active:cursor-grabbing"
:style="{ transform: `translateY(-50%) rotate(${dialRotation}deg)` }"
>
<div
v-for="tick in dialTicks"
:key="tick.zoom"
class="absolute top-1/2 left-0 w-full h-0 flex justify-start items-center origin-center pointer-events-auto"
:style="{ transform: `translate(0, -50%) rotate(${tick.angle}deg)` }"
@click.stop="setZoom(tick.zoom)"
>
<!-- 刻度线本体 (位于圆盘左边缘) -->
<div
class="absolute left-3 bg-white/90 transition-all duration-200"
:class="{
'w-10 h-1 opacity-90': tick.isInteger,
'w-6 h-0.5 opacity-60': !tick.isInteger && tick.isMajor,
'w-3 h-[1px] opacity-40': !tick.isMajor
}"
></div>
<!-- 刻度文字 -->
<div
v-if="tick.isInteger"
class="absolute left-[52px] text-white/90 font-bold text-lg drop-shadow-md"
:style="{ transform: `rotate(${-tick.angle - dialRotation}deg)` }"
>
<span
:class="{ 'text-yellow-400 scale-110': currentZoom === tick.zoom }"
class="block transition-all duration-200"
>
{{ tick.label }}
</span>
</div>
</div>
</div>
</div>
<!-- 拍摄按钮 -->
<ImgButton
image="shooting/按钮-拍摄.png"
class="capture-button absolute pointer-events-auto right-14 top-1/2 -translate-y-1/2 transition-transform active:scale-95"
@click="capturePhoto"
/>
</div>
</div>
<!-- 结果弹窗 -->
<Overlay :visible="showModal" @close="handleModalClose">
<div class="relative w-[1000px] h-[600px] flex items-center justify-center">
<!-- 截图内容 -->
<div v-if="capturedImageUrl" class="bg-white p-2 rounded-md -rotate-6">
<img :src="capturedImageUrl" alt="拍摄结果" />
</div>
<!-- 操作按钮 -->
<div class="absolute bottom-1/2 -right-1/3 translate-y-1/2">
<ImgButton image="shooting/按钮-前往下一个取景地" @click="handleFinish" />
</div>
</div>
</Overlay>
</div>
<LoadingOverlay :visible="isLoading" text="正在出片中..." />
</div>
</template>
<style lang="less">
@keyframes flash {
0% {
opacity: 0;
}
50% {
opacity: 0.8;
}
100% {
opacity: 0;
}
}
.animate-flash {
animation: flash 0.3s ease-out;
}
.mask-gradient {
mask-image: linear-gradient(to bottom, transparent, black 15%, black 85%, transparent);
-webkit-mask-image: linear-gradient(to bottom, transparent, black 15%, black 85%, transparent);
}
</style>
@@ -0,0 +1,149 @@
import { ref } from 'vue'
import { getTouchDistance, getClientPosition } from '../utils/touchGestures'
/**
* 手机拍照模式的取景逻辑
* 支持缩放和拖拽功能
*/
export function useViewfinderA() {
// 缩放限制
const MIN_ZOOM = 1
const MAX_ZOOM = 3
// 缩放级别 (保留用于点击按钮)
const zoomLevels = [3, 2, 1]
const currentZoom = ref(1)
const isCapturing = ref(false) // 拍照闪光效果
// 拖拽相关状态
const isDragging = ref(false)
const dragStartX = ref(0)
const dragStartY = ref(0)
const offsetX = ref(0)
const offsetY = ref(0)
const currentOffsetX = ref(0)
const currentOffsetY = ref(0)
// 双指缩放相关
const isPinching = ref(false)
const pinchStartDistance = ref(0)
const pinchStartZoom = ref(1)
// 计算边界限制
const limitOffset = () => {
const containerWidth = 871
const containerHeight = 420
const scale = currentZoom.value
// 计算图片缩放后的尺寸
const scaledWidth = containerWidth * scale
const scaledHeight = containerHeight * scale
// 计算最大偏移量(图片边缘不能超出容器中心)
const maxOffsetX = (scaledWidth - containerWidth) / 2
const maxOffsetY = (scaledHeight - containerHeight) / 2
// 限制偏移量
currentOffsetX.value = Math.max(-maxOffsetX, Math.min(maxOffsetX, currentOffsetX.value))
currentOffsetY.value = Math.max(-maxOffsetY, Math.min(maxOffsetY, currentOffsetY.value))
}
// 设置缩放级别
const setZoom = (zoom: number) => {
currentZoom.value = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom))
limitOffset()
}
// 鼠标滚轮缩放
const handleWheel = (e: WheelEvent) => {
e.preventDefault()
// 滚轮向下是正数(缩小),向上是负数(放大)
const delta = -e.deltaY * 0.001
const newZoom = currentZoom.value + delta
setZoom(newZoom)
}
// 开始拖拽 / 触摸
const startDrag = (e: MouseEvent | TouchEvent) => {
// 双指缩放
if ('touches' in e && e.touches.length === 2) {
isPinching.value = true
isDragging.value = false
pinchStartDistance.value = getTouchDistance(e.touches[0], e.touches[1])
pinchStartZoom.value = currentZoom.value
e.preventDefault()
return
}
// 单指拖拽
if ('touches' in e && e.touches.length !== 1) return
isDragging.value = true
isPinching.value = false
const { x: clientX, y: clientY } = getClientPosition(e)
dragStartX.value = clientX
dragStartY.value = clientY
offsetX.value = currentOffsetX.value
offsetY.value = currentOffsetY.value
}
// 拖拽中 / 缩放中
const onDrag = (e: MouseEvent | TouchEvent) => {
// 处理双指缩放
if (isPinching.value && 'touches' in e && e.touches.length === 2) {
const currentDistance = getTouchDistance(e.touches[0], e.touches[1])
const scale = currentDistance / pinchStartDistance.value
setZoom(pinchStartZoom.value * scale)
e.preventDefault()
return
}
if (!isDragging.value) return
const { x: clientX, y: clientY } = getClientPosition(e)
const deltaX = clientX - dragStartX.value
const deltaY = clientY - dragStartY.value
currentOffsetX.value = offsetX.value + deltaX
currentOffsetY.value = offsetY.value + deltaY
limitOffset()
// 防止滚动
if (e.cancelable) {
e.preventDefault()
}
}
// 结束拖拽
const endDrag = () => {
isDragging.value = false
isPinching.value = false
}
// 计算transform样式
const getTransform = () => {
return `translate(${currentOffsetX.value}px, ${currentOffsetY.value}px) scale(${currentZoom.value})`
}
return {
// 状态
zoomLevels,
currentZoom,
isCapturing,
isDragging,
currentOffsetX,
currentOffsetY,
// 方法
setZoom,
startDrag,
onDrag,
endDrag,
handleWheel,
getTransform
}
}
@@ -0,0 +1,258 @@
<script lang="ts" setup>
import { ref } from 'vue'
import { getBackgroundImage } from '@/utils'
import { ImgButton } from '@/components/Button'
import { Overlay } from '@/components/Overlay'
import { useViewfinderB } from './useViewfinderB'
import { useCanvasCapture } from '../composables/useCanvasCapture'
import StartOverlay from '../components/StartOverlay.vue'
import LoadingOverlay from '@/components/Loading/LoadingOverlay.vue'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { useRouter } from 'vue-router'
import { useInterviewStore } from '@/stores'
const router = useRouter()
const start = ref(false)
// 基础逻辑
const back = () => router.back()
// 截图功能
const { isLoading, showModal, capturedImageUrl, capture } = useCanvasCapture()
const handleModalClose = () => {
showModal.value = false
}
// 自由取景框模式
const { viewfinderContainer, viewfinderBox, viewfinder, startDrag, startResize } = useViewfinderB()
const showGuide = ref(false)
const backgroundImage = ref<HTMLImageElement | null>(null)
// Canvas 截图配置常量
const UI_UPDATE_DELAY = 50 // UI 更新延迟(ms)
const CANVAS_SCALE = 2 // Canvas 缩放比例(2倍高清)
const GUIDE_SHOW_DELAY = 1000 // 引导显示延迟(ms)
/**
* 截取场景
* 使用 Canvas 手动绘制,确保移动端兼容性
*/
/**
* 截取场景
* 使用 Canvas 手动绘制,确保移动端兼容性
*/
const captureScene = async () => {
if (isLoading.value) return
await capture({
width: viewfinder.value.width,
height: viewfinder.value.height,
scale: CANVAS_SCALE,
delay: UI_UPDATE_DELAY,
onDraw: ctx => {
if (!backgroundImage.value) {
throw new Error('Background image not found')
}
// 计算图片实际尺寸和设计稿尺寸的缩放比例
// viewfinder 的坐标是基于设计稿尺寸(1920x1080)
// 但 backgroundImage 的实际尺寸可能不同
const imageScaleX = backgroundImage.value.naturalWidth / 1920
const imageScaleY = backgroundImage.value.naturalHeight / 1080
// 绘制背景图片的裁剪区域
// 将设计稿坐标转换为图片实际坐标
ctx.drawImage(
backgroundImage.value,
viewfinder.value.x * imageScaleX, // 源图 x(转换为实际坐标)
viewfinder.value.y * imageScaleY, // 源图 y(转换为实际坐标)
viewfinder.value.width * imageScaleX, // 源图 width(转换为实际坐标)
viewfinder.value.height * imageScaleY, // 源图 height(转换为实际坐标)
0, // 目标 x
0, // 目标 y
viewfinder.value.width, // 目标 width
viewfinder.value.height // 目标 height
)
},
onComplete: () => {
// 延迟显示引导
setTimeout(() => {
showGuide.value = true
}, GUIDE_SHOW_DELAY)
}
})
}
/**
* 完成引导,跳转到 Vlog 页面
*/
const handleComplete = () => {
showGuide.value = false
useInterviewStore().setInterviewUnlockProgress(3)
router.push({
name: 'Interview'
})
}
</script>
<template>
<div class="w-full h-full">
<div
class="relative w-full h-full bg-cover bg-center bg-no-repeat shooting-bg"
:style="{
backgroundImage: start
? getBackgroundImage('shooting/取景大图.png')
: getBackgroundImage('shooting/取景地模糊背景.png')
}"
>
<!-- 标题 -->
<ImgButton image="shooting/标题-取景地b.png" class="title-button absolute left-0" />
<!-- 返回按钮 -->
<ImgButton
image="shooting/返回.png"
class="back-button absolute right-0 z-50"
@click="back"
/>
<StartOverlay v-if="!start" @start="start = true" />
<!-- 取景 -->
<div class="absolute bottom-0 w-full h-full" v-else>
<!-- 隐藏的背景图片,用于 Canvas 绘制 -->
<img
ref="backgroundImage"
class="absolute inset-0 w-full h-full object-cover pointer-events-none opacity-0"
src="@/assets/images/shooting/取景大图.png"
alt=""
crossorigin="anonymous"
/>
<!-- 取景框容器 -->
<div ref="viewfinderContainer" class="relative w-full h-full">
<!-- 四个方向的遮罩层 -->
<!-- 上遮罩 -->
<div
class="mask-layer absolute left-0 right-0 bg-black/60 pointer-events-none z-10"
:style="{
top: 0,
height: viewfinder.y + 'px'
}"
></div>
<!-- 下遮罩 -->
<div
class="mask-layer absolute left-0 right-0 bg-black/60 pointer-events-none z-10"
:style="{
top: viewfinder.y + viewfinder.height + 'px',
bottom: 0
}"
></div>
<!-- 左遮罩 -->
<div
class="mask-layer absolute left-0 bg-black/60 pointer-events-none z-10"
:style="{
top: viewfinder.y + 'px',
width: viewfinder.x + 'px',
height: viewfinder.height + 'px'
}"
></div>
<!-- 右遮罩 -->
<div
class="mask-layer absolute right-0 bg-black/60 pointer-events-none z-10"
:style="{
top: viewfinder.y + 'px',
left: viewfinder.x + viewfinder.width + 'px',
height: viewfinder.height + 'px'
}"
></div>
<!-- 可拖动调整的取景框 -->
<div
ref="viewfinderBox"
class="viewfinder-frame absolute border-4 border-white cursor-move z-20"
:style="{
left: viewfinder.x + 'px',
top: viewfinder.y + 'px',
width: viewfinder.width + 'px',
height: viewfinder.height + 'px'
}"
@mousedown="startDrag"
@touchstart="startDrag"
>
<!-- 九宫格辅助线 -->
<div class="grid-lines absolute inset-0 grid grid-cols-3 grid-rows-3">
<div
v-for="i in 9"
:key="i"
class="border border-white/40"
:class="{
'border-t-0': i <= 3,
'border-b-0': i >= 7,
'border-l-0': (i - 1) % 3 === 0,
'border-r-0': i % 3 === 0
}"
></div>
</div>
<!-- 四个角的调整手柄 -->
<div
class="resize-handle absolute w-6 h-6 bg-white rounded-full -left-3 -top-3 cursor-nwse-resize"
@mousedown.stop="startResize('nw', $event)"
@touchstart.stop="startResize('nw', $event)"
></div>
<div
class="resize-handle absolute w-6 h-6 bg-white rounded-full -right-3 -top-3 cursor-nesw-resize"
@mousedown.stop="startResize('ne', $event)"
@touchstart.stop="startResize('ne', $event)"
></div>
<div
class="resize-handle absolute w-6 h-6 bg-white rounded-full -left-3 -bottom-3 cursor-nesw-resize"
@mousedown.stop="startResize('sw', $event)"
@touchstart.stop="startResize('sw', $event)"
></div>
<div
class="resize-handle absolute w-6 h-6 bg-white rounded-full -right-3 -bottom-3 cursor-nwse-resize"
@mousedown.stop="startResize('se', $event)"
@touchstart.stop="startResize('se', $event)"
></div>
</div>
</div>
<!-- 底部取景按钮 - 跟随取景框 -->
<div
class="capture-button-container absolute z-20 -translate-x-1/2 -translate-y-1/2"
:style="{
left: viewfinder.x + viewfinder.width / 2 + 'px',
top: viewfinder.y + viewfinder.height + 'px'
}"
>
<ImgButton class="w-36" image="shooting/按钮-取景" @click="captureScene" />
</div>
</div>
<!-- 结果弹窗 -->
<Overlay :visible="showModal" @close="handleModalClose">
<div class="relative w-[1000px] h-[600px] flex items-center justify-center">
<!-- 截图内容 -->
<div v-if="capturedImageUrl" class="bg-white p-2 rounded-md -rotate-6">
<img :src="capturedImageUrl" alt="拍摄结果" />
</div>
<!-- 操作按钮 -->
<!-- <div class="absolute bottom-1/2 -right-1/3 translate-y-1/2">
<ImgButton image="shooting/按钮-前往下一个取景地" @click="handleFinish" />
</div> -->
</div>
</Overlay>
</div>
<LoadingOverlay :visible="isLoading" text="正在出片中..." />
<!-- 引导步骤 -->
<GuideSteps v-if="showGuide" positionOffsetY="80%" @complete="handleComplete">
<GuideStepsItem> 实地取景已全部完成,现在跟着我走进现场开始正式的拍摄吧~ </GuideStepsItem>
</GuideSteps>
</div>
</template>
@@ -0,0 +1,291 @@
import { ref, onMounted, onUnmounted } from 'vue'
import { getTouchDistance, getTouchCenter, getClientPosition } from '../utils/touchGestures'
// 设计稿尺寸常量
const DESIGN_WIDTH = 1920
const DESIGN_HEIGHT = 1080
/**
* 自由取景框模式的逻辑
* 支持拖动、四角调整大小、双指缩放
*/
export function useViewfinderB() {
// 取景框相关
const viewfinderContainer = ref<HTMLElement>()
const viewfinderBox = ref<HTMLElement>()
const viewfinder = ref({
x: 660, // (1920 - 600) / 2 = 660,居中
y: 340, // (1080 - 400) / 2 = 340,居中
width: 600,
height: 400
})
// 拖动相关状态
const isDragging = ref(false)
const dragStart = ref({ x: 0, y: 0 })
// 调整大小相关状态
const isResizing = ref(false)
const resizeCorner = ref<'nw' | 'ne' | 'sw' | 'se' | null>(null)
const resizeStart = ref({
x: 0,
y: 0,
boxX: 0,
boxY: 0,
boxWidth: 0,
boxHeight: 0
})
// 双指缩放相关状态
const isPinching = ref(false)
const pinchStart = ref({
distance: 0,
centerX: 0,
centerY: 0,
boxWidth: 0,
boxHeight: 0,
boxX: 0,
boxY: 0
})
// 将屏幕坐标转换为缩放后的本地坐标
const convertClientToLocal = (clientX: number, clientY: number) => {
if (!viewfinderContainer.value) return { x: clientX, y: clientY }
const containerRect = viewfinderContainer.value.getBoundingClientRect()
// 计算缩放比例
const scaleX = containerRect.width / DESIGN_WIDTH
const scaleY = containerRect.height / DESIGN_HEIGHT
// 计算相对于容器的坐标,并转换为设计稿坐标系
const localX = (clientX - containerRect.left) / scaleX
const localY = (clientY - containerRect.top) / scaleY
return { x: localX, y: localY }
}
// 开始拖动取景框
const startDrag = (e: MouseEvent | TouchEvent) => {
if (isResizing.value || isPinching.value) return
// 检测双指触摸
if ('touches' in e && e.touches.length === 2) {
// 双指缩放
isPinching.value = true
const touch1 = e.touches[0]
const touch2 = e.touches[1]
pinchStart.value = {
distance: getTouchDistance(touch1, touch2),
centerX: (touch1.clientX + touch2.clientX) / 2,
centerY: (touch1.clientY + touch2.clientY) / 2,
boxWidth: viewfinder.value.width,
boxHeight: viewfinder.value.height,
boxX: viewfinder.value.x,
boxY: viewfinder.value.y
}
e.preventDefault()
return
}
// 单指拖动
if ('touches' in e && e.touches.length !== 1) return
isDragging.value = true
const { x: clientX, y: clientY } = getClientPosition(e)
const localPos = convertClientToLocal(clientX, clientY)
dragStart.value = {
x: localPos.x - viewfinder.value.x,
y: localPos.y - viewfinder.value.y
}
e.preventDefault()
}
// 开始调整大小
const startResize = (corner: 'nw' | 'ne' | 'sw' | 'se', e: MouseEvent | TouchEvent) => {
isResizing.value = true
resizeCorner.value = corner
const { x: clientX, y: clientY } = getClientPosition(e)
const localPos = convertClientToLocal(clientX, clientY)
resizeStart.value = {
x: localPos.x,
y: localPos.y,
boxX: viewfinder.value.x,
boxY: viewfinder.value.y,
boxWidth: viewfinder.value.width,
boxHeight: viewfinder.value.height
}
e.preventDefault()
}
// 处理移动
const handleMove = (e: MouseEvent | TouchEvent) => {
if (!isDragging.value && !isResizing.value && !isPinching.value) return
// 处理双指缩放
if (isPinching.value && 'touches' in e && e.touches.length === 2) {
const touch1 = e.touches[0]
const touch2 = e.touches[1]
const currentDistance = getTouchDistance(touch1, touch2)
const scale = currentDistance / pinchStart.value.distance
// 计算新的尺寸
let newWidth = pinchStart.value.boxWidth * scale
let newHeight = pinchStart.value.boxHeight * scale
const minSize = 200
// 限制最小和最大尺寸
newWidth = Math.max(minSize, Math.min(newWidth, DESIGN_WIDTH))
newHeight = Math.max(minSize, Math.min(newHeight, DESIGN_HEIGHT))
// 保持中心点不变,计算新的位置
const center = getTouchCenter(touch1, touch2)
const localCenter = convertClientToLocal(center.x, center.y)
let newX = localCenter.x - newWidth / 2
let newY = localCenter.y - newHeight / 2
// 边界检测
newX = Math.max(0, Math.min(newX, DESIGN_WIDTH - newWidth))
newY = Math.max(0, Math.min(newY, DESIGN_HEIGHT - newHeight))
viewfinder.value.width = newWidth
viewfinder.value.height = newHeight
viewfinder.value.x = newX
viewfinder.value.y = newY
e.preventDefault()
return
}
const { x: clientX, y: clientY } = getClientPosition(e)
const localPos = convertClientToLocal(clientX, clientY)
if (isDragging.value && viewfinderContainer.value) {
// 拖动取景框
let newX = localPos.x - dragStart.value.x
let newY = localPos.y - dragStart.value.y
// 边界检测 - 使用设计稿尺寸
newX = Math.max(0, Math.min(newX, DESIGN_WIDTH - viewfinder.value.width))
newY = Math.max(0, Math.min(newY, DESIGN_HEIGHT - viewfinder.value.height))
viewfinder.value.x = newX
viewfinder.value.y = newY
} else if (isResizing.value && viewfinderContainer.value) {
// 调整取景框大小
const deltaX = localPos.x - resizeStart.value.x
const deltaY = localPos.y - resizeStart.value.y
const minSize = 200 // 最小尺寸
if (resizeCorner.value === 'se') {
// 右下角 - 使用设计稿尺寸限制
viewfinder.value.width = Math.max(
minSize,
Math.min(resizeStart.value.boxWidth + deltaX, DESIGN_WIDTH - viewfinder.value.x)
)
viewfinder.value.height = Math.max(
minSize,
Math.min(resizeStart.value.boxHeight + deltaY, DESIGN_HEIGHT - viewfinder.value.y)
)
} else if (resizeCorner.value === 'sw') {
// 左下角 - 使用设计稿尺寸限制
const newWidth = resizeStart.value.boxWidth - deltaX
const newX = resizeStart.value.boxX + deltaX
if (newWidth >= minSize && newX >= 0) {
viewfinder.value.x = newX
viewfinder.value.width = newWidth
}
viewfinder.value.height = Math.max(
minSize,
Math.min(resizeStart.value.boxHeight + deltaY, DESIGN_HEIGHT - viewfinder.value.y)
)
} else if (resizeCorner.value === 'ne') {
// 右上角 - 使用设计稿尺寸限制
const newHeight = resizeStart.value.boxHeight - deltaY
const newY = resizeStart.value.boxY + deltaY
if (newHeight >= minSize && newY >= 0) {
viewfinder.value.y = newY
viewfinder.value.height = newHeight
}
viewfinder.value.width = Math.max(
minSize,
Math.min(resizeStart.value.boxWidth + deltaX, DESIGN_WIDTH - viewfinder.value.x)
)
} else if (resizeCorner.value === 'nw') {
// 左上角 - 使用设计稿尺寸限制
const newWidth = resizeStart.value.boxWidth - deltaX
const newHeight = resizeStart.value.boxHeight - deltaY
const newX = resizeStart.value.boxX + deltaX
const newY = resizeStart.value.boxY + deltaY
if (newWidth >= minSize && newX >= 0) {
viewfinder.value.x = newX
viewfinder.value.width = newWidth
}
if (newHeight >= minSize && newY >= 0) {
viewfinder.value.y = newY
viewfinder.value.height = newHeight
}
}
}
e.preventDefault()
}
// 结束拖动或调整
const handleEnd = () => {
isDragging.value = false
isResizing.value = false
isPinching.value = false
resizeCorner.value = null
}
// 挂载时添加全局事件监听
onMounted(() => {
document.addEventListener('mousemove', handleMove)
document.addEventListener('mouseup', handleEnd)
document.addEventListener('touchmove', handleMove)
document.addEventListener('touchend', handleEnd)
})
// 卸载时移除事件监听
onUnmounted(() => {
document.removeEventListener('mousemove', handleMove)
document.removeEventListener('mouseup', handleEnd)
document.removeEventListener('touchmove', handleMove)
document.removeEventListener('touchend', handleEnd)
})
return {
// Refs
viewfinderContainer,
viewfinderBox,
// 状态
viewfinder,
isDragging,
isResizing,
isPinching,
// 方法
startDrag,
startResize,
// 常量
DESIGN_WIDTH,
DESIGN_HEIGHT
}
}
@@ -6,7 +6,7 @@ import { Overlay } from '@/components/Overlay'
import { ImgButton } from '@/components/Button'
import { getBackgroundImage, IMAGE_PATHS } from '@/utils'
import shootingtitle from '@/assets/images/shooting/标题-实地取景.png'
import videoSrc from '/video/新手导航_九种构图法.mp4'
import videoSrc from '/video/xinshou_jiuzhonggoutufa.mp4'
import KnowledgeCard from '@/components/KnowledgeCard/KnowledgeCard.vue'
type Btn = {
@@ -71,11 +71,11 @@ const currentCompositionKey = computed(() => {
const handleClickA = () => {
// 跳转设备准备
router.push({ name: 'filmingLocation-a' })
router.push({ name: 'InterviewFilmingLocationA' })
}
const handleClickB = () => {
// 跳转设备准备
router.push({ name: 'filmingLocation-b' })
router.push({ name: 'InterviewFilmingLocationB' })
}
const back = () => {
// 跳转设备准备
@@ -93,21 +93,16 @@ const back = () => {
<img :src="shootingtitle" alt="" />
</div>
<div class="shooting-return">
<img src="@/assets/images/shooting/返回.png" alt="" @click="back" />
<ImgButton image="shooting/返回.png" @click="back" />
</div>
<div class="shooting-left">
<img src="@/assets/images/shooting/按钮-取景地A.png" alt="" @click="handleClickA" />
<ImgButton image="shooting/按钮-取景地A.png" @click="handleClickA" />
</div>
<div class="shooting-right">
<img src="@/assets/images/shooting/按钮-取景地B.png" alt="" @click="handleClickB" />
<ImgButton image="shooting/按钮-取景地B.png" @click="handleClickB" />
</div>
<div class="shooting-composition">
<img
class="mybutton cursor-pointer"
src="@/assets/images/shooting/构图指南.png"
alt=""
@click="showModal = true"
/>
<ImgButton image="shooting/构图指南.png" @click="showModal = true" />
</div>
</div>
@@ -116,7 +111,7 @@ const back = () => {
:highlight-selector="'.mybutton'"
:highlight-padding="12"
:highlight-radius="20"
position-offset-y="70%"
position-offset-y="85%"
>
<GuideStepsItem> 在走进拍摄场景之前,我们先来查看一下构图指南。 </GuideStepsItem>
</GuideSteps>
@@ -0,0 +1,47 @@
/**
* 触摸手势工具函数
* 用于处理触摸事件的通用计算
*/
/**
* 计算两个触摸点之间的距离
* @param touch1 第一个触摸点
* @param touch2 第二个触摸点
* @returns 两点之间的欧氏距离
*/
export const getTouchDistance = (touch1: Touch, touch2: Touch): number => {
const dx = touch1.clientX - touch2.clientX
const dy = touch1.clientY - touch2.clientY
return Math.sqrt(dx * dx + dy * dy)
}
/**
* 计算两个触摸点的中心坐标
* @param touch1 第一个触摸点
* @param touch2 第二个触摸点
* @returns 中心点坐标 {x, y}
*/
export const getTouchCenter = (touch1: Touch, touch2: Touch) => {
return {
x: (touch1.clientX + touch2.clientX) / 2,
y: (touch1.clientY + touch2.clientY) / 2
}
}
/**
* 从鼠标或触摸事件中获取客户端坐标
* @param e 鼠标事件或触摸事件
* @returns 客户端坐标 {x, y}
*/
export const getClientPosition = (e: MouseEvent | TouchEvent) => {
if ('touches' in e && e.touches.length > 0) {
return {
x: e.touches[0].clientX,
y: e.touches[0].clientY
}
}
return {
x: (e as MouseEvent).clientX,
y: (e as MouseEvent).clientY
}
}
+357
View File
@@ -0,0 +1,357 @@
<script lang="ts" setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
import { ImgButton } from '@/components/Button'
import { StarRating } from '@/components/Rating'
import { useRouter } from 'vue-router'
import BackToTopIcon from '@/assets/svg/back-to-top.svg'
const router = useRouter()
const TOTAL_PAGES = 3
const PAGE_HEIGHT = 1080
const SCROLL_DURATION = 1000
const currentPage = ref<number>(0)
const worksScrollIndex = ref<number>(0)
const animatingToPage = ref<number>(0)
const containerRef = ref<HTMLElement | null>(null)
const isAnimating = ref<boolean>(false)
/** 是否为最后一页 */
const isLastPage = computed<boolean>(() => currentPage.value >= TOTAL_PAGES - 1)
/** 是否正在从第二页回到第一页 */
const isScrollingBackToFirst = computed<boolean>(() => {
return isAnimating.value && currentPage.value === 1 && animatingToPage.value === 0
})
/** 第一页坐着的人物是否显示 */
const showSittingPerson = computed<boolean>(() => {
// 当前在第一页,且不是正在回滚中
return currentPage.value === 0 && !isScrollingBackToFirst.value
})
/** 他人实战作品列表 */
interface WorkItem {
id: number
image: string
title: string
stars: number
path: string
description: string
}
const worksList = ref<WorkItem[]>([
// {
// id: 1,
// image: 'https://picsum.photos/400/300?random=1',
// title: '作品名称',
// stars: 4,
// path: '/video/practice/vlog_anli1.mp4',
// description:
// '作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介'
// },
// {
// id: 2,
// image: 'https://picsum.photos/400/300?random=2',
// title: '作品名称',
// stars: 5,
// path: '/video/practice/vlog_anli2.mp4',
// description: '作品简介作品简介作品简介作品简介作品简介作品简介作品简介作品简介'
// }
])
/** 左滑动作品列表 */
const scrollWorksLeft = (): void => {
if (worksScrollIndex.value > 0) {
worksScrollIndex.value--
}
}
/** 右滑动作品列表 */
const scrollWorksRight = (): void => {
if (worksScrollIndex.value < worksList.value.length - 3) {
worksScrollIndex.value++
}
}
const handleClose = (): void => {
router.back()
}
/** 自定义平滑滚动动画 */
const smoothScrollTo = (toPage: number): void => {
const container = containerRef.value
if (!container || isAnimating.value) return
isAnimating.value = true
animatingToPage.value = toPage
const startTop = container.scrollTop
const targetTop = toPage * PAGE_HEIGHT
const distance = targetTop - startTop
const startTime = performance.now()
/** easeInOutQuart 缓动函数 - 更平滑 */
const easeInOutQuart = (t: number): number => {
return t < 0.5 ? 8 * t * t * t * t : 1 - Math.pow(-2 * t + 2, 4) / 2
}
const animateScroll = (currentTime: number): void => {
const elapsed = currentTime - startTime
const progress = Math.min(elapsed / SCROLL_DURATION, 1)
const easeProgress = easeInOutQuart(progress)
container.scrollTop = startTop + distance * easeProgress
if (progress < 1) {
requestAnimationFrame(animateScroll)
} else {
isAnimating.value = false
currentPage.value = toPage
}
}
requestAnimationFrame(animateScroll)
}
/** 滚动到指定页 */
const scrollToPage = (page: number): void => {
const targetPage = Math.max(0, Math.min(page, TOTAL_PAGES - 1))
if (targetPage !== currentPage.value && !isAnimating.value) {
smoothScrollTo(targetPage)
}
}
/** 滚动到下一页 */
const scrollToNextPage = (): void => {
scrollToPage(currentPage.value + 1)
}
/** 滚动到上一页 */
const scrollToPrevPage = (): void => {
scrollToPage(currentPage.value - 1)
}
/** 回到顶部 */
const scrollToTop = (): void => {
scrollToPage(0)
}
/** 处理鼠标滚轮事件 */
const handleWheel = (event: WheelEvent): void => {
event.preventDefault()
if (isAnimating.value) return
if (event.deltaY > 0) {
scrollToNextPage()
} else if (event.deltaY < 0) {
scrollToPrevPage()
}
}
onMounted(() => {
containerRef.value?.addEventListener('wheel', handleWheel, { passive: false })
})
onUnmounted(() => {
containerRef.value?.removeEventListener('wheel', handleWheel)
})
</script>
<template>
<div ref="containerRef" class="w-full h-full overflow-auto">
<div class="relative min-h-full overflow-hidden">
<!-- 关闭 -->
<ImgButton
class="absolute right-0 top-0 z-50"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleClose"
/>
<!-- 实战操作台 -->
<div
class="w-full h-[1080px] bg-cover bg-no-repeat relative"
:style="{ backgroundImage: getBackgroundImage('vlog/practice/实战操作台首页') }"
>
<ImgButton
class="absolute left-40 bottom-28 z-10"
image="vlog/practice/查看任务要求"
@click="scrollToNextPage"
/>
<!-- img -->
<img
class="absolute right-0"
draggable="false"
:src="getStaticImage('vlog/practice/背景1')"
alt=""
/>
</div>
<!-- 任务要求 -->
<div
class="w-full h-[1080px] bg-cover bg-no-repeat relative overflow-hidden"
:style="{ backgroundImage: getBackgroundImage('vlog/practice/背景2') }"
>
<ImgButton
class="absolute right-20 bottom-0 z-99"
image="vlog/practice/我的创作笔记"
path="/practice/my-notes"
/>
<div class="task-panel w-[757px] h-full ml-10 overflow-y-auto" @wheel.stop>
<img :src="getStaticImage('vlog/practice/滑动任务面板')" alt="" />
</div>
<img
class="absolute right-0 bottom-0 pointer-events-none"
:src="getStaticImage('vlog/practice/摄像机')"
alt=""
/>
<!-- 站着的人物 - 第二页固定在此(不在回滚动画中) -->
<Transition name="fade">
<img
v-show="currentPage === 1 && !isScrollingBackToFirst"
class="absolute right-8 bottom-0 max-h-[90%] pointer-events-none"
draggable="false"
:src="getStaticImage('vlog/practice/摄像机-人')"
alt=""
/>
</Transition>
</div>
<!-- 他人实战作品 -->
<div
class="w-full h-[1080px] bg-cover bg-no-repeat pt-44 px-16"
:style="{ backgroundImage: getBackgroundImage('vlog/practice/他人实战作品') }"
>
<!-- 作品列表 -->
<div class="w-full overflow-hidden">
<div
class="flex gap-6 transition-transform duration-500"
:style="{ transform: `translateX(-${worksScrollIndex * 509}px)` }"
>
<div
v-for="work in worksList"
:key="work.id"
class="w-[503px] h-[722px] flex-shrink-0 rounded-[40px] overflow-hidden bg-[#FFF8F0] py-8 px-6"
>
<!-- 作品图片 -->
<div class="w-full h-[280px] rounded-2xl overflow-hidden">
<img class="w-full h-full object-cover" :src="work.image" :alt="work.title" />
</div>
<!-- 作品信息 -->
<div class="flex flex-col items-center pt-8 space-y-4">
<h3 class="text-5xl font-bold text-gray-800 mb-2">{{ work.title }}</h3>
<!-- 星星评分 -->
<StarRating :score="work.stars" size="lg" readonly />
<!-- 简介 -->
<p class="h-[110px] text-2xl text-gray-600 mb-3 line-clamp-3 leading-relaxed">
{{ work.description }}
</p>
<!-- 查看按钮 -->
<ImgButton
image="vlog/practice/查看"
:path="`/practice/video/${encodeURIComponent(work.path)}`"
/>
</div>
</div>
</div>
</div>
<!-- 指示按钮 -->
<div class="absolute bottom-5 left-1/2 -translate-x-1/2 flex gap-16">
<img
class="cursor-pointer hover:scale-110 transition-transform"
:class="{ 'opacity-50 cursor-not-allowed': worksScrollIndex === 0 }"
:src="getStaticImage('vlog/practice/滑动左')"
alt="向左"
@click="scrollWorksLeft"
/>
<img
class="cursor-pointer hover:scale-110 transition-transform rotate-180"
:class="{ 'opacity-50 cursor-not-allowed': worksScrollIndex >= worksList.length - 3 }"
:src="getStaticImage('vlog/practice/滑动左')"
alt="向右"
@click="scrollWorksRight"
/>
</div>
</div>
</div>
<!-- 下滑按钮 - 最后一页隐藏 -->
<ImgButton
v-show="!isLastPage"
class="absolute bottom-0 left-1/2 -translate-x-1/2"
:image="'vlog/practice/下滑'"
@click="scrollToNextPage"
/>
<!-- 回到顶部按钮 - 最后一页显示 -->
<img
v-show="isLastPage"
class="absolute bottom-10 right-10 cursor-pointer transition-all duration-300 hover:scale-105"
:src="BackToTopIcon"
@click="scrollToTop"
/>
<!-- 跨页人物动画 -->
<div v-show="showSittingPerson || isScrollingBackToFirst" class="character-container">
<!-- 坐着的人物 - 第一页 -->
<Transition name="fade">
<img
v-show="showSittingPerson"
class="character-img right-20 bottom-10"
draggable="false"
:src="getStaticImage('vlog/practice/背景1-人')"
alt=""
/>
</Transition>
<!-- 站着的人物 - 回滚动画中 -->
<Transition name="fade">
<img
v-show="isScrollingBackToFirst"
class="character-img right-8 bottom-0"
draggable="false"
:src="getStaticImage('vlog/practice/摄像机-人')"
alt=""
/>
</Transition>
</div>
</div>
</template>
<style scoped>
/* 隐藏滚动条但保持可滚动 - 兼容主流浏览器 */
.task-panel {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
.task-panel::-webkit-scrollbar {
display: none; /* Chrome/Safari/Opera */
}
/* 第一页人物动画容器 */
.character-container {
position: fixed;
right: 0;
top: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 20;
}
.character-img {
position: absolute;
max-height: 90%;
}
/* 淡入淡出动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 1s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
@@ -0,0 +1,148 @@
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { getBackgroundImage, IMAGE_PATHS } from '@/utils'
import { ImgButton } from '@/components/Button'
import { Overlay } from '@/components/Overlay'
import { StarRating } from '@/components/Rating'
const router = useRouter()
/** 笔记内容 */
const noteContent = ref<string>('')
/** 保存弹窗 */
const showModal = ref<boolean>(false)
/** 返回上一页 */
const handleBack = (): void => {
router.back()
}
const disabled = computed<boolean>(() => noteContent.value.trim() === '')
interface EvaluationItem {
/** 标题 */
title: string
/** 描述 */
description: string
/** 评分 */
score: number
}
/** 评估项目列表 */
const evaluationItems = ref<EvaluationItem[]>([
{
title: '主题与内容',
description: '主题明确聚焦"身边美好",内容真实、有感染力,能让观众感受到日常闪光',
score: 3
},
{
title: '拍摄技巧',
description: '运镜自然、光线运用合理、画面清晰饱和度、能辅助主题表达(至少1种运镜+1种光线)',
score: 4
},
{
title: '剪辑与包装',
description: '素材拼接流畅,调色/滤镜适配、字幕清晰、音乐不盖人声、整体风格统一',
score: 5
},
{
title: '创意与独特性',
description: '视角新颖、有个人观察,或用特殊细节/情节展现“美好”,避免平淡复刻',
score: 4
},
{
title: '综合感受',
description: '视角新颖、有个人观察,或用特殊细节/情节展现“美好”避免平淡复刻',
score: 5
}
])
const handleSave = (): void => {
console.log('保存笔记')
handleBack()
}
</script>
<template>
<div
class="w-full h-full bg-no-repeat bg-cover px-8 pt-[400px]"
:style="{ backgroundImage: getBackgroundImage('vlog/practice/创作笔记底图') }"
>
<ImgButton
class="absolute top-0 right-0"
:image="IMAGE_PATHS.COMPONENTS.CLOSE"
@click="handleBack"
/>
<textarea
v-model="noteContent"
class="w-full h-[300px]"
name="myNotes"
placeholder="点击输入创作笔记"
></textarea>
<div class="absolute bottom-24 left-1/2 -translate-x-1/2 flex gap-60">
<ImgButton image="vlog/practice/保存" @click="handleSave" />
<ImgButton image="vlog/practice/自评" :disabled="disabled" @click="showModal = true" />
</div>
<!-- 保存图片弹窗 -->
<Overlay :visible="showModal" :clickable="false">
<div
class="w-[1795px] h-[987px] bg-no-repeat bg-cover p-10 pt-[100px]"
:style="{ backgroundImage: getBackgroundImage('vlog/practice/评分底板') }"
>
<ImgButton
class="absolute -top-8 -right-14 z-10"
:image="IMAGE_PATHS.COMPONENTS.CLOSE"
@click="showModal = false"
/>
<div class="relative h-full overflow-y-scroll px-8">
<div
class="w-full bg-no-repeat bg-cover bg-left py-44"
:style="{ backgroundImage: getBackgroundImage('vlog/practice/滑动表内文字') }"
>
<div class="pl-10 space-y-16">
<div
v-for="(item, index) in evaluationItems"
:key="index"
class="relative w-full h-[182px] bg-no-repeat bg-cover px-12 py-3 flex justify-between"
:style="{ backgroundImage: getBackgroundImage('vlog/practice/评分条') }"
>
<div class="pr-8 space-y-10">
<h4 class="text-3xl font-bold text-white mb-3">
{{ `0${index + 1}` }} {{ item.title }}
</h4>
<p class="text-3xl text-gray-800 font-bold leading-relaxed">
{{ item.description }}
</p>
</div>
<StarRating class="absolute -bottom-5 right-10" :score="item.score" size="lg" />
</div>
<div class="w-full flex justify-center">
<ImgButton image="vlog/practice/保存" @click="showModal = false" />
</div>
</div>
</div>
</div>
</div>
</Overlay>
</div>
</template>
<style scoped>
textarea {
border: none;
outline: none;
resize: none;
font-size: 36px;
background-color: transparent;
}
textarea::placeholder {
color: #5d5b5b;
font-weight: 700;
}
</style>
@@ -0,0 +1,129 @@
<script lang="ts" setup>
import { useRouter } from 'vue-router'
import { Video } from '@/components'
import { ImgButton } from '@/components/Button'
import { StarRating } from '@/components/Rating'
import { IMAGE_PATHS, getBackgroundImage } from '@/utils'
import { Overlay } from '@/components/Overlay'
import { ref } from 'vue'
const router = useRouter()
const props = defineProps({
path: {
type: String,
required: true
}
})
interface EvaluationItem {
/** 标题 */
title: string
/** 描述 */
description: string
/** 评分 */
score: number
}
/** 评估项目列表 */
const evaluationItems = ref<EvaluationItem[]>([
{
title: '主题与内容',
description: '主题明确聚焦"身边美好",内容真实、有感染力,能让观众感受到日常闪光',
score: 3
},
{
title: '拍摄技巧',
description: '运镜自然、光线运用合理、画面清晰饱和度、能辅助主题表达(至少1种运镜+1种光线)',
score: 4
},
{
title: '剪辑与包装',
description: '素材拼接流畅,调色/滤镜适配、字幕清晰、音乐不盖人声、整体风格统一',
score: 5
},
{
title: '创意与独特性',
description: '视角新颖、有个人观察,或用特殊细节/情节展现“美好”,避免平淡复刻',
score: 4
},
{
title: '综合感受',
description: '视角新颖、有个人观察,或用特殊细节/情节展现“美好”避免平淡复刻',
score: 5
}
])
/** 保存弹窗 */
const showModal = ref<boolean>(false)
/** 返回上一页 */
const handleBack = (): void => {
router.back()
}
</script>
<template>
<div class="relative h-full">
<ImgButton
class="absolute right-0 top-0 z-50"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleBack"
/>
<ImgButton
class="absolute right-0 bottom-10 z-50"
image="vlog/practice/评分"
@click="showModal = true"
/>
<Video
:src="decodeURIComponent(props.path)"
:volume="1"
:muted="false"
:controls="true"
:loop="false"
/>
<!-- 保存图片弹窗 -->
<Overlay :visible="showModal" :clickable="false">
<div
class="w-[1795px] h-[987px] bg-no-repeat bg-cover p-10 pt-[80px]"
:style="{ backgroundImage: getBackgroundImage('vlog/practice/评分底板') }"
>
<ImgButton
class="absolute -top-8 -right-14 z-10"
:image="IMAGE_PATHS.COMPONENTS.CLOSE"
@click="showModal = false"
/>
<div class="relative h-full overflow-y-scroll px-8">
<div
class="w-full bg-no-repeat bg-cover bg-left py-44"
:style="{ backgroundImage: getBackgroundImage('vlog/practice/滑动表内文字') }"
>
<div class="pl-10 space-y-16">
<div
v-for="(item, index) in evaluationItems"
:key="index"
class="relative w-full h-[182px] bg-no-repeat bg-cover px-12 py-3 flex justify-between"
:style="{ backgroundImage: getBackgroundImage('vlog/practice/评分条') }"
>
<div class="pr-8 space-y-10">
<h4 class="text-3xl font-bold text-white mb-3">
{{ `0${index + 1}` }} {{ item.title }}
</h4>
<p class="text-3xl text-gray-800 font-bold leading-relaxed">
{{ item.description }}
</p>
</div>
<StarRating class="absolute -bottom-5 right-10" :score="item.score" size="lg" />
</div>
<div class="w-full flex justify-center">
<ImgButton image="vlog/practice/保存" @click="showModal = false" />
</div>
</div>
</div>
</div>
</div>
</Overlay>
</div>
</template>
Whitespace-only changes.
@@ -0,0 +1,88 @@
<script lang="ts" setup>
import { IMAGE_PATHS } from '@/utils'
import { ImgButton } from '@/components/Button'
import { ref } from 'vue'
import sample from './sample.vue'
import writing from './writing.vue'
import { useRouter } from 'vue-router'
import { getStaticImage } from '@/utils'
const router = useRouter()
const writingtext = ref('撰写格式')
const hiddenbar = ref(false)
const formatClick = () => {
writingtext.value = '撰写格式'
}
const sampleanClick = () => {
writingtext.value = '样例展示'
// router.push({ name: 'sample' })
}
const hiddenbarclick = () => {
hiddenbar.value = !hiddenbar.value
}
const back = () => {
// 跳转设备准备
router.back()
}
</script>
<template>
<div class="relative w-full h-full">
<!-- 返回按钮 -->
<ImgButton
class="absolute right-0 top-0 z-10"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="back"
/>
<!-- 侧边栏 -->
<div
:class="[
'absolute h-full left-0 top-0 transition-transform duration-300 z-10',
hiddenbar ? 'translate-x-[-400px]' : 'translate-x-[0px]'
]"
>
<img class="absolute inset-0 -z-10" src="@/assets/images/equipment/侧边导航栏.png" alt="" />
<!-- 脚本撰写 -->
<img class="" :src="getStaticImage(IMAGE_PATHS.VLOG.SCRIPTINGTITLE)" />
<img
class="absolute bottom-4 left-6"
src="@/assets/images/vlog/制作变焦镜/左边导航栏/ai分镜工具.png"
alt=""
/>
<div class="absolute left-0 top-36">
<ImgButton
:image="
writingtext == '撰写格式' ? IMAGE_PATHS.VLOG.FORMATLIANG : IMAGE_PATHS.VLOG.FORMATAN
"
@click="formatClick"
/>
<ImgButton
:image="
writingtext == '样例展示' ? IMAGE_PATHS.VLOG.SAMPLELANG : IMAGE_PATHS.VLOG.SAMPLEAN
"
@click="sampleanClick"
/>
</div>
</div>
<div
:class="[
'absolute transition-transform duration-200 top-1/2 -translate-y-1/2 z-20',
hiddenbar ? ' translate-x-[-38px]' : 'absolute translate-x-[270px]'
]"
@click="hiddenbarclick()"
>
<ImgButton :image="hiddenbar ? 'equipment/按钮-展开' : 'equipment/按钮-收缩'" />
</div>
<div class="absolute w-full h-full left-0 top-0">
<writing v-if="writingtext == '撰写格式'"></writing>
<sample v-else-if="writingtext == '样例展示'"></sample>
</div>
</div>
</template>
@@ -0,0 +1,180 @@
<script lang="tsx" setup>
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ImagePreview } from '@/components'
import { useScale } from '@/composables/useScale'
import { useInterviewStore } from '@/stores'
const router = useRouter()
const showDoubao = ref(false)
const interview = useInterviewStore()
const steps = [
{ id: 'step1', content: '先认识 Vlog:点击右侧「1.vlog初识」继续' },
{ id: 'step2', content: '这是主要内容区域的边框' }
]
const stepIndex = ref(1)
const back = () => {
router.back()
}
interface SimpleStep {
id: string
content: string
}
const handleStepChange = (_step: SimpleStep | null, index: number) => {
stepIndex.value = index
}
const currentHighlight = computed(() =>
stepIndex.value === 0 ? '.img-switch-item--vlog' : '.work-border'
)
const clickdoubao = () => {
interview.setInterviewUnlockProgress(2)
router.push({
name: 'Interview'
})
}
function openImages(urls: string[], startIndex = 0) {
ImagePreview({
images: urls,
startIndex,
maskClosable: true,
getScaleMetrics: () => ({ scale: scale.value, offsetX: offsetX.value, offsetY: offsetY.value }),
onClose: () => {
showDoubao.value = true
}
})
}
const { scale, offsetX, offsetY } = useScale()
onMounted(() => {
openImages([getStaticImage('vlog/脚本切图/我的志愿者手记拍摄脚本.png')])
})
</script>
<template>
<div class="writing-index">
<!-- 背景图片 -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat writing-bg"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.BIAOGEBG) }"
>
<div>
<div class="writing-title">
<img src="@/assets/images/vlog/标题栏-中间.png" alt="" class="titledibu" />
<img
src="@/assets/images/vlog/制作变焦镜/文字标题/《我的志愿者手记》分镜头脚本.png"
alt=""
class="titletext"
/>
</div>
<div class="doubao" @click="clickdoubao">
<img
src="@/assets/images/vlog/制作变焦镜/ai脚本视频占位图.png"
alt=""
class="doubao-img"
/>
</div>
<div class="shooting-return">
<img src="@/assets/images/shooting/返回.png" alt="" @click="back" />
</div>
</div>
<!-- <div v-else>
<div class="writing-title">
<img src="@/assets/images/vlog/标题栏-中间.png" alt="" class="titledibu" />
<img
src="@/assets/images/vlog/制作变焦镜/文字标题/《我的志愿者手记》分镜头脚本.png"
alt=""
class="titletext"
/>
</div>
<div class="biaogehome" @click="deletebiaoge()">
<img
src="@/assets/images/vlog/脚本切图/我的志愿者手记拍摄脚本.png"
alt=""
class="biaoge-img"
/>
<img src="@/assets/images/midterm/叉.png" alt="" class="biaoge-x" />
</div>
<div class="shooting-return">
<img src="@/assets/images/shooting/返回.png" alt="" @click="back" />
</div>
</div> -->
<!-- 引导步骤 -->
<GuideSteps
v-if="showDoubao"
:steps="steps"
:highlight-selector="currentHighlight"
:highlight-padding="12"
:highlight-radius="20"
@step-change="handleStepChange"
position-offset-y="85%"
>
<GuideStepsItem>
<img src="@/assets/images/components/引导/ai脚本工具引导.png" alt="" />
</GuideStepsItem>
</GuideSteps>
</div>
</div>
</template>
<style lang="less" scoped>
.writing-index {
width: 100%;
height: 100%;
}
.shooting-return {
position: absolute;
right: -90%;
top: 0;
width: 50%;
height: 50%;
}
.doubao .doubao-img {
width: 100%;
height: 100%;
}
.biaoge-x {
position: absolute;
top: -20px;
right: 0px;
width: 70px;
cursor: pointer;
z-index: 9999999;
}
.writing-title {
.titledibu {
position: absolute;
top: 17px;
left: 30%;
}
.titletext {
position: absolute;
top: 30px;
left: 40%;
}
}
.biaogehome {
position: absolute;
top: 165px;
left: 5%;
width: 94%;
}
.biaogehome .biaoge-img {
height: 896px !important;
width: 1800px;
display: block;
}
</style>
@@ -0,0 +1,61 @@
<template>
<div class="writing-index">
<!-- 背景图片 -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat writing-bg"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.SCRIPTING) }"
>
<div class="geshi-title">
<img src="@/assets/images/vlog/标题栏-中间.png" alt="" class="titledibu" />
<img
src="@/assets/images/vlog/制作变焦镜/文字标题/脚本的概念和基本要素.png"
alt=""
class="titletext"
/>
</div>
<video :src="fourth" controls></video>
<!-- <div class="shooting-return">
<img src="@/assets/images/shooting/返回.png" alt="" @click="back" />
</div> -->
<!-- 引导步骤 -->
<!-- <GuideSteps :steps="steps" :highlight-selector="currentHighlight" :highlight-padding="12"
:highlight-radius="20" @step-change="handleStepChange" position-offset-y="85%" hidden:false>
<GuideStepsItem>
<img src="@/assets/images/equipment/设备准备引导1.png" alt="" />
</GuideStepsItem>
</GuideSteps> -->
</div>
</div>
</template>
<script lang="tsx" setup>
import { getBackgroundImage, IMAGE_PATHS } from '@/utils'
import fourth from '/video/vlog_renwuqujing_jiaobencehua.mp4'
</script>
<style lang="less" scoped>
.writing-index {
width: 100%;
height: 100%;
}
.geshi-title {
.titledibu {
position: absolute;
top: 17px;
left: 30%;
}
.titletext {
position: absolute;
top: 30px;
left: 45%;
}
}
.shooting-return {
position: absolute;
right: -90%;
top: 0;
width: 100%;
height: 100%;
}
</style>
+229
View File
@@ -0,0 +1,229 @@
<script lang="ts" setup>
import { computed, nextTick, onMounted, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue'
import { GuideSteps, StepNavigation } from '@/components/Guide'
import { Video } from '@/components'
import { getStaticImage, IMAGE_PATHS } from '@/utils'
import second from '/video/vlog_renwuqujing_jichujingbieyunyong.mp4'
import third from '/video/vlog_renwuqujing_huamiangoutu.mp4'
import fourth from '/video/vlog_renwuqujing_jiaobencehua.mp4'
import five from '/video/vlog_renwuqujing_zhuapai.mp4'
import six from '/video/vlog_renwuqujing_jianjipingjie.mp4'
import { useInterviewStore } from '@/stores'
const router = useRouter()
const route = useRoute()
const interview = useInterviewStore()
// 受控播放开关(用于切换到下一个视频时自动播放)
const playing = ref(false)
const status = ref(false)
//引导步骤
const steps = ref([
{
id: 'step1',
content: '试着用这些方法记录身边的小事,你会发现,原来平凡的日子里有这么多值得捕捉的瞬间。'
}
])
// 步骤导航数据
const navigationSteps = ref([
{
id: 'step1',
label: '拍摄执行',
content: '基础景别应用',
type: 'shooting' as const
},
{
id: 'step2',
label: '拍摄执行',
content: '画面构图',
type: 'shooting' as const
},
{
id: 'step3',
label: '内容策划',
content: '简单脚本策划',
type: 'planning' as const
},
{
id: 'step4',
label: '拍摄执行',
content: '动态事件跟拍与抓拍',
type: 'shooting' as const
},
{
id: 'step5',
label: '后期制作',
content: '素材导入、剪辑拼接',
type: 'post' as const
}
])
// 步骤导航相关状态
const showStepNavigation = ref(false)
const currentNavigationStep = ref(1) // 对应视频索引,从0开始
// 视频列表
const videoList = [second, third, fourth, five, six]
const currentVideoIndex = ref(0)
//按顺序播放
const currentVideo = computed(() => videoList[currentVideoIndex.value])
const resolvedSrc = computed(() => {
if (key.value === 'case') return '/video/anlishangxi_meishi_xiaoyuanzi.mp4'
if (key.value === 'profession') return currentVideo.value
return ''
})
const handleVideoChange = () => {
if (currentVideoIndex.value < videoList.length - 1) {
currentVideoIndex.value++
currentNavigationStep.value = currentVideoIndex.value
} else {
status.value = true
steps.value = [
{
id: 'step1',
content: '恭喜你,已完成专业剖析的学习,点击【查看脚本】回顾视频脚本吧!'
}
]
return
}
nextTick(() => {
// 使用受控播放来触发下一个视频自动播放
playing.value = true
})
}
const handleClose = () => {
router.back()
}
const image = computed(() => route.query.image as string)
const key = computed(() => route.query.key as string)
const handleClick = () => {}
const handlePlay = () => {}
const onEnded = () => {
if (key.value === 'profession') handleVideoChange()
}
const handleComplete = () => {
if (key.value === 'profession') {
if (!interview.isInterviewUnlock) interview.setIsInterviewUnlock(true)
router.push({
name: 'InterviewWork'
})
} else {
if (interview.InterviewUnlockProgress == 0) interview.setInterviewUnlockProgress(1)
router.push({
name: 'Interview'
})
}
}
// 步骤导航事件处理
const handleStepClick = (index: number) => {
currentVideoIndex.value = index
currentNavigationStep.value = index
nextTick(() => {
playing.value = true
})
}
const handleStepNavigationClose = () => {
showStepNavigation.value = false
}
onMounted(() => {
if (key.value === 'idea') {
status.value = true
}
})
</script>
<template>
<div class="w-full h-full relative bg-white/10" @click="handleClick">
<template v-if="key !== 'case' && key !== 'profession'">
<ImgButton class="absolute left-4 top-4 z-10" :image="image"></ImgButton>
<ImgButton
class="absolute right-0 top-0 z-10"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleClose"
/>
</template>
<!-- 步骤导航按钮 -->
<button
v-if="key === 'profession'"
class="absolute right-4 top-1/2 z-10 -translate-y-1/2 w-12 h-12 bg-blue-500/80 hover:bg-blue-500 rounded-full flex items-center justify-center text-white transition-all duration-200"
@click="showStepNavigation = true"
title="步骤导航"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
d="M3 4H17M3 8H17M3 12H17M3 16H17"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</button>
<div v-if="key === 'vlog'" :src="image">
<img :src="getStaticImage('work/vlog背景全屏待定')" alt="" />
</div>
<Video
v-else-if="key === 'case' || key === 'profession'"
:src="resolvedSrc"
v-model="playing"
:muted="false"
:volume="1"
:controls="true"
:hideNativeControls="true"
@play="handlePlay"
@ended="onEnded"
>
<template v-slot:overlay>
<ImgButton
class="absolute left-4 top-4 z-10 pointer-events-auto"
:image="image"
></ImgButton>
<ImgButton
class="absolute right-0 top-0 z-10 pointer-events-auto"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleClose"
/>
</template>
</Video>
<div v-else-if="key === 'idea'" :src="image">
<img :src="getStaticImage('work/思路提点全屏')" alt="" />
</div>
</div>
<!-- 引导与高亮 -->
<GuideSteps v-if="status" :steps="steps" position-offset-y="85%" @complete="handleComplete" />
<!-- 步骤导航抽屉 -->
<StepNavigation
:visible="showStepNavigation && key === 'profession'"
:background-image="getStaticImage('work/vlog背景全屏待定')"
:steps="navigationSteps"
:current-step-index="currentNavigationStep"
@step-click="handleStepClick"
@close="handleStepNavigationClose"
/>
</template>
<style scoped>
video {
width: 100%;
height: 100%;
/* 核心:保持视频比例,多余部分被裁剪 */
object-fit: cover;
}
</style>
@@ -0,0 +1,66 @@
<template>
<ImageSwitchList
v-model="innerValue"
:items="items"
direction="vertical"
:item-class="itemClass"
@change="onChange"
/>
</template>
<script setup lang="ts">
import { ImageSwitchList } from '@/components/Switcher'
import { computed } from 'vue'
type WorkTab = 'vlog' | 'case' | 'profession' | 'idea'
interface Props {
modelValue?: WorkTab
itemClass?: string
}
interface Emits {
(e: 'update:modelValue', value: WorkTab): void
(e: 'change', value: WorkTab): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: 'case',
itemClass: 'w-[260px] h-[64px] rounded-[18px]'
})
const emit = defineEmits<Emits>()
const innerValue = computed({
get: () => props.modelValue as WorkTab,
set: (val: WorkTab) => emit('update:modelValue', val)
})
const items = [
// { key: 'vlog', activeImage: 'work/vlog初识-当前', inactiveImage: 'work/vlog初识-未选中' },
{
key: 'case',
label: '案例赏析',
activeImage: 'work/选中_带三角',
inactiveImage: 'work/未选中_带三角'
},
{
key: 'profession',
label: '专业剖析',
activeImage: 'work/选中_带三角',
inactiveImage: 'work/未选中_带三角'
},
{
key: 'idea',
label: '思路提点',
activeImage: 'work/选中_带三角',
inactiveImage: 'work/未选中_带三角'
}
]
const onChange = (val: string) => emit('change', val as WorkTab)
defineExpose({
items
})
</script>
+197
View File
@@ -0,0 +1,197 @@
<script lang="tsx" name="Work" setup>
defineOptions({ name: 'Work' })
import { ref, computed } from 'vue'
import { getBackgroundImage, IMAGE_PATHS, getStaticImage } from '@/utils'
import { ImgButton } from '@/components/Button'
import WorkSideSwitcher from './components/WorkSideSwitcher.vue'
import { GuideSteps } from '@/components/Guide'
import { useRouter } from 'vue-router'
import { useScale } from '@/composables/useScale'
import { ImagePreview } from '@/components'
import { useInterviewStore } from '@/stores'
const router = useRouter()
const interview = useInterviewStore()
type WorkTab = 'vlog' | 'case' | 'profession' | 'idea'
const selected = ref<WorkTab>('case')
const stepIndex = ref(0)
const workSideSwitcherRef = ref<InstanceType<typeof WorkSideSwitcher>>()
const isGuideInterviewTask = computed(() => interview.isGuideInterviewTask)
const isInterviewUnlock = computed(() => interview.isInterviewUnlock)
//引导步骤
const steps = [
{
id: 'step1',
content:
'现在我们要一起走进 Vlog 的世界。简单说,Vlog 就是用视频记录生活的一种方式 —— 它像一本会动的日记,能把我们身边的小事、瞬间的心情,通过镜头变得生动又具体。'
},
{ id: 'step2', content: '接下来,我们就通过一个具体的案例,看看 Vlog 是怎么“捕捉美好” 的吧。' }
]
//切换图片
const sideSwitcherItems = ref([
{
key: 'vlog',
description: '记录身边的美好',
image: 'work/vlog背景全屏待定'
},
{
key: 'case',
description: '案例赏析-桂花酒酿小圆子',
image: 'work/案例背景待定'
},
{
key: 'profession',
description: '专业剖析-桂花酒酿小圆子',
image: 'work/专业剖析小屏'
},
{
key: 'idea',
description: '思路提点-桂花酒酿小圆子',
image: 'work/思路提点小屏'
}
])
//当前选中的item
const currentItem = computed(() => {
return sideSwitcherItems.value.find(item => item.key === selected.value)
})
const currentHighlight = computed(() =>
stepIndex.value === 0 ? '.img-switch-item--case' : '.work-border'
)
interface SimpleStep {
id: string
content: string
}
const handleStepChange = (_step: SimpleStep | null, index: number) => {
stepIndex.value = index
}
const handleSideSwitcherChange = (value: WorkTab) => {
if (selected.value === value) return
}
const handleImageClick = () => {
router.push({
name: 'FilmVideo',
query: {
image:
workSideSwitcherRef.value?.items.find(item => item.key === selected.value)?.activeImage ||
'',
key: selected.value
}
})
}
const handleClose = () => {
router.push({ name: 'Film' })
}
const handleWorkClick = () => {
if (!isInterviewUnlock.value) return
openImages([getStaticImage('work/桂花酒酿小圆子')])
}
const handleGuideComplete = () => {
interview.setIsGuideInterviewTask(false)
}
const handleOpenGuide = () => {
stepIndex.value = 0
interview.setIsGuideInterviewTask(true)
}
const { scale, offsetX, offsetY } = useScale()
function openImages(urls: string[], startIndex = 0) {
ImagePreview({
images: urls,
startIndex,
maskClosable: true,
getScaleMetrics: () => ({ scale: scale.value, offsetX: offsetX.value, offsetY: offsetY.value })
})
}
</script>
<template>
<div class="w-full h-full relative">
<!-- 背景图片-center -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.WORK.BG) }"
></div>
<!-- 关闭 -->
<ImgButton
class="absolute right-0 top-0"
:image="IMAGE_PATHS.COMPONENTS.CLOSE"
@click="handleClose"
/>
<!-- border图片 -->
<div
class="work-border absolute top-[140px] left-24 inset-0 bg-cover bg-center bg-no-repeat z-10 w-[1360px] h-[800px] p-10"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.WORK.BORDER) }"
>
<div
class="flex items-center pl-16 w-[1357px] h-[265px] absolute -bottom-16 left-0 bg-cover bg-center bg-no-repeat z-20"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.WORK.TITLE) }"
>
<span
class="mt-24 text-white font-bold text-6xl [text-shadow:0_2px_4px_rgba(0,0,0,0.2),0_0_6px_rgba(0,207,255,0.9),0_0_14px_rgba(0,207,255,0.7)]"
>
{{ currentItem?.description }}</span
>
</div>
<img
@click="handleImageClick"
class="rounded-[62px] cursor-pointer"
:src="getStaticImage(currentItem?.image || '')"
alt=""
/>
</div>
<!-- 左侧选项切换 -->
<div class="absolute right-4 top-56 z-20">
<WorkSideSwitcher
ref="workSideSwitcherRef"
v-model="selected"
@change="handleSideSwitcherChange"
:item-class="'w-[359px] h-[96px]'"
/>
</div>
<!-- 任务取景按钮 -->
<ImgButton
class="absolute right-14 bottom-10"
:image="isInterviewUnlock ? IMAGE_PATHS.WORK.TASK_BTN_OK : IMAGE_PATHS.WORK.TASK_BTN"
@click="handleWorkClick"
/>
<!-- 引导与高亮 -->
<GuideSteps
v-if="isGuideInterviewTask"
:steps="steps"
:highlight-selector="currentHighlight"
:highlight-padding="12"
:highlight-radius="20"
@step-change="handleStepChange"
@complete="handleGuideComplete"
@skip="handleGuideComplete"
position-offset-y="85%"
/>
<!-- 引导词按钮 -->
<ImgButton
class="absolute w-[120px] bottom-0 right-0 z-10"
image="components/long"
@click="handleOpenGuide"
/>
</div>
</template>
@@ -1,30 +0,0 @@
<script lang="ts" setup>
import { useRouter } from 'vue-router'
const router = useRouter()
const handleClicka2 = () => {
// 跳转设备准备
router.push({ name: 'a-2' })
}
const back = () => {
// 跳转设备准备
router.back()
}
</script>
<template>
<div>
<img src="@/assets/images/shooting/取景地a合图1.png" alt="" @click="handleClicka2" />
</div>
<div class="shooting-return">
<img src="@/assets/images/shooting/返回.png" alt="" @click="back" />
</div>
</template>
<style>
.shooting-return {
position: absolute;
right: -90%;
top: 0;
width: 100%;
height: 100%;
}
</style>
@@ -1,30 +0,0 @@
<script lang="ts" setup>
import { useRouter } from 'vue-router'
const router = useRouter()
const handleClicka3 = () => {
// 跳转设备准备
router.push({ name: 'a-3' })
}
const back = () => {
// 跳转设备准备
router.back()
}
</script>
<template>
<div>
<img src="@/assets/images/shooting/取景地a合图2.png" alt="" @click="handleClicka3" />
</div>
<div class="shooting-return">
<img src="@/assets/images/shooting/返回.png" alt="" @click="back" />
</div>
</template>
<style>
.shooting-return {
position: absolute;
right: -90%;
top: 0;
width: 100%;
height: 100%;
}
</style>
@@ -1,30 +0,0 @@
<script lang="ts" setup>
import { useRouter } from 'vue-router'
const router = useRouter()
// const handleClicka2 = () => {
// // 跳转设备准备
// router.push({ name: 'a-2' })
// }
const back = () => {
// 跳转设备准备
router.back()
}
</script>
<template>
<div>
<img src="@/assets/images/shooting/取景地a合图3.png" alt="" />
</div>
<div class="shooting-return">
<img src="@/assets/images/shooting/返回.png" alt="" @click="back" />
</div>
</template>
<style>
.shooting-return {
position: absolute;
right: -90%;
top: 0;
width: 100%;
height: 100%;
}
</style>
@@ -1,65 +0,0 @@
<script lang="ts" setup>
import { getBackgroundImage, IMAGE_PATHS } from '@/utils'
import { useRouter } from 'vue-router'
const router = useRouter()
const handleClicka1 = () => {
// 跳转设备准备
router.push({ name: 'a-1' })
}
const back = () => {
// 跳转设备准备
router.back()
}
</script>
<template>
<div class="shooting-index">
<!-- 背景图片 -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat shooting-bg"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.FILMINGLOCATIONA.BG) }"
>
<div class="start">
<img src="@/assets/images/shooting/开始取景.png" alt="" />
</div>
<div class="text">
<img src="@/assets/images/shooting/按钮-开始取景.png" alt="" @click="handleClicka1" />
</div>
<div class="shooting-return">
<img src="@/assets/images/shooting/返回.png" alt="" @click="back" />
</div>
</div>
</div>
</template>
<style lang="less">
.shooting-index {
position: absolute;
width: 100%;
height: 100%;
.start {
position: absolute;
left: 0;
bottom: -29%;
width: 100%;
height: 100%;
}
.text {
position: absolute;
left: 40%;
bottom: -56%;
width: 100%;
height: 100%;
}
.shooting-return {
position: absolute;
right: -90%;
top: 0;
width: 100%;
height: 100%;
}
}
</style>
@@ -1,31 +0,0 @@
<script lang="ts" setup>
import { useRouter } from 'vue-router'
const router = useRouter()
const back = () => {
// 跳转设备准备
router.back()
}
const handleClickb2 = () => {
// 跳转设备准备
router.push({ name: 'b-2' })
}
</script>
<template>
<div>
<img src="@/assets/images/shooting/取景地b合图1.png" alt="" @click="handleClickb2" />
</div>
<div class="shooting-return">
<img src="@/assets/images/shooting/返回.png" alt="" @click="back" />
</div>
</template>
<style>
.shooting-return {
position: absolute;
right: -90%;
top: 0;
width: 100%;
height: 100%;
}
</style>
@@ -1,30 +0,0 @@
<script lang="ts" setup>
import { useRouter } from 'vue-router'
const router = useRouter()
const handleClickb3 = () => {
// 跳转设备准备
router.push({ name: 'b-3' })
}
const back = () => {
// 跳转设备准备
router.back()
}
</script>
<template>
<div>
<img src="@/assets/images/shooting/取景地b合图2.png" alt="" @click="handleClickb3" />
</div>
<div class="shooting-return">
<img src="@/assets/images/shooting/返回.png" alt="" @click="back" />
</div>
</template>
<style>
.shooting-return {
position: absolute;
right: -90%;
top: 0;
width: 100%;
height: 100%;
}
</style>
@@ -1,34 +0,0 @@
<script lang="ts" setup>
import { useRouter } from 'vue-router'
const router = useRouter()
// const handleClickb2 = () => {
// // 跳转设备准备
// router.push({ name: 'b-2' })
// }
const back = () => {
// 跳转设备准备
router.back()
}
const tiaohouqi = () => {
router.push('afterditing')
}
</script>
<template>
<div>
<img src="@/assets/images/shooting/取景地b合图3.png" alt="" @click="tiaohouqi" />
</div>
<div class="shooting-return">
<img src="@/assets/images/shooting/返回.png" alt="" @click="back" />
</div>
</template>
<style>
.shooting-return {
position: absolute;
right: -90%;
top: 0;
width: 100%;
height: 100%;
}
</style>
@@ -1,64 +0,0 @@
<script lang="ts" setup>
import { getBackgroundImage, IMAGE_PATHS } from '@/utils'
import { useRouter } from 'vue-router'
const router = useRouter()
const handleClickb1 = () => {
// 跳转设备准备
router.push({ name: 'b-1' })
}
const back = () => {
// 跳转设备准备
router.back()
}
</script>
<template>
<div class="shooting-index">
<!-- 背景图片 -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat shooting-bg"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.FILMINGLOCATIONA.BG) }"
>
<div class="start">
<img src="@/assets/images/shooting/开始取景.png" alt="" />
</div>
<div class="text">
<img src="@/assets/images/shooting/按钮-开始取景.png" alt="" @click="handleClickb1" />
</div>
<div class="shooting-return">
<img src="@/assets/images/shooting/返回.png" alt="" @click="back" />
</div>
</div>
</div>
</template>
<style lang="less">
.shooting-index {
position: absolute;
width: 100%;
height: 100%;
.start {
position: absolute;
left: 0;
bottom: -29%;
width: 100%;
height: 100%;
}
.text {
position: absolute;
left: 40%;
bottom: -56%;
width: 100%;
height: 100%;
}
.shooting-return {
position: absolute;
right: -90%;
top: 0;
width: 100%;
height: 100%;
}
}
</style>
+17
View File
@@ -0,0 +1,17 @@
<script lang="tsx" setup>
import { useRouter } from 'vue-router'
const router = useRouter()
const handleGoHome = () => {
router.push('/')
}
</script>
<template>
<div class="bg-white h-full flex flex-col items-center justify-center space-y-2">
<h1 class="text-4xl font-bold">404</h1>
<p class="text-2xl">页面不存在</p>
<button @click="handleGoHome">返回首页</button>
</div>
</template>
-190
View File
@@ -1,190 +0,0 @@
<script lang="tsx" setup>
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
import { ImgButton } from '@/components/Button'
import { ref, computed } from 'vue'
import { ProgressBar } from '@/components'
import { useRouter } from 'vue-router'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
const steps = [
{
id: 'step1',
content:
'引导词:前期准备要做好四件事,【资源筹备】【创意构想】【脚本撰写】【分镜制作】,画面中高亮的按钮是我们可以展开学习的内容。点击进入学习吧!'
},
{ id: 'step2', content: '这是主要内容区域的边框' }
]
// const selected = ref<WorkTab>('vlog')
const stepIndex = ref(0)
const currentHighlight = computed(() =>
stepIndex.value === 0 ? '.img-switch-item--vlog' : '.work-border'
)
const router = useRouter()
const currentStep = ref(1)
const handleComplete = () => {
console.log('完成前置条件')
// 处理完成逻辑
}
const handleStepChange = (step: number) => {
console.log('步骤变化:', step)
// 处理步骤变化逻辑
}
const handleWorkClick = () => {
console.log('任务取景按钮点击')
// 处理任务取景按钮点击逻辑
router.push({ name: 'Work' })
}
interface SimpleStep {
id: string
content: string
}
const handleStepChangeGui = (_step: SimpleStep | null, index: number) => {
stepIndex.value = index
}
const highlightShow = ref(true)
// const StoryboardButton = ref()
const SelectArrowtext = ref('脚本撰写')
const ScriptingClick = (routeName: string) => {
SelectArrowtext.value = '脚本撰写'
router.push({ name: routeName })
}
const StoryboardClick = (routeName: string) => {
SelectArrowtext.value = '分镜制作'
router.push({ name: routeName })
// IMAGE_PATHS.VLOG.Storyboard
// IMAGE_PATHS.VLOG.SelectedStoryboard
}
const currentPage = ref(0)
</script>
<template>
<div class="w-full h-full relative">
<!-- 背景图片-center -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.BG_CENTER) }"
></div>
<!-- 背景图片 -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.BG) }"
></div>
<!-- 标题 -->
<div class="absolute top-2 left-1/2 -translate-x-1/2">
<ImgButton :image="IMAGE_PATHS.VLOG.TITLE" />
</div>
<!-- 任务取景按钮 -->
<div
class="absolute left-24 top-24"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.TASK_CAMERA) }"
>
<ImgButton :image="IMAGE_PATHS.VLOG.TASK_BTN_OK" />
</div>
<!-- 前期准备底板 -->
<div v-if="highlightShow" class="absolute right-90 ml-[135px]">
<img :src="getStaticImage(IMAGE_PATHS.VLOG.PREPARE)" />
</div>
<!-- 脚本撰写按钮 -->
<div v-if="highlightShow" class="absolute left-[13%] top-1/3">
<ImgButton
:image="
SelectArrowtext == '脚本撰写'
? IMAGE_PATHS.VLOG.SelectedScripting
: IMAGE_PATHS.VLOG.Scripting
"
@click="ScriptingClick('script-writing')"
/>
</div>
<!-- 选中箭头 -->
<div
v-if="highlightShow"
class="absolute left-[13%] top-[37%] -translate-x-1/2"
:class="SelectArrowtext == '脚本撰写' ? 'SelectArrow-Scripting' : 'SelectArrow-Storyboard'"
>
<ImgButton :image="IMAGE_PATHS.VLOG.SelectArrow" />
</div>
<!-- 分镜制作 -->
<div v-if="highlightShow" class="absolute left-[20%] top-[47%] -translate-x-1/2">
<ImgButton
:image="
SelectArrowtext == '分镜制作'
? IMAGE_PATHS.VLOG.SelectedStoryboard
: IMAGE_PATHS.VLOG.Storyboard
"
@click="StoryboardClick"
/>
</div>
<!-- 进度百分比 -->
<div v-if="highlightShow" class="absolute left-1/2 top-[12%] -translate-x-1/2">
<div class="flex">
<ImgButton :image="IMAGE_PATHS.VLOG.Progress" />
<ImgButton :image="IMAGE_PATHS.VLOG.Four" />
<ImgButton :image="IMAGE_PATHS.VLOG.Zero" />
<ImgButton :image="IMAGE_PATHS.VLOG.Percentage" />
</div>
</div>
<!-- 引导任务 -->
<!-- <ImgButton class="absolute right-24 bottom-24" :image="IMAGE_PATHS.VLOG.TASK_PERSON" /> -->
<div class="absolute bottom-0 w-full">
<ProgressBar
:current-step="currentStep"
@complete="handleComplete"
@step-change="handleStepChange"
/>
</div>
<GuideSteps
:steps="steps"
:highlight-selector="currentHighlight"
:highlight-padding="12"
:highlight-radius="20"
@step-change="handleStepChangeGui"
>
<GuideStepsItem>
<img src="@/assets/images/midterm/前期准备引导1.png" alt="" />
</GuideStepsItem>
</GuideSteps>
</div>
<!-- 叉 -->
<div
v-if="highlightShow"
class="absolute right-0 top-0"
style="z-index: 998; cursor: pointer"
@click="router.back()"
>
<img src="@/assets/images/midterm/叉.png" alt="" />
</div>
</template>
<style>
.guide-ui {
top: 80% !important;
}
.dragon-character {
top: 80% !important;
}
.SelectArrow-Scripting {
position: absolute;
left: 13%;
top: 37%;
}
.SelectArrow-Storyboard {
position: absolute;
left: 11%;
top: 51%;
}
</style>
-127
View File
@@ -1,127 +0,0 @@
<script lang="ts" setup>
import { IMAGE_PATHS, getStaticImage } from '@/utils'
import { ImgButton } from '@/components/Button'
import { useRouter } from 'vue-router'
import { ref } from 'vue'
import sample from './sample.vue'
import writing from './writing.vue'
const router = useRouter()
const writingtext = ref('撰写格式')
const hiddenbar = ref(false)
const formatClick = () => {
writingtext.value = '撰写格式'
}
const sampleanClick = () => {
writingtext.value = '样例展示'
// router.push({ name: 'sample' })
}
const hiddenbarclick = () => {
hiddenbar.value = !hiddenbar.value
}
</script>
<template>
<div class="writing-bar">
<img src="@/assets/images/equipment/侧边导航栏.png" alt="" :hidden="hiddenbar" />
<div class="writing-item" :hidden="hiddenbar">
<ImgButton
:image="
writingtext == '撰写格式' ? IMAGE_PATHS.VLOG.FORMATLIANG : IMAGE_PATHS.VLOG.FORMATAN
"
@click="formatClick"
/>
<ImgButton
:image="writingtext == '样例展示' ? IMAGE_PATHS.VLOG.SAMPLELANG : IMAGE_PATHS.VLOG.SAMPLEAN"
@click="sampleanClick"
/>
<img
class="writing-ai"
src="@/assets/images/vlog/制作变焦镜/左边导航栏/ai分镜工具.png"
alt=""
/>
</div>
<!-- 设备准备按钮 -->
<div class="writing-bt">
<ImgButton :image="IMAGE_PATHS.VLOG.SCRIPTINGTITLE" />
</div>
<div :class="hiddenbar ? 'writing-unfold1' : 'writing-unfold2'" @click="hiddenbarclick()">
<ImgButton :image="hiddenbar ? 'equipment/按钮-展开' : 'equipment/按钮-收缩'" />
</div>
</div>
<writing v-if="writingtext == '撰写格式'"></writing>
<sample v-else-if="writingtext == '样例展示'"></sample>
</template>
<style lang="less">
.writing-bar {
position: absolute;
top: 0;
left: 0;
z-index: 10;
.writing-bt {
position: absolute;
top: 0;
left: -34px;
width: 476px;
img {
width: 100%;
height: 100%;
}
}
.writing-unfold1 {
position: absolute;
top: 490px;
left: -41px;
width: 150px;
img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.writing-unfold2 {
position: absolute;
top: 490px;
left: 268px;
width: 150px;
img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.writing-item {
position: absolute;
top: 0;
left: 0;
padding-top: 220px;
padding-left: 20px;
span {
display: flex;
}
}
.guide-ui {
top: 80% !important;
}
.dragon-character {
top: 80% !important;
}
.writing-ai {
position: absolute;
bottom: -600px;
left: 10px;
// width: 100%;
}
}
</style>
-49
View File
@@ -1,49 +0,0 @@
<template>
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat equipment-bg"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.EQUIPMENT.BG) }"
>
<Sidebar
:menu-items="menuItems"
:default-active="defaultActive"
@item-selected="handleMenuItemSelected"
@toggle="handleSidebarToggle"
/>
</div>
</template>
<script setup lang="tsx">
import { ref } from 'vue'
import Sidebar from '@/components/Sidebar/Sidebar.vue'
import { getBackgroundImage, IMAGE_PATHS } from '@/utils'
import equipmentPhone from '@/assets/images/equipment/手机-亮.png'
import equipmentPhone2 from '@/assets/images/equipment/手机-暗.png'
const menuItems = ref([
{
activeIcon: equipmentPhone,
inactiveIcon: equipmentPhone2,
title: '手机'
},
{
activeIcon: equipmentPhone,
inactiveIcon: equipmentPhone2,
title: '手机2'
},
{
activeIcon: equipmentPhone,
inactiveIcon: equipmentPhone2,
title: '手机3'
}
])
const defaultActive = ref(0)
const handleMenuItemSelected = (index: number) => {
defaultActive.value = index
console.log('选中了菜单项:', index)
// 根据选中的菜单项更新页面内容
}
const handleSidebarToggle = (collapsed: boolean) => {
console.log('侧边栏状态:', collapsed ? '已折叠' : '已展开')
}
</script>
<style scoped lang="less"></style>
+48 -7
View File
@@ -1,6 +1,4 @@
<script setup lang="ts">
import { defineProps, defineEmits } from 'vue'
type CategoryName = '拍摄基础' | '内容策划' | '拍摄执行' | '后期制作' | '版权伦理意识'
const props = defineProps<{
@@ -15,7 +13,10 @@ const emit = defineEmits<{ (e: 'select', name: CategoryName): void }>()
<template>
<div
v-show="props.sideStatus || props.selectedCategory === '拍摄基础'"
class="module module-left-bottom module-vertical"
:class="[
'module module-left-bottom module-vertical',
{ active: props.selectedCategory === '拍摄基础' }
]"
@click="emit('select', '拍摄基础')"
>
<div
@@ -31,7 +32,10 @@ const emit = defineEmits<{ (e: 'select', name: CategoryName): void }>()
<div
v-show="props.sideStatus || props.selectedCategory === '内容策划'"
class="module module-left-middle module-vertical"
:class="[
'module module-left-middle module-vertical',
{ active: props.selectedCategory === '内容策划' }
]"
@click="emit('select', '内容策划')"
>
<div
@@ -47,7 +51,10 @@ const emit = defineEmits<{ (e: 'select', name: CategoryName): void }>()
<div
v-show="props.sideStatus || props.selectedCategory === '拍摄执行'"
class="module module-right-middle module-vertical"
:class="[
'module module-right-middle module-vertical',
{ active: props.selectedCategory === '拍摄执行' }
]"
@click="emit('select', '拍摄执行')"
>
<div
@@ -63,7 +70,10 @@ const emit = defineEmits<{ (e: 'select', name: CategoryName): void }>()
<div
v-show="props.sideStatus || props.selectedCategory === '后期制作'"
class="module module-right-upper module-vertical"
:class="[
'module module-right-upper module-vertical',
{ active: props.selectedCategory === '后期制作' }
]"
@click="emit('select', '后期制作')"
>
<div
@@ -79,7 +89,10 @@ const emit = defineEmits<{ (e: 'select', name: CategoryName): void }>()
<div
v-show="props.sideStatus || props.selectedCategory === '版权伦理意识'"
class="module module-right-far module-vertical"
:class="[
'module module-right-far module-vertical',
{ active: props.selectedCategory === '版权伦理意识' }
]"
@click="emit('select', '版权伦理意识')"
>
<div
@@ -102,49 +115,61 @@ const emit = defineEmits<{ (e: 'select', name: CategoryName): void }>()
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;
@@ -157,6 +182,7 @@ const emit = defineEmits<{ (e: 'select', name: CategoryName): void }>()
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;
@@ -168,4 +194,19 @@ const emit = defineEmits<{ (e: 'select', name: CategoryName): void }>()
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>
-2
View File
@@ -1,5 +1,4 @@
<script setup lang="ts">
import { defineProps, defineEmits, computed } from 'vue'
import { ImgButton } from '@/components/Button'
const props = defineProps<{
@@ -15,7 +14,6 @@ const emit = defineEmits<{ (e: 'iconClick', icon: string): void }>()
<ImgButton
v-for="(icon, index) in props.displayedIcons"
:key="index"
v-show="!props.sideStatus"
@click="emit('iconClick', icon)"
class="absolute z-10"
:style="{ top: props.positions[index]?.top, left: props.positions[index]?.left }"
@@ -1,5 +1,4 @@
<script setup lang="ts">
import { defineEmits, defineProps } from 'vue'
import { ImgButton } from '@/components/Button'
const props = defineProps<{ moduleName: string }>()
+14 -17
View File
@@ -68,33 +68,28 @@ const onPrevModule = () => {
// 分类选择(保持原交互)
const onCategorySelect = (categoryName: string) => {
handleCategoryClick(categoryName)
sideStatus.value = false
// 如果已解锁(status=true),则不隐藏侧边栏;否则隐藏
sideStatus.value = status.value ? true : false
showSubIcons.value = false
selectedPrimaryIcon.value = null
}
// 返回按钮相关
const handleBackToMain = () => {
selectedCategory.value = ''
sideStatus.value = true
showSubIcons.value = false
selectedPrimaryIcon.value = null
}
const backButtonTooltip = computed(() => {
if (showSubIcons.value) return '返回上一步'
return selectedCategory.value ? '返回主视图' : '退出页面'
})
const handleClose = () => {
if (showSubIcons.value) {
showSubIcons.value = false
selectedPrimaryIcon.value = null
return
}
if (selectedCategory.value) {
handleBackToMain()
} else {
router.back()
}
// if (showSubIcons.value) {
// showSubIcons.value = false
// selectedPrimaryIcon.value = null
// return
// }
// if (selectedCategory.value) {
// handleBackToMain()
// } else {
router.back()
// }
}
// 知识卡片(本地维护)
@@ -276,10 +271,12 @@ const handleIconClick = (icon: string) => {
cursor: pointer;
align-items: flex-end;
}
.module-vertical .module-text {
writing-mode: vertical-rl;
text-orientation: upright;
}
.module-vertical .module-percent {
transform: translateY(6px);
}
+18 -63
View File
@@ -35,49 +35,24 @@ export function useSkillState() {
value: 1,
categories: {
拍摄基础: {
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: ['skill/画面构图未选中', 'skill/三分法构图', 'skill/居中构图', 'skill/对角线构图'],
positions: [
{ top: '36%', left: '23%' },
{ top: '15%', left: '34%' },
{ top: '20%', left: '45%' },
{ top: '28%', left: '58%' }
]
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: ['skill/画面构图未选中', 'skill/三分法构图', 'skill/居中构图', 'skill/对角线构图'],
positions: [
{ top: '36%', left: '23%' },
{ top: '15%', left: '34%' },
{ top: '20%', left: '45%' },
{ top: '28%', left: '58%' }
]
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: []
}
}
},
@@ -86,22 +61,12 @@ export function useSkillState() {
value: 2,
categories: {
拍摄基础: {
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: ['skill/画面构图未选中', 'skill/三分法构图', 'skill/居中构图', 'skill/对角线构图'],
positions: [
{ top: '36%', left: '23%' },
{ top: '15%', left: '34%' },
{ top: '20%', left: '45%' },
{ top: '28%', left: '58%' }
]
icons: [],
positions: []
},
拍摄执行: {
icons: ['skill/画面构图未选中', 'skill/景别', 'skill/运镜技巧', 'skill/光线运用'],
@@ -113,22 +78,12 @@ export function useSkillState() {
]
},
后期制作: {
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: ['skill/画面构图未选中', 'skill/景别', 'skill/运镜技巧', 'skill/光线运用'],
positions: [
{ top: '36%', left: '23%' },
{ top: '15%', left: '34%' },
{ top: '20%', left: '45%' },
{ top: '28%', left: '58%' }
]
icons: [],
positions: []
}
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { ref } from 'vue'
import { useRouter } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue'
import { IMAGE_PATHS } from '@/utils'
import six from '/video/新手导航_九种构图法.mp4'
import six from '/video/xinshou_jiuzhonggoutufa.mp4'
const router = useRouter()
@@ -0,0 +1,98 @@
<script lang="ts" setup>
import { onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue'
// import { GuideSteps } from '@/components/Guide'
import { IMAGE_PATHS } from '@/utils'
const router = useRouter()
// 导入视频文件
import videoadjustmentColor from '/video/vlog_renwuqujing_tiaose.mp4'
const videoList = [videoadjustmentColor]
const videoRef = ref<HTMLVideoElement>()
const currentVideoIndex = ref(0)
onMounted(() => {
// 确保视频元素加载并自动播放
if (videoRef.value) {
videoRef.value.src = videoList[currentVideoIndex.value]
videoRef.value.load()
// 尝试自动播放
const playPromise = videoRef.value.play()
if (playPromise !== undefined) {
playPromise.catch(error => {
// 自动播放失败,可能是浏览器策略阻止,需要用户交互
console.log('自动播放被阻止:', error)
})
}
// 监听视频结束事件
videoRef.value.addEventListener('ended', playNextVideo)
}
})
// 播放下一个视频
const playNextVideo = () => {
if (currentVideoIndex.value < videoList.length - 1) {
currentVideoIndex.value++
if (videoRef.value) {
videoRef.value.src = videoList[currentVideoIndex.value]
videoRef.value.load()
// 自动播放下一个视频
const playPromise = videoRef.value.play()
if (playPromise !== undefined) {
playPromise.catch(error => {
console.log('自动播放被阻止:', error)
})
}
}
}
}
// 监听当前视频索引变化
watch(currentVideoIndex, newIndex => {
if (videoRef.value) {
videoRef.value.src = videoList[newIndex]
videoRef.value.load()
}
})
const handleClose = () => {
router.back()
}
</script>
<template>
<div>
<!--使用动态绑定和ref-->
<video
ref="videoRef"
:src="videoList[currentVideoIndex]"
type="video/mp4"
controls
preload="auto"
class="video-player"
autoplay
>
<p>你的浏览器不支持video标签.</p>
</video>
<ImgButton
class="absolute right-0 top-0 z-10"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleClose"
/>
</div>
</template>
<style scoped>
.video-player {
width: 100%;
height: 100%;
object-fit: contain;
}
</style>
+304
View File
@@ -0,0 +1,304 @@
<script lang="ts" setup>
import router from '@/router'
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
import { ImgButton } from '@/components/Button'
import { Overlay } from '@/components/Overlay'
import { ref } from 'vue'
import ImageFilter from '@/components/ImageFilter/ImageFilter.vue'
import { useVlogStore } from '@/stores'
const selected = ref<number | undefined>(1)
const btnState = ref(false)
const screenshot = ref()
const showModal = ref(false)
const activeTab = ref<'color' | 'filter'>('color') // 'color' | 'filter'
const adjustmentList = ref([
{ name: '亮度', value: 0, min: -100, max: 100 },
{ name: '对比度', value: 0, min: -100, max: 100 },
{ name: '饱和度', value: 0, min: -100, max: 100 },
{ name: '曝光', value: 0, min: -100, max: 100 },
{ name: '色调', value: 0, min: 0, max: 100 },
{ name: '锐化', value: 0, min: 0, max: 100 },
{ name: '模糊', value: 0, min: 0, max: 100 }
])
const colorBarList = ref([
{ id: 1, name: '原图', filter: 'none' },
{ id: 2, name: '暖阳', filter: 'sepia(0.3) contrast(1.1) brightness(1.1)' },
{ id: 3, name: '冷调', filter: 'hue-rotate(180deg) contrast(0.9)' },
{ id: 4, name: '复古', filter: 'sepia(0.6) contrast(1.2) brightness(0.9)' },
{ id: 5, name: '黑白', filter: 'grayscale(1)' },
{ id: 6, name: '鲜艳', filter: 'saturate(1.5) contrast(1.1)' }
])
// 获取指定参数的值
// 获取指定参数的值
const imageFilterRef = ref<InstanceType<typeof ImageFilter> | null>(null)
// 获取指定参数的值
const getVal = (name: string) => {
const val = adjustmentList.value.find(item => item.name === name)?.value || 0
return Number(val)
}
// 更新滤镜效果
const updateFilter = () => {
if (!imageFilterRef.value) return
// 如果开启了对比模式(显示原图),则不应用任何滤镜
if (btnState.value) {
imageFilterRef.value.setOptions({
brightness: 0,
contrast: 0,
saturation: 0,
exposure: 0,
hue: 0,
blur: 0,
sharpen: 0,
filter: 'none'
})
return
}
const selectedFilter = colorBarList.value.find(v => v.id === selected.value)?.filter || 'none'
imageFilterRef.value.setOptions({
brightness: getVal('亮度'),
contrast: getVal('对比度'),
saturation: getVal('饱和度'),
exposure: getVal('曝光'),
hue: getVal('色调'),
blur: getVal('模糊'),
sharpen: getVal('锐化'),
filter: selectedFilter
})
}
const changeOriginalImgae = () => {
btnState.value = !btnState.value
updateFilter()
}
// Tab 切换处理
const handleTabChange = (tab: 'color' | 'filter') => {
activeTab.value = tab
// 切换时重置所有参数
handleReset()
}
// 重置所有参数
const handleReset = () => {
adjustmentList.value.forEach(item => (item.value = 0))
selected.value = 1
updateFilter()
}
// 生成图片
const handleGenerate = async () => {
if (!imageFilterRef.value) return
try {
const dataUrl = await imageFilterRef.value.generate()
screenshot.value = dataUrl
showModal.value = true
} catch (error) {
console.error('生成图片失败:', error)
}
}
const handleModalClose = () => {
showModal.value = false
screenshot.value = null
}
const handleTryOther = () => {
showModal.value = false
}
const handleFinish = () => {
useVlogStore().setVlogUnlockProgress(4)
router.push({
name: 'Vlog'
})
}
const handleClose = () => {
router.back()
}
const changeColor = (item: { id: number; name: string }) => {
selected.value = item.id
updateFilter()
}
</script>
<template>
<div class="w-full h-full relative pt-44">
<!-- 返回 -->
<ImgButton
class="absolute right-0 top-0 z-10"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleClose"
/>
<!-- 背景图片-center -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat -z-10"
:style="{
backgroundImage: getBackgroundImage('after-editing/color/底图')
}"
></div>
<div class="flex h-[759px] justify-center space-x-12">
<!-- 效果图 -->
<div
class="relative w-[1273px] h-[732px] bg-no-repeat bg-cover mt-10 p-2 pb-[26px] pr-[26px]"
:style="{ backgroundImage: getBackgroundImage('after-editing/color/图片框.png') }"
>
<ImageFilter ref="imageFilterRef" :src="getStaticImage('after-editing/color/图片')" />
<!-- 开启对比 -->
<ImgButton
class="absolute top-2 right-6 flex-[0_0_200px]"
@click="changeOriginalImgae"
:image="
btnState
? 'after-editing/color/开启对比 - 可开启'
: 'after-editing/color/开启对比 - 不可开启'
"
></ImgButton>
</div>
<!-- 滤镜/参数配置 -->
<div>
<div
class="w-[445px] h-[759px] bg-no-repeat bg-cover p-2 rounded-xl"
:style="{ backgroundImage: getBackgroundImage('after-editing/color/小底板') }"
>
<!-- tab切换 -->
<header class="flex h-[75px] items-center justify-center space-x-4">
<div @click="handleTabChange('color')">
<ImgButton
:image="
activeTab !== 'color'
? 'after-editing/color/调色-暗'
: 'after-editing/color/调色-亮'
"
/>
</div>
<div @click="handleTabChange('filter')">
<ImgButton
:image="
activeTab !== 'filter'
? 'after-editing/color/滤镜-暗'
: 'after-editing/color/滤镜-亮'
"
/>
</div>
</header>
<!-- tab-content -->
<div class="px-4 h-[calc(100%-75px)]">
<!-- 调色内容 -->
<div
v-show="activeTab === 'color'"
class="space-y-6 h-full overflow-y-auto custom-scrollbar pt-4"
>
<div v-for="item in adjustmentList" :key="item.name" class="px-2">
<div class="flex justify-between text-[#648ab2] text-2xl font-bold mb-2">
<span>{{ item.name }}</span>
<span>{{ item.value }}</span>
</div>
<input
type="range"
v-model.number="item.value"
:min="item.min"
:max="item.max"
@change="updateFilter"
class="w-full h-3 bg-[#b8cfe7] rounded-lg appearance-none cursor-pointer slider-thumb"
/>
</div>
</div>
<!-- 滤镜内容 -->
<div
v-show="activeTab === 'filter'"
class="space-y-10 h-full overflow-y-auto custom-scrollbar pt-10"
>
<div
v-for="item in colorBarList"
:key="item.id"
:style="{
backgroundImage:
selected == item.id
? getBackgroundImage('after-editing/color/滤镜-选中')
: getBackgroundImage('after-editing/color/滤镜-未选中')
}"
:class="[
'mx-auto w-[373px] h-[62px] text-white text-2xl font-bold flex items-center justify-center cursor-pointer bg-no-repeat bg-contain bg-center transition-all hover:scale-105',
selected !== item.id && 'text-gray-700'
]"
@click="changeColor(item)"
>
<span>{{ item.name }}</span>
</div>
</div>
</div>
</div>
<div class="flex justify-around mt-4">
<!-- 重置 -->
<ImgButton image="after-editing/color/重置" @click="handleReset" />
<!-- 生成 -->
<ImgButton image="after-editing/color/生成" @click="handleGenerate" />
</div>
</div>
</div>
<!-- title -->
<img class="absolute top-0 left-0" :src="getStaticImage('after-editing/color/标题-调色实践')" />
<!-- 保存图片弹窗 -->
<Overlay :visible="showModal" @close="handleModalClose">
<div class="relative w-[1000px] h-[600px] flex items-center justify-center">
<!-- 截图内容 -->
<div class="bg-white p-2 rounded-md -rotate-6">
<img :src="screenshot" alt="调色结果" />
</div>
<!-- <img :src="screenshot" alt="调色结果" class="max-w-full max-h-full object-contain rotate-2" /> -->
<!-- 操作按钮 -->
<div class="absolute bottom-1/2 -left-1/3 translate-y-1/2">
<ImgButton image="after-editing/color/按钮-试试其他调色" @click="handleTryOther" />
</div>
<div class="absolute bottom-1/2 -right-1/3 translate-y-1/2">
<ImgButton image="after-editing/color/按钮-结束调色实践" @click="handleFinish" />
</div>
</div>
</Overlay>
</div>
</template>
<style scoped>
/* 自定义滑块样式 */
.slider-thumb::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 34px;
height: 34px;
background: #92c6f2;
border: 3px solid #ffffff;
border-radius: 4px;
cursor: pointer;
box-shadow: 4px 4px 2px rgba(0, 0, 0, 0.4);
}
.slider-thumb::-moz-range-thumb {
width: 34px;
height: 34px;
background: #92c6f2;
border: 3px solid #ffffff;
border-radius: 4px;
cursor: pointer;
box-shadow: 4px 4px 2px rgba(0, 0, 0, 0.4);
}
</style>
+99
View File
@@ -0,0 +1,99 @@
<script lang="ts" setup>
import { onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue'
// import { GuideSteps } from '@/components/Guide'
import { IMAGE_PATHS } from '@/utils'
const router = useRouter()
// 导入视频文件
import videoBasic from '/video/vlog_zhizuobianjiaojing_jichujianji.mp4'
import videoSoundeffect from '/video/vlog_renwuqujing_yingxiaochuli.mp4'
import videoPackaging from '/video/vlog_renwuqujing_baozhuang.mp4'
import videoCopyright from '/video/vlog_renwuqujing_banquanguifan.mp4'
const videoList = [videoBasic, videoSoundeffect, videoPackaging, videoCopyright]
const videoRef = ref<HTMLVideoElement>()
const currentVideoIndex = ref(0)
onMounted(() => {
// 确保视频元素加载并自动播放
if (videoRef.value) {
videoRef.value.src = videoList[currentVideoIndex.value]
videoRef.value.load()
// 尝试自动播放
const playPromise = videoRef.value.play()
if (playPromise !== undefined) {
playPromise.catch(error => {
// 自动播放失败,可能是浏览器策略阻止,需要用户交互
console.log('自动播放被阻止:', error)
})
}
// 监听视频结束事件
videoRef.value.addEventListener('ended', playNextVideo)
}
})
// 播放下一个视频
const playNextVideo = () => {
if (currentVideoIndex.value < videoList.length - 1) {
currentVideoIndex.value++
if (videoRef.value) {
videoRef.value.src = videoList[currentVideoIndex.value]
videoRef.value.load()
// 自动播放下一个视频
const playPromise = videoRef.value.play()
if (playPromise !== undefined) {
playPromise.catch(error => {
console.log('自动播放被阻止:', error)
})
}
}
}
}
// 监听当前视频索引变化
watch(currentVideoIndex, newIndex => {
if (videoRef.value) {
videoRef.value.src = videoList[newIndex]
videoRef.value.load()
}
})
const handleClose = () => {
router.back()
}
</script>
<template>
<div>
<!--使用动态绑定和ref-->
<video
ref="videoRef"
:src="videoList[currentVideoIndex]"
type="video/mp4"
controls
preload="auto"
class="video-player"
autoplay
></video>
<ImgButton
class="absolute right-0 top-0 z-10"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleClose"
/>
</div>
</template>
<style scoped>
.video-player {
width: 100%;
height: 100%;
object-fit: contain;
}
</style>
+172
View File
@@ -0,0 +1,172 @@
<script lang="tsx" setup>
import { getStaticImage } from '@/utils'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { getBackgroundImage } from '@/utils'
defineOptions({ name: 'InterviewMenu' })
/**
* 菜单项 (前期准备、中期拍摄、后期剪辑)
* 前期准备-资源筹备、创意构想、脚本撰写、分镜制作
* 中期拍摄-设备准备、实地取景、现场拍摄
* 后期剪辑-素材管理、剪辑、调色、导出发布
*/
const category = ref([
{
label: '前期准备',
value: 1,
icon: getStaticImage('midterm/准备图标.png')
},
{
label: '中期拍摄',
value: 2,
icon: getStaticImage('midterm/拍摄图标.png')
},
{
label: '后期剪辑',
value: 3,
icon: getStaticImage('midterm/剪辑图标.png')
}
])
const menuItems = ref([
{
label: '资源筹备',
routeName: 'VlogResource',
parentValue: 1,
disabled: true
},
{
label: '创意构想',
routeName: 'VlogCreative',
parentValue: 1,
disabled: true
},
{
label: '脚本撰写',
routeName: 'VlogScriptWriting',
parentValue: 1,
disabled: false
},
{
label: '分镜制作',
routeName: 'VlogStoryboard',
parentValue: 1,
disabled: true
},
{
label: '设备准备',
routeName: 'VlogMidtermEquipment',
parentValue: 2,
disabled: false
},
{
label: '实地取景',
routeName: 'VlogMidtermShooting',
parentValue: 2,
disabled: false
},
{
label: '现场拍摄',
routeName: 'VlogShooting',
parentValue: 2,
disabled: true
},
{
label: '素材管理',
routeName: 'Vlog素材管理',
parentValue: 3,
disabled: true
},
{
label: '剪辑',
routeName: 'VlogEditing',
parentValue: 3,
disabled: false
},
{
label: '调色',
routeName: 'VlogColor',
parentValue: 3,
disabled: false
},
{
label: '导出发布',
routeName: 'Vlog导出发布',
parentValue: 3,
disabled: true
}
])
const props = defineProps<{
currentStep: number
}>()
const router = useRouter()
const currentParentValue = computed<number>(() => {
return props.currentStep
}) // 当前选中的父级菜单项
const currentItemValue = ref<string>('') // 当前选中的子级菜单项
const getMenuItems = computed(() => {
return menuItems.value.filter(item => item.parentValue === currentParentValue.value)
})
const handleItemClick = (item: { routeName: string; disabled: boolean }) => {
if (item.disabled) return
currentItemValue.value = item.routeName
router.push({ name: item.routeName })
}
</script>
<template>
<div class="absolute left-20 top-0 bottom-0 w-[520px] pt-28" v-if="currentStep">
<!-- 分类名称 -->
<div class="absolute top-3 left-24 flex items-center space-x-2">
<span class="text-4xl text-[#fbbf24] font-black">{{
category[currentParentValue - 1].label
}}</span>
<img class="filter-blue" :src="category[currentParentValue - 1].icon" alt="" />
</div>
<!-- 背景图片 -->
<div
class="absolute inset-0 bg-cover bg-left bg-no-repeat -z-10"
:style="{ backgroundImage: getBackgroundImage('components/menu-bg.png') }"
></div>
<div class="flex items-center flex-col pl-[60px]">
<template v-for="(item, index) in getMenuItems" :key="item.routeName">
<div
class="relative w-[373px] h-[143px] pl-14 flex items-center justify-center group text-4xl font-black transition-colors"
:class="[
currentItemValue === item.routeName ? 'text-blue-400' : 'text-white',
item.disabled ? 'opacity-20 cursor-not-allowed' : 'cursor-pointer'
]"
:style="{ marginLeft: -index * 40 + 'px' }"
@click="handleItemClick(item)"
>
<div
v-if="!item.disabled"
class="absolute inset-0 bg-cover bg-center bg-no-repeat opacity-0 group-hover:opacity-100 transition-opacity duration-300"
:style="{ backgroundImage: getBackgroundImage('components/menu-hover.png') }"
></div>
<span :class="[!item.disabled ? 'menu-glow-text' : '']">{{ item.label }}</span>
</div>
</template>
</div>
</div>
</template>
<style scoped>
.filter-blue {
filter: brightness(0) saturate(100%) invert(79%) sepia(29%) saturate(1190%) hue-rotate(330deg)
brightness(102%) contrast(98%);
}
.menu-glow-text {
text-shadow:
0 0 40px rgba(0, 180, 255, 0.9),
0 0 20px rgba(0, 180, 255, 0.7);
}
</style>
+43 -12
View File
@@ -2,38 +2,52 @@
import { getBackgroundImage, IMAGE_PATHS } from '@/utils'
import { ImgButton } from '@/components/Button'
import { onMounted, ref } from 'vue'
import { ProgressBar } from '@/components'
import { useRouter, useRoute } from 'vue-router'
import { computed, onMounted, ref, watch } from 'vue'
import { ProgressBar, Overlay } from '@/components'
import { useRouter } from 'vue-router'
import { useVlogStore } from '@/stores'
import VlogMenu from './components/menu.vue'
const router = useRouter()
const route = useRoute()
const vlog = useVlogStore()
const visible = ref(false)
const currentStep = ref(0)
const currentStep = computed(() => vlog.vlogUnlockProgress)
const handleComplete = () => {
console.log('完成前置条件')
// console.log('完成前置条件')
// 处理完成逻辑
router.push({ name: 'VlogPractice' })
}
const handleStepChange = (step: number) => {
console.log('步骤变化:', step)
// 处理步骤变化逻辑
vlog.setVlogUnlockProgress(step)
visible.value = true
}
const handleWorkClick = () => {
console.log('任务取景按钮点击')
// 处理任务取景按钮点击逻辑
router.push({ name: 'Work' })
router.push({ name: 'VlogWork' })
}
const handleClose = () => {
router.back()
router.push({ name: 'Action' })
}
// watch(
// () => currentStep.value,
// newVal => {
// if (newVal !== 0 && newVal !== 4) {
// visible.value = true
// }
// }
// )
onMounted(() => {
if (route.query.key === 'video') {
currentStep.value = 1
if (currentStep.value !== 0 && currentStep.value !== 4) {
visible.value = true
}
})
</script>
@@ -70,7 +84,7 @@ onMounted(() => {
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.TASK_CAMERA) }"
>
<ImgButton
:image="currentStep === 1 ? IMAGE_PATHS.VLOG.TASK_BTN_OK : IMAGE_PATHS.VLOG.TASK_BTN"
:image="currentStep === 4 ? IMAGE_PATHS.VLOG.TASK_BTN_OK : IMAGE_PATHS.VLOG.TASK_BTN"
@click="handleWorkClick"
/>
</div>
@@ -78,6 +92,23 @@ onMounted(() => {
<!-- 引导任务 -->
<ImgButton class="absolute right-24 bottom-24" :image="IMAGE_PATHS.VLOG.TASK_PERSON" />
<Overlay content-class="w-full h-full" :visible="visible && currentStep < 4">
<!-- 关闭按钮 -->
<ImgButton
class="absolute right-0 top-0 z-20"
:image="IMAGE_PATHS.COMPONENTS.CLOSE"
@click="visible = false"
/>
<!-- 菜单 -->
<VlogMenu :current-step="currentStep" />
<!-- 进度条 -->
<div class="absolute left-0 bottom-0 w-full">
<ProgressBar :current-step="currentStep" @step-change="handleStepChange" />
</div>
</Overlay>
<div class="absolute bottom-0 w-full">
<ProgressBar
:current-step="currentStep"
Loaded 100 of 339 files, more files were not shown because too many files have changed in this diff. Show more