feat: 完善视频学习进度缓存

完善新手导航和三大创作模块的视频完成缓存、未完成提示与学习进度计算,确保进度按实际学习资源完成情况更新。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
zhongyidianandClaude Opus 4.7 committed 2026-05-11 09:05:29 +08:00
1 parent 1f8723aabd
commit 6f289959be
63 files changed
+3139 -1126

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 512 KiB

+147
View File
@@ -0,0 +1,147 @@
<script lang="ts" setup>
import { ref, computed } from 'vue'
import type { Component } from 'vue'
import { Overlay } from '@/components/Overlay'
import { ImgButton } from '@/components/Button'
import { Video } from '@/components/Video'
import { GuideSteps, type GuideStep } from '@/components/Guide'
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
interface CompositionBtn {
key: string
name: string
btnImage: string
images: string[]
desc: string
}
withDefaults(
defineProps<{
visible: boolean
bgImage: string
btnList: CompositionBtn[]
knowledgeCardComponent: Component
videoSrc: string
guideSteps?: GuideStep[]
showInternalGuide?: boolean
}>(),
{
guideSteps: () => [],
showInternalGuide: false,
},
)
const emit = defineEmits<{
close: []
}>()
const showKnowledgeCard = ref(false)
const showVideo = ref(false)
const currentItem = ref<CompositionBtn>({
key: '',
name: '',
btnImage: '',
images: [],
desc: '',
})
const currentCompositionKey = computed(() => {
const k = currentItem.value.key
if (k === '1' || k === '2' || k === '3') return k
return '1'
})
const handleClose = () => {
emit('close')
}
const handleItemClick = (item: CompositionBtn) => {
currentItem.value = item
showKnowledgeCard.value = true
}
</script>
<template>
<Overlay
:visible="visible"
:clickable="false"
:customStyle="{ backgroundImage: getBackgroundImage(bgImage) }"
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="handleClose"
/>
<ImgButton
class="absolute left-0 top-0 z-10"
image="shooting/标题-构图指南"
/>
<div class="absolute left-28 top-[calc(50%-40px)] -translate-y-1/2">
<ImgButton
image="shooting/新手导航-视频回顾"
@click="showVideo = true"
/>
<div
class="absolute left-[38%] top-[50%] -translate-x-1/2 -translate-y-1/2 z-10 animate-bounce pointer-events-none"
>
<img
class="w-40"
:src="getStaticImage('work/小手')"
alt=""
/>
</div>
</div>
<div class="absolute top-[30%] right-10">
<ImgButton
v-for="item in btnList"
:key="item.key"
:class="`btn-item-${item.key}`"
:image="item.btnImage"
@click="handleItemClick(item)"
/>
</div>
<GuideSteps
v-if="showInternalGuide && guideSteps.length > 0"
:visible="showInternalGuide"
:steps="guideSteps"
:keyboard-navigation="true"
:highlight-padding="12"
:highlight-radius="16"
position-offset-y="85%"
/>
<component
:is="knowledgeCardComponent"
v-if="showKnowledgeCard && currentItem.key"
:visible="showKnowledgeCard"
:title="currentItem.name"
:desc="currentItem.desc"
:images="currentItem.images"
:compositionKey="currentCompositionKey"
@close="showKnowledgeCard = false"
/>
<Overlay
:visible="showVideo"
:clickable="false"
>
<Video
:src="videoSrc"
controls
:volume="1"
:muted="false"
/>
<ImgButton
class="absolute right-0 top-0 z-10"
:image="IMAGE_PATHS.COMPONENTS.CLOSE"
@click="showVideo = false"
/>
</Overlay>
</Overlay>
</template>
+1
View File
@@ -57,6 +57,7 @@
<!-- 支持字符串形式的 content --> <!-- 支持字符串形式的 content -->
<div <div
v-else v-else
class="whitespace-pre-line"
v-html="currentStepContent" v-html="currentStepContent"
></div> ></div>
</GuideStepsItem> </GuideStepsItem>
+64 -17
View File
@@ -1,7 +1,13 @@
<template> <template>
<Transition name="step-navigation" appear> <Transition
name="step-navigation"
appear
>
<!-- 抽屉遮罩层 --> <!-- 抽屉遮罩层 -->
<div v-if="visible" class="fixed inset-0 z-50 bg-black/30 backdrop-blur-md"> <div
v-if="visible"
class="fixed inset-0 z-50 bg-black/30 backdrop-blur-md"
>
<ImgButton <ImgButton
class="absolute right-0 top-0 z-[10]" class="absolute right-0 top-0 z-[10]"
image="components/close" image="components/close"
@@ -20,14 +26,24 @@
@click.stop @click.stop
> >
<!-- 顶部分页箭头(上一页) --> <!-- 顶部分页箭头(上一页) -->
<div class="relative z-10 flex items-center justify-center pointer-events-none"> <div
class="relative z-10 flex items-center justify-center pointer-events-none"
>
<button <button
class="pointer-events-auto flex h-20 w-20 items-center justify-center border-none bg-transparent transition-all duration-200 disabled:cursor-not-allowed disabled:opacity-40 hover:enabled:scale-110" class="pointer-events-auto flex h-20 w-20 items-center justify-center border-none bg-transparent transition-all duration-200 disabled:cursor-not-allowed disabled:opacity-40 hover:enabled:scale-110"
@click="goPrevPage" @click="goPrevPage"
:disabled="!canPrevPage" :disabled="!canPrevPage"
> >
<svg width="64" height="64" viewBox="0 0 40 40" fill="none"> <svg
<path d="M5 30L35 30L20 6Z" fill="white" /> width="64"
height="64"
viewBox="0 0 40 40"
fill="none"
>
<path
d="M5 30L35 30L20 6Z"
fill="white"
/>
</svg> </svg>
</button> </button>
</div> </div>
@@ -41,20 +57,31 @@
class="step-item-bg relative flex h-[122px] w-[831px] cursor-pointer items-center px-8 py-4 transition-all duration-300 hover:-translate-x-[23%]" class="step-item-bg relative flex h-[122px] w-[831px] cursor-pointer items-center px-8 py-4 transition-all duration-300 hover:-translate-x-[23%]"
:class="{ :class="{
'step-item-active-bg h-[230px] w-[1034px] pl-[180px] -translate-x-[23%]': 'step-item-active-bg h-[230px] w-[1034px] pl-[180px] -translate-x-[23%]':
currentPageStartIndex + index === currentStepIndex currentPageStartIndex + index === currentStepIndex,
}" }"
@click="handleStepClick(index)" @click="handleStepClick(index)"
> >
<!-- 步骤标签 --> <!-- 步骤标签 -->
<div <div
class="mr-4 min-w-[5rem] rounded-full px-16 py-4 text-center text-2xl font-semibold shadow-md" class="mr-0 min-w-[5rem] rounded-full px-16 py-4 text-center text-2xl font-semibold shadow-md"
:class="getTagColor(step.label)" :class="getTagColor(step.label)"
> >
{{ step.label }} {{ step.label }}
</div> </div>
<!-- 步骤内容 --> <!-- 步骤内容 -->
<div class="mr-4 flex-1 text-3xl font-bold leading-[1.5]"> <div
class="mr-4 flex-1 text-3xl font-bold leading-[1.5] flex items-center"
>
<img
class="pointer-events-none h-12 w-12 object-contain transition-all duration-300"
:src="
completedSteps?.[currentPageStartIndex + index]
? completedIcon
: incompleteIcon
"
alt=""
/>
{{ step.content }} {{ step.content }}
</div> </div>
@@ -63,7 +90,7 @@
class="absolute right-[15%] z-[1] flex items-center justify-center text-6xl font-semibold text-white/30 transition-all duration-300" class="absolute right-[15%] z-[1] flex items-center justify-center text-6xl font-semibold text-white/30 transition-all duration-300"
:class="{ :class="{
'mb-20 text-[150px] text-white drop-shadow-[5px_10px_5px_rgba(0,0,0,0.1)]': 'mb-20 text-[150px] text-white drop-shadow-[5px_10px_5px_rgba(0,0,0,0.1)]':
currentPageStartIndex + index === currentStepIndex currentPageStartIndex + index === currentStepIndex,
}" }"
> >
{{ currentPageStartIndex + index + 1 }} {{ currentPageStartIndex + index + 1 }}
@@ -79,8 +106,16 @@
@click="goNextPage" @click="goNextPage"
:disabled="!canNextPage" :disabled="!canNextPage"
> >
<svg width="64" height="64" viewBox="0 0 40 40" fill="none"> <svg
<path d="M5 10L35 10L20 34Z" fill="white" /> width="64"
height="64"
viewBox="0 0 40 40"
fill="none"
>
<path
d="M5 10L35 10L20 34Z"
fill="white"
/>
</svg> </svg>
</button> </button>
</div> </div>
@@ -94,6 +129,8 @@
import { ImgButton } from '@/components' import { ImgButton } from '@/components'
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { getTagColor } from '@/utils' import { getTagColor } from '@/utils'
import completedIcon from '@/assets/images/midterm/完成.png'
import incompleteIcon from '@/assets/images/midterm/未完成.png'
export interface StepItem { export interface StepItem {
id: string id: string
@@ -111,6 +148,8 @@ interface Props {
steps: StepItem[] steps: StepItem[]
/** 当前步骤索引 */ /** 当前步骤索引 */
currentStepIndex?: number currentStepIndex?: number
/** 步骤完成状态 */
completedSteps?: boolean[]
/** 是否显示关闭按钮 */ /** 是否显示关闭按钮 */
showClose?: boolean showClose?: boolean
} }
@@ -122,7 +161,7 @@ interface Emits {
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
currentStepIndex: 0, currentStepIndex: 0,
showClose: true showClose: true,
}) })
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
@@ -131,10 +170,15 @@ const emit = defineEmits<Emits>()
const pageSize = 5 const pageSize = 5
const currentPage = ref(0) const currentPage = ref(0)
const totalPages = computed(() => Math.max(1, Math.ceil((props.steps?.length ?? 0) / pageSize))) const totalPages = computed(() =>
Math.max(1, Math.ceil((props.steps?.length ?? 0) / pageSize)),
)
const currentPageStartIndex = computed(() => currentPage.value * pageSize) const currentPageStartIndex = computed(() => currentPage.value * pageSize)
const pagedSteps = computed(() => const pagedSteps = computed(() =>
props.steps.slice(currentPageStartIndex.value, currentPageStartIndex.value + pageSize) props.steps.slice(
currentPageStartIndex.value,
currentPageStartIndex.value + pageSize,
),
) )
const canPrevPage = computed(() => currentPage.value > 0) const canPrevPage = computed(() => currentPage.value > 0)
@@ -164,14 +208,17 @@ const handleClose = () => {
// 当外部当前步骤改变时,自动定位到对应页 // 当外部当前步骤改变时,自动定位到对应页
watch( watch(
() => props.currentStepIndex, () => props.currentStepIndex,
newVal => { (newVal) => {
const idx = newVal ?? 0 const idx = newVal ?? 0
const targetPage = Math.floor(idx / pageSize) const targetPage = Math.floor(idx / pageSize)
if (targetPage !== currentPage.value) { if (targetPage !== currentPage.value) {
currentPage.value = Math.min(Math.max(targetPage, 0), totalPages.value - 1) currentPage.value = Math.min(
Math.max(targetPage, 0),
totalPages.value - 1,
)
} }
}, },
{ immediate: true } { immediate: true },
) )
</script> </script>
+37 -3
View File
@@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue' import { computed, onBeforeUnmount, ref } from 'vue'
import { ImgButton } from '@/components/Button' import { ImgButton } from '@/components/Button'
import { Overlay } from '@/components/Overlay' import { Overlay } from '@/components/Overlay'
import { StarRating } from '@/components/Rating' import { StarRating } from '@/components/Rating'
@@ -23,11 +23,37 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'close'): void (e: 'close'): void
(e: 'update:items', items: PracticeRatingItem[]): void
(e: 'save', items: PracticeRatingItem[]): void
}>() }>()
const hasTips = computed<boolean>(() => const hasTips = computed<boolean>(() =>
Boolean(props.topTip || props.bottomTip), Boolean(props.topTip || props.bottomTip),
) )
const showSuccess = ref<boolean>(false)
let successTimer: number | undefined
const handleScoreChange = (index: number, score: number): void => {
const nextItems = props.items.map((item, itemIndex) =>
itemIndex === index ? { ...item, score } : item,
)
emit('update:items', nextItems)
}
const handleSave = (): void => {
emit('save', props.items)
showSuccess.value = true
window.clearTimeout(successTimer)
successTimer = window.setTimeout(() => {
showSuccess.value = false
emit('close')
}, 1500)
}
onBeforeUnmount(() => {
window.clearTimeout(successTimer)
})
</script> </script>
<template> <template>
@@ -36,7 +62,7 @@ const hasTips = computed<boolean>(() =>
:clickable="false" :clickable="false"
> >
<div <div
class="w-[1795px] h-[987px] bg-no-repeat bg-cover p-10 pt-[100px]" class="relative w-[1795px] h-[987px] bg-no-repeat bg-cover p-10 pt-[100px]"
:style="{ backgroundImage: getBackgroundImage(backgroundImage) }" :style="{ backgroundImage: getBackgroundImage(backgroundImage) }"
> >
<ImgButton <ImgButton
@@ -45,6 +71,13 @@ const hasTips = computed<boolean>(() =>
@click="emit('close')" @click="emit('close')"
/> />
<div
v-if="showSuccess"
class="absolute left-1/2 top-1/2 z-[100] -translate-x-1/2 -translate-y-1/2 rounded-2xl bg-black/70 px-16 py-8 text-5xl font-bold text-white"
>
提交成功
</div>
<div class="relative h-full overflow-y-scroll px-8"> <div class="relative h-full overflow-y-scroll px-8">
<div class="pl-10 space-y-10 mt-10"> <div class="pl-10 space-y-10 mt-10">
<p <p
@@ -71,6 +104,7 @@ const hasTips = computed<boolean>(() =>
class="absolute -bottom-5 right-10" class="absolute -bottom-5 right-10"
:score="item.score" :score="item.score"
size="lg" size="lg"
@change="handleScoreChange(index, $event)"
/> />
</div> </div>
<p <p
@@ -85,7 +119,7 @@ const hasTips = computed<boolean>(() =>
> >
<ImgButton <ImgButton
:image="saveButtonImage" :image="saveButtonImage"
@click="emit('close')" @click="handleSave"
/> />
</div> </div>
</div> </div>
+22 -6
View File
@@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref, computed } from 'vue' import { ref, computed, watch } from 'vue'
import { getStaticImage } from '@/utils' import { getStaticImage } from '@/utils'
interface Props { interface Props {
@@ -16,7 +16,7 @@ interface Props {
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
maxStars: 5, maxStars: 5,
size: 'md', size: 'md',
readonly: false readonly: false,
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@@ -34,11 +34,13 @@ const hoverScore = ref<number>(0)
const sizeMap = { const sizeMap = {
sm: 'w-10 h-10', sm: 'w-10 h-10',
md: 'w-14 h-14', md: 'w-14 h-14',
lg: 'w-18 h-18' lg: 'w-18 h-18',
} }
/** 显示的评分(优先显示悬停评分,否则显示当前评分) */ /** 显示的评分(优先显示悬停评分,否则显示当前评分) */
const displayScore = computed<number>(() => hoverScore.value || currentScore.value) const displayScore = computed<number>(
() => hoverScore.value || currentScore.value,
)
/** 处理星星点击 */ /** 处理星星点击 */
const handleStarClick = (star: number): void => { const handleStarClick = (star: number): void => {
@@ -57,6 +59,13 @@ const handleMouseEnter = (star: number): void => {
const handleMouseLeave = (): void => { const handleMouseLeave = (): void => {
hoverScore.value = 0 hoverScore.value = 0
} }
watch(
() => props.score,
(score) => {
currentScore.value = score
},
)
</script> </script>
<template> <template>
@@ -68,9 +77,16 @@ const handleMouseLeave = (): void => {
<img <img
v-for="star in props.maxStars" v-for="star in props.maxStars"
:key="star" :key="star"
:class="[sizeMap[props.size], !props.readonly && 'transition-transform hover:scale-110']" :class="[
sizeMap[props.size],
!props.readonly && 'transition-transform hover:scale-110',
]"
:src=" :src="
getStaticImage(star <= displayScore ? 'vlog/practice/星星-亮' : 'vlog/practice/星星-灰') getStaticImage(
star <= displayScore
? 'vlog/practice/星星-亮'
: 'vlog/practice/星星-灰',
)
" "
alt="" alt=""
@click="handleStarClick(star)" @click="handleStarClick(star)"
@@ -0,0 +1 @@
export { default } from './index.vue'
@@ -0,0 +1,80 @@
<script lang="ts" setup>
import { Overlay } from '@/components/Overlay'
interface Props {
visible: boolean
message?: string
continueText?: string
leaveText?: string
}
withDefaults(defineProps<Props>(), {
message: '当前页面还有学习资源没有学习完成,建议继续完成后再离开。',
continueText: '继续学习',
leaveText: '确认离开',
})
const emit = defineEmits<{
(e: 'continue'): void
(e: 'leave'): void
}>()
</script>
<template>
<Overlay
:visible="visible"
:clickable="false"
background="rgba(80, 220, 255, 0.18)"
animation="zoom"
:z-index="3000"
>
<div
class="relative w-[860px] overflow-hidden rounded-[42px] border-[5px] border-white/80 bg-gradient-to-br from-[#e8fbff]/95 via-[#d8ddff]/95 to-[#ffd8f3]/95 px-16 py-14 text-center shadow-[0_24px_70px_rgba(48,122,255,0.32)]"
>
<div
class="pointer-events-none absolute -left-16 -top-20 h-56 w-56 rounded-full bg-[#5ff3ff]/55 blur-3xl"
/>
<div
class="pointer-events-none absolute right-[-70px] top-[-60px] h-60 w-60 rounded-full bg-[#ff65d8]/45 blur-3xl"
/>
<div
class="pointer-events-none absolute bottom-[-90px] left-1/2 h-64 w-64 -translate-x-1/2 rounded-full bg-[#ffe96b]/45 blur-3xl"
/>
<div
class="pointer-events-none absolute inset-x-10 top-5 h-[3px] rounded-full bg-gradient-to-r from-transparent via-white to-transparent"
/>
<div
class="relative mx-auto mb-7 flex h-24 w-24 items-center justify-center rounded-[30px] border-[4px] border-white bg-gradient-to-br from-[#36eaff] to-[#7b7cff] shadow-[0_12px_28px_rgba(54,234,255,0.38)] rotate-[-6deg]"
>
<span
class="text-[56px] font-black leading-none text-white drop-shadow-[0_4px_0_rgba(34,80,180,0.45)]"
>
!
</span>
</div>
<p
class="relative mx-auto mt-7 max-w-[650px] text-[34px] font-black leading-[1.65] text-[#294579]"
>
{{ message }}
</p>
<div class="relative mt-12 flex items-center justify-center gap-9">
<button
class="h-[76px] min-w-[220px] rounded-full border-[3px] border-[#78bfff] bg-white/70 px-10 text-[28px] font-black text-[#3470c8] shadow-[0_10px_0_rgba(70,139,220,0.22)] transition-all duration-200 hover:-translate-y-1 hover:bg-white active:translate-y-1 active:shadow-[0_5px_0_rgba(70,139,220,0.22)]"
@click="emit('leave')"
>
{{ leaveText }}
</button>
<button
class="h-[76px] min-w-[220px] rounded-full border-[3px] border-white bg-gradient-to-r from-[#ff82d8] via-[#ff9f4a] to-[#ffd447] px-10 text-[28px] font-black text-white shadow-[0_10px_0_rgba(236,111,40,0.28),0_16px_35px_rgba(255,130,216,0.32)] transition-all duration-200 hover:-translate-y-1 active:translate-y-1 active:shadow-[0_5px_0_rgba(236,111,40,0.28)]"
@click="emit('continue')"
>
{{ continueText }}
</button>
</div>
</div>
</Overlay>
</template>
+1
View File
@@ -20,6 +20,7 @@ export * from './ImagePreview'
// 弹窗组件 // 弹窗组件
export * from './Modal' export * from './Modal'
export * from './UnfinishedLearningPrompt'
// 遮罩层组件 // 遮罩层组件
export * from './Overlay' export * from './Overlay'
+10 -1
View File
@@ -38,12 +38,21 @@ export const moduleStepMap: Record<
3: [ 3: [
'InterviewEditing', 'InterviewEditing',
'InterviewColor', 'InterviewColor',
'InterviewAudio', 'InterviewSoundEffect',
'InterviewExportPublish', 'InterviewExportPublish',
], ],
}, },
} as const } as const
export const videoCompletionRouteNames = new Set<string>([
'FilmOnsiteShooting',
'FilmEditing',
'VlogShooting',
'VlogEditing',
'InterviewOnsiteShooting',
'InterviewEditing',
])
export const allTrackedRouteNames = new Set<string>( export const allTrackedRouteNames = new Set<string>(
Object.values(moduleStepMap).flatMap((stepMap) => Object.values(moduleStepMap).flatMap((stepMap) =>
Object.values(stepMap).flatMap((names) => names), Object.values(stepMap).flatMap((names) => names),
+28 -20
View File
@@ -1,7 +1,11 @@
import { createRouter, createWebHashHistory } from 'vue-router' import { createRouter, createWebHashHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router' import type { RouteRecordRaw } from 'vue-router'
import { BasicElements, Film, Interview, Vlog } from './modules' import { BasicElements, Film, Interview, Vlog } from './modules'
import { allTrackedRouteNames, getModuleNameByRouteName } from '@/config/moduleStepMap' import {
allTrackedRouteNames,
getModuleNameByRouteName,
videoCompletionRouteNames,
} from '@/config/moduleStepMap'
import { useFilmStore, useInterviewStore, useVlogStore } from '@/stores' import { useFilmStore, useInterviewStore, useVlogStore } from '@/stores'
const NotFound = { const NotFound = {
@@ -9,8 +13,8 @@ const NotFound = {
name: 'NotFound', name: 'NotFound',
component: () => import('@/views/not-found.vue'), component: () => import('@/views/not-found.vue'),
meta: { meta: {
title: '404' title: '404',
} },
} }
const routes: RouteRecordRaw[] = [ const routes: RouteRecordRaw[] = [
@@ -19,8 +23,8 @@ const routes: RouteRecordRaw[] = [
name: 'Home', name: 'Home',
component: () => import('@/views/home/index.tsx'), component: () => import('@/views/home/index.tsx'),
meta: { meta: {
title: '首页' title: '首页',
} },
}, },
//vlog模块 //vlog模块
...Vlog, ...Vlog,
@@ -35,8 +39,8 @@ const routes: RouteRecordRaw[] = [
name: 'Action', name: 'Action',
component: () => import('@/views/home/action.vue'), component: () => import('@/views/home/action.vue'),
meta: { meta: {
title: '开始拍摄' title: '开始拍摄',
} },
}, },
//技能树 //技能树
{ {
@@ -44,23 +48,23 @@ const routes: RouteRecordRaw[] = [
name: 'Skill', name: 'Skill',
component: () => import('@/views/skill/index.vue'), component: () => import('@/views/skill/index.vue'),
meta: { meta: {
title: '技能树' title: '技能树',
} },
}, },
{ {
path: '/skill/video', path: '/skill/video',
name: 'SkillVdieo', name: 'SkillVdieo',
component: () => import('@/views/skill/video.vue'), component: () => import('@/views/skill/video.vue'),
meta: { meta: {
title: '技能树视频' title: '技能树视频',
} },
}, },
NotFound NotFound,
] ]
const router = createRouter({ const router = createRouter({
history: createWebHashHistory(), history: createWebHashHistory(),
routes routes,
}) })
const VIEW_THRESHOLD_MS = 3000 const VIEW_THRESHOLD_MS = 3000
@@ -82,18 +86,22 @@ router.beforeEach((to, from, next) => {
allTrackedRouteNames.has(fromName) && allTrackedRouteNames.has(fromName) &&
currentEnterAt > 0 currentEnterAt > 0
) { ) {
const stayedMs = Date.now() - currentEnterAt if (videoCompletionRouteNames.has(fromName)) {
if (stayedMs >= VIEW_THRESHOLD_MS) { // 多视频页面不通过停留时间自动完成,由视频全部播完后写入 viewed
const moduleName = getModuleNameByRouteName(fromName) } else {
if (moduleName === 'film') useFilmStore().markViewed(fromName) const stayedMs = Date.now() - currentEnterAt
if (moduleName === 'vlog') useVlogStore().markViewed(fromName) if (stayedMs >= VIEW_THRESHOLD_MS) {
if (moduleName === 'interview') useInterviewStore().markViewed(fromName) const moduleName = getModuleNameByRouteName(fromName)
if (moduleName === 'film') useFilmStore().markViewed(fromName)
if (moduleName === 'vlog') useVlogStore().markViewed(fromName)
if (moduleName === 'interview') useInterviewStore().markViewed(fromName)
}
} }
} }
next() next()
}) })
router.afterEach(to => { router.afterEach((to) => {
// 记录进入:to 是当前页面 // 记录进入:to 是当前页面
const toName = typeof to.name === 'string' ? to.name : null const toName = typeof to.name === 'string' ? to.name : null
if (toName && allTrackedRouteNames.has(toName)) { if (toName && allTrackedRouteNames.has(toName)) {
+5 -4
View File
@@ -138,11 +138,12 @@ const Interview: RouteRecordRaw[] = [
}, },
}, },
{ {
path: '/interview/audio', path: '/interview/sound-effect',
name: 'InterviewAudio', name: 'InterviewSoundEffect',
component: () => import('@/views/interview/after-editing/audio/index.vue'), component: () =>
import('@/views/interview/after-editing/sound-effect/index.vue'),
meta: { meta: {
title: '音频', title: '音效',
}, },
}, },
{ {
+33 -9
View File
@@ -9,6 +9,7 @@ interface BasicElementsSavePayload {
isGuideBasicElementsTask?: boolean isGuideBasicElementsTask?: boolean
isBasicElementsUnlock?: boolean isBasicElementsUnlock?: boolean
basicElementsUnlockProgress?: number basicElementsUnlockProgress?: number
learningVideosWatched?: Record<string, boolean>
guideShown?: Record<string, boolean> guideShown?: Record<string, boolean>
} }
@@ -37,6 +38,7 @@ export const useBasicElementsStore = defineStore(
const isBasicElementsUnlock = ref<boolean>(false) const isBasicElementsUnlock = ref<boolean>(false)
//basic-elements-发现与记录解锁进度 //basic-elements-发现与记录解锁进度
const basicElementsUnlockProgress = ref<number>(0) const basicElementsUnlockProgress = ref<number>(0)
const learningVideosWatched = ref<Record<string, boolean>>({})
const guideShown = ref<Record<string, boolean>>({}) const guideShown = ref<Record<string, boolean>>({})
//Actions //Actions
@@ -46,14 +48,21 @@ export const useBasicElementsStore = defineStore(
isGuideBasicElementsTask: isGuideBasicElementsTask.value, isGuideBasicElementsTask: isGuideBasicElementsTask.value,
isBasicElementsUnlock: isBasicElementsUnlock.value, isBasicElementsUnlock: isBasicElementsUnlock.value,
basicElementsUnlockProgress: basicElementsUnlockProgress.value, basicElementsUnlockProgress: basicElementsUnlockProgress.value,
guideShown: guideShown.value learningVideosWatched: learningVideosWatched.value,
guideShown: guideShown.value,
} }
getYibanApi() getYibanApi()
.then(api => .then((api) =>
api?.setUserDefine(BASIC_ELEMENTS_LESSON_ID, BASIC_ELEMENTS_KEY, JSON.stringify(payload)) api?.setUserDefine(
BASIC_ELEMENTS_LESSON_ID,
BASIC_ELEMENTS_KEY,
JSON.stringify(payload),
),
)
.catch((error) =>
console.error('Failed to save Basic Elements store', error),
) )
.catch(error => console.error('Failed to save Basic Elements store', error))
} }
async function loadBasicElementsStore(): Promise<void> { async function loadBasicElementsStore(): Promise<void> {
@@ -61,14 +70,19 @@ export const useBasicElementsStore = defineStore(
const api = await getYibanApi() const api = await getYibanApi()
if (!api) return if (!api) return
const raw = await api.getUserDefine(BASIC_ELEMENTS_LESSON_ID, BASIC_ELEMENTS_KEY) const raw = await api.getUserDefine(
BASIC_ELEMENTS_LESSON_ID,
BASIC_ELEMENTS_KEY,
)
if (!raw) return if (!raw) return
const data = JSON.parse(raw) as BasicElementsSavePayload const data = JSON.parse(raw) as BasicElementsSavePayload
isGuideNewUser.value = data.isGuideNewUser ?? true isGuideNewUser.value = data.isGuideNewUser ?? true
isGuideBasicElementsTask.value = data.isGuideBasicElementsTask ?? true isGuideBasicElementsTask.value = data.isGuideBasicElementsTask ?? true
isBasicElementsUnlock.value = data.isBasicElementsUnlock ?? false isBasicElementsUnlock.value = data.isBasicElementsUnlock ?? false
basicElementsUnlockProgress.value = data.basicElementsUnlockProgress ?? 0 basicElementsUnlockProgress.value =
data.basicElementsUnlockProgress ?? 0
learningVideosWatched.value = data.learningVideosWatched ?? {}
guideShown.value = data.guideShown ?? {} guideShown.value = data.guideShown ?? {}
} catch (error) { } catch (error) {
console.error('Failed to load Basic Elements store', error) console.error('Failed to load Basic Elements store', error)
@@ -101,11 +115,19 @@ export const useBasicElementsStore = defineStore(
saveBasicElementsStore() saveBasicElementsStore()
} }
function markLearningVideoWatched(key: string): void {
if (!key) return
if (learningVideosWatched.value[key]) return
learningVideosWatched.value[key] = true
saveBasicElementsStore()
}
function resetBasicElementsState(): void { function resetBasicElementsState(): void {
isGuideNewUser.value = true isGuideNewUser.value = true
isGuideBasicElementsTask.value = true isGuideBasicElementsTask.value = true
isBasicElementsUnlock.value = false isBasicElementsUnlock.value = false
basicElementsUnlockProgress.value = 0 basicElementsUnlockProgress.value = 0
learningVideosWatched.value = {}
guideShown.value = {} guideShown.value = {}
saveBasicElementsStore() saveBasicElementsStore()
} }
@@ -117,6 +139,7 @@ export const useBasicElementsStore = defineStore(
setIsBasicElementsUnlock, setIsBasicElementsUnlock,
setBasicElementsUnlockProgress, setBasicElementsUnlockProgress,
markGuideShown, markGuideShown,
markLearningVideoWatched,
saveBasicElementsStore, saveBasicElementsStore,
loadBasicElementsStore, loadBasicElementsStore,
resetBasicElementsState, resetBasicElementsState,
@@ -125,10 +148,11 @@ export const useBasicElementsStore = defineStore(
isGuideBasicElementsTask, isGuideBasicElementsTask,
isBasicElementsUnlock, isBasicElementsUnlock,
basicElementsUnlockProgress, basicElementsUnlockProgress,
guideShown learningVideosWatched,
guideShown,
} }
}, },
{ {
persist: true persist: true,
} },
) )
+142 -7
View File
@@ -5,6 +5,13 @@ import { moduleStepMap } from '@/config/moduleStepMap'
export const FILM_LESSON_ID = 'dianying_rwqjk' export const FILM_LESSON_ID = 'dianying_rwqjk'
export const FILM_KEY = 4 export const FILM_KEY = 4
type FilmWorkTab = 'case' | 'profession' | 'idea'
export const FILM_WORK_COMPLETION_KEYS = ['case', 'profession', 'idea'] as const
type PracticeRatingScores = Record<string, number>
type PracticeWorkRatings = Record<string, PracticeRatingScores>
interface FilmSavePayload { interface FilmSavePayload {
viewed?: Record<string, boolean> viewed?: Record<string, boolean>
isGuideFilmTask?: boolean isGuideFilmTask?: boolean
@@ -15,9 +22,15 @@ interface FilmSavePayload {
FilmUnlockProgress?: number FilmUnlockProgress?: number
filmMenuVisible?: boolean filmMenuVisible?: boolean
editingVideosWatched?: Record<string, boolean> editingVideosWatched?: Record<string, boolean>
professionVideosWatched?: Record<string, boolean>
workVideosWatched?: Record<string, boolean>
isGuideFilmEditingComplete?: boolean isGuideFilmEditingComplete?: boolean
guideShown?: Record<string, boolean> guideShown?: Record<string, boolean>
practiceScrollPage?: number practiceScrollPage?: number
workSelectedTab?: FilmWorkTab
practiceNoteContent?: string
practiceSelfEvaluationScores?: PracticeRatingScores
practiceWorkRatings?: PracticeWorkRatings
} }
export const FILM_EDITING_VIDEO_KEYS = [ export const FILM_EDITING_VIDEO_KEYS = [
@@ -29,6 +42,20 @@ export const FILM_EDITING_VIDEO_KEYS = [
'continuity', 'continuity',
] as const ] as const
export const FILM_ONSITE_ROUTE_NAMES = [
'FilmOnsiteTimeLapse',
'FilmOnsiteSlider',
'FilmOnsiteSlowShutterDay',
'FilmOnsiteSlowShutterNight',
'FilmOnsiteLowAngle',
'FilmOnsiteRandom',
] as const
const DERIVED_FILM_ROUTE_NAMES = new Set<string>([
'FilmOnsiteShooting',
'FilmEditing',
])
async function getYibanApi(): Promise<typeof import('@/utils/yiban') | null> { async function getYibanApi(): Promise<typeof import('@/utils/yiban') | null> {
try { try {
return await import('@/utils/yiban') return await import('@/utils/yiban')
@@ -68,10 +95,16 @@ export const useFilmStore = defineStore(
const filmMenuVisible = ref<boolean>(false) const filmMenuVisible = ref<boolean>(false)
//Film-后期剪辑6个子视频已观看记录 //Film-后期剪辑6个子视频已观看记录
const editingVideosWatched = ref<Record<string, boolean>>({}) const editingVideosWatched = ref<Record<string, boolean>>({})
const professionVideosWatched = ref<Record<string, boolean>>({})
const workVideosWatched = ref<Record<string, boolean>>({})
//Film-后期剪辑6个视频学完后的一次性引导 //Film-后期剪辑6个视频学完后的一次性引导
const isGuideFilmEditingComplete = ref<boolean>(true) const isGuideFilmEditingComplete = ref<boolean>(true)
const guideShown = ref<Record<string, boolean>>({}) const guideShown = ref<Record<string, boolean>>({})
const practiceScrollPage = ref<number>(0) const practiceScrollPage = ref<number>(0)
const workSelectedTab = ref<FilmWorkTab>('case')
const practiceNoteContent = ref<string>('')
const practiceSelfEvaluationScores = ref<PracticeRatingScores>({})
const practiceWorkRatings = ref<PracticeWorkRatings>({})
//Actions //Actions
function saveFilmStore(): void { function saveFilmStore(): void {
@@ -85,9 +118,15 @@ export const useFilmStore = defineStore(
FilmUnlockProgress: FilmUnlockProgress.value, FilmUnlockProgress: FilmUnlockProgress.value,
filmMenuVisible: filmMenuVisible.value, filmMenuVisible: filmMenuVisible.value,
editingVideosWatched: editingVideosWatched.value, editingVideosWatched: editingVideosWatched.value,
professionVideosWatched: professionVideosWatched.value,
workVideosWatched: workVideosWatched.value,
isGuideFilmEditingComplete: isGuideFilmEditingComplete.value, isGuideFilmEditingComplete: isGuideFilmEditingComplete.value,
guideShown: guideShown.value, guideShown: guideShown.value,
practiceScrollPage: practiceScrollPage.value, practiceScrollPage: practiceScrollPage.value,
workSelectedTab: workSelectedTab.value,
practiceNoteContent: practiceNoteContent.value,
practiceSelfEvaluationScores: practiceSelfEvaluationScores.value,
practiceWorkRatings: practiceWorkRatings.value,
} }
getYibanApi() getYibanApi()
@@ -115,16 +154,23 @@ export const useFilmStore = defineStore(
FilmUnlockProgress.value = data.FilmUnlockProgress ?? 0 FilmUnlockProgress.value = data.FilmUnlockProgress ?? 0
filmMenuVisible.value = data.filmMenuVisible ?? false filmMenuVisible.value = data.filmMenuVisible ?? false
editingVideosWatched.value = data.editingVideosWatched ?? {} editingVideosWatched.value = data.editingVideosWatched ?? {}
professionVideosWatched.value = data.professionVideosWatched ?? {}
workVideosWatched.value = data.workVideosWatched ?? {}
isGuideFilmEditingComplete.value = isGuideFilmEditingComplete.value =
data.isGuideFilmEditingComplete ?? true data.isGuideFilmEditingComplete ?? true
guideShown.value = data.guideShown ?? {} guideShown.value = data.guideShown ?? {}
practiceScrollPage.value = data.practiceScrollPage ?? 0 practiceScrollPage.value = data.practiceScrollPage ?? 0
workSelectedTab.value = data.workSelectedTab ?? 'case'
practiceNoteContent.value = data.practiceNoteContent ?? ''
practiceSelfEvaluationScores.value =
data.practiceSelfEvaluationScores ?? {}
practiceWorkRatings.value = data.practiceWorkRatings ?? {}
} catch (error) { } catch (error) {
console.error('Failed to load Film store', error) console.error('Failed to load Film store', error)
} }
} }
function markViewed(key: string): void { function markRouteViewed(key: string): void {
if (!key) return if (!key) return
viewed.value[key] = true viewed.value[key] = true
FilmUnlockProgress.value = Math.max( FilmUnlockProgress.value = Math.max(
@@ -134,6 +180,12 @@ export const useFilmStore = defineStore(
saveFilmStore() saveFilmStore()
} }
function markViewed(key: string): void {
if (!key) return
if (DERIVED_FILM_ROUTE_NAMES.has(key)) return
markRouteViewed(key)
}
function setIsGuideFilmTask(value: boolean): void { function setIsGuideFilmTask(value: boolean): void {
isGuideFilmTask.value = value isGuideFilmTask.value = value
saveFilmStore() saveFilmStore()
@@ -174,6 +226,24 @@ export const useFilmStore = defineStore(
if (!key) return if (!key) return
if (editingVideosWatched.value[key]) return if (editingVideosWatched.value[key]) return
editingVideosWatched.value[key] = true editingVideosWatched.value[key] = true
if (allEditingVideosWatched.value) {
markRouteViewed('FilmEditing')
return
}
saveFilmStore()
}
function markProfessionVideoWatched(key: string): void {
if (!key) return
if (professionVideosWatched.value[key]) return
professionVideosWatched.value[key] = true
saveFilmStore()
}
function markWorkVideoWatched(key: string): void {
if (!key) return
if (workVideosWatched.value[key]) return
workVideosWatched.value[key] = true
saveFilmStore() saveFilmStore()
} }
@@ -193,19 +263,63 @@ export const useFilmStore = defineStore(
saveFilmStore() saveFilmStore()
} }
const allEditingVideosWatched = computed<boolean>(() => function setWorkSelectedTab(value: FilmWorkTab): void {
FILM_EDITING_VIDEO_KEYS.every( workSelectedTab.value = value
(key) => editingVideosWatched.value[key] === true, saveFilmStore()
), }
function setPracticeNoteContent(value: string): void {
practiceNoteContent.value = value
saveFilmStore()
}
function setPracticeSelfEvaluationScores(
scores: PracticeRatingScores,
): void {
practiceSelfEvaluationScores.value = scores
saveFilmStore()
}
function setPracticeWorkRating(
workKey: string,
scores: PracticeRatingScores,
): void {
if (!workKey) return
practiceWorkRatings.value[workKey] = scores
saveFilmStore()
}
function getPracticeWorkRating(workKey: string): PracticeRatingScores {
return practiceWorkRatings.value[workKey] ?? {}
}
const areRoutesViewed = (routeNames: readonly string[]) =>
routeNames.every((name) => viewed.value[name] === true)
const areEditingVideosWatched = (keys: readonly string[]) =>
keys.every((key) => editingVideosWatched.value[key] === true)
const allOnsiteScenesViewed = computed<boolean>(() =>
areRoutesViewed(FILM_ONSITE_ROUTE_NAMES),
) )
const allEditingVideosWatched = computed<boolean>(() =>
areEditingVideosWatched(FILM_EDITING_VIDEO_KEYS),
)
function isRouteViewed(name: string): boolean {
if (name === 'FilmOnsiteShooting') return allOnsiteScenesViewed.value
if (name === 'FilmEditing') return allEditingVideosWatched.value
return viewed.value[name] === true
}
// 基于“子页面是否已查看”的大步骤完成度(1..3) // 基于“子页面是否已查看”的大步骤完成度(1..3)
// disabled 子项不计入(在这里直接不放进列表) // disabled 子项不计入(在这里直接不放进列表)
const stepRouteNames = moduleStepMap.film const stepRouteNames = moduleStepMap.film
const completedStep = computed<number>(() => { const completedStep = computed<number>(() => {
const isDone = (step: 1 | 2 | 3) => const isDone = (step: 1 | 2 | 3) =>
stepRouteNames[step].every((name) => viewed.value[name] === true) stepRouteNames[step].every((name) => isRouteViewed(name))
if (isDone(1) && isDone(2) && isDone(3)) return 3 if (isDone(1) && isDone(2) && isDone(3)) return 3
if (isDone(1) && isDone(2)) return 2 if (isDone(1) && isDone(2)) return 2
@@ -217,7 +331,7 @@ export const useFilmStore = defineStore(
const progressPercent = computed<number>(() => { const progressPercent = computed<number>(() => {
const allRoutes = (Object.values(stepRouteNames) as string[][]).flat() const allRoutes = (Object.values(stepRouteNames) as string[][]).flat()
if (allRoutes.length === 0) return 0 if (allRoutes.length === 0) return 0
const viewedCount = allRoutes.filter((name) => viewed.value[name]).length const viewedCount = allRoutes.filter((name) => isRouteViewed(name)).length
return Math.round((viewedCount / allRoutes.length) * 100) return Math.round((viewedCount / allRoutes.length) * 100)
}) })
@@ -232,9 +346,15 @@ export const useFilmStore = defineStore(
FilmUnlockProgress.value = 0 FilmUnlockProgress.value = 0
filmMenuVisible.value = false filmMenuVisible.value = false
editingVideosWatched.value = {} editingVideosWatched.value = {}
professionVideosWatched.value = {}
workVideosWatched.value = {}
isGuideFilmEditingComplete.value = true isGuideFilmEditingComplete.value = true
guideShown.value = {} guideShown.value = {}
practiceScrollPage.value = 0 practiceScrollPage.value = 0
workSelectedTab.value = 'case'
practiceNoteContent.value = ''
practiceSelfEvaluationScores.value = {}
practiceWorkRatings.value = {}
saveFilmStore() saveFilmStore()
} }
@@ -249,9 +369,16 @@ export const useFilmStore = defineStore(
setIsGuideFilmStage3, setIsGuideFilmStage3,
setFilmMenuVisible, setFilmMenuVisible,
markEditingVideoWatched, markEditingVideoWatched,
markProfessionVideoWatched,
markWorkVideoWatched,
setIsGuideFilmEditingComplete, setIsGuideFilmEditingComplete,
markGuideShown, markGuideShown,
setPracticeScrollPage, setPracticeScrollPage,
setWorkSelectedTab,
setPracticeNoteContent,
setPracticeSelfEvaluationScores,
setPracticeWorkRating,
getPracticeWorkRating,
saveFilmStore, saveFilmStore,
loadFilmStore, loadFilmStore,
resetFilmState, resetFilmState,
@@ -267,10 +394,18 @@ export const useFilmStore = defineStore(
isGuideFilmStage3, isGuideFilmStage3,
filmMenuVisible, filmMenuVisible,
editingVideosWatched, editingVideosWatched,
professionVideosWatched,
workVideosWatched,
isGuideFilmEditingComplete, isGuideFilmEditingComplete,
guideShown, guideShown,
practiceScrollPage, practiceScrollPage,
workSelectedTab,
practiceNoteContent,
practiceSelfEvaluationScores,
practiceWorkRatings,
allOnsiteScenesViewed,
allEditingVideosWatched, allEditingVideosWatched,
isRouteViewed,
} }
}, },
{ {
+149
View File
@@ -5,6 +5,30 @@ import { moduleStepMap } from '@/config/moduleStepMap'
export const INTERVIEW_LESSON_ID = 'fangtan_rwqjk' export const INTERVIEW_LESSON_ID = 'fangtan_rwqjk'
export const INTERVIEW_KEY = 3 export const INTERVIEW_KEY = 3
type InterviewWorkTab = 'vlog' | 'case' | 'profession' | 'idea'
export const INTERVIEW_ONSITE_VIDEO_KEYS = [
'interview-onsite-shoot-material',
'interview-onsite-required-shots',
'interview-onsite-moving-shot',
'interview-onsite-close-up',
] as const
export const INTERVIEW_EDITING_VIDEO_KEYS = [
'interview-editing-basic-flow',
'interview-editing-j-cut-l-cut',
'interview-editing-rhythm',
] as const
export const INTERVIEW_WORK_COMPLETION_KEYS = [
'case',
'profession',
'idea',
] as const
type PracticeRatingScores = Record<string, number>
type PracticeWorkRatings = Record<string, PracticeRatingScores>
interface InterviewSavePayload { interface InterviewSavePayload {
viewed?: Record<string, boolean> viewed?: Record<string, boolean>
isGuideInterviewTask?: boolean isGuideInterviewTask?: boolean
@@ -14,8 +38,14 @@ interface InterviewSavePayload {
isInterviewUnlock?: boolean isInterviewUnlock?: boolean
InterviewUnlockProgress?: number InterviewUnlockProgress?: number
interviewMenuVisible?: boolean interviewMenuVisible?: boolean
professionVideosWatched?: Record<string, boolean>
workVideosWatched?: Record<string, boolean>
guideShown?: Record<string, boolean> guideShown?: Record<string, boolean>
practiceScrollPage?: number practiceScrollPage?: number
workSelectedTab?: InterviewWorkTab
practiceNoteContent?: string
practiceSelfEvaluationScores?: PracticeRatingScores
practiceWorkRatings?: PracticeWorkRatings
} }
async function getYibanApi(): Promise<typeof import('@/utils/yiban') | null> { async function getYibanApi(): Promise<typeof import('@/utils/yiban') | null> {
@@ -55,8 +85,14 @@ export const useInterviewStore = defineStore(
const InterviewUnlockProgress = ref<number>(0) const InterviewUnlockProgress = ref<number>(0)
//Interview-菜单 Overlay 是否可见 //Interview-菜单 Overlay 是否可见
const interviewMenuVisible = ref<boolean>(false) const interviewMenuVisible = ref<boolean>(false)
const professionVideosWatched = ref<Record<string, boolean>>({})
const workVideosWatched = ref<Record<string, boolean>>({})
const guideShown = ref<Record<string, boolean>>({}) const guideShown = ref<Record<string, boolean>>({})
const practiceScrollPage = ref<number>(0) const practiceScrollPage = ref<number>(0)
const workSelectedTab = ref<InterviewWorkTab>('case')
const practiceNoteContent = ref<string>('')
const practiceSelfEvaluationScores = ref<PracticeRatingScores>({})
const practiceWorkRatings = ref<PracticeWorkRatings>({})
//Actions //Actions
function saveInterviewStore(): void { function saveInterviewStore(): void {
@@ -69,8 +105,14 @@ export const useInterviewStore = defineStore(
isInterviewUnlock: isInterviewUnlock.value, isInterviewUnlock: isInterviewUnlock.value,
InterviewUnlockProgress: InterviewUnlockProgress.value, InterviewUnlockProgress: InterviewUnlockProgress.value,
interviewMenuVisible: interviewMenuVisible.value, interviewMenuVisible: interviewMenuVisible.value,
professionVideosWatched: professionVideosWatched.value,
workVideosWatched: workVideosWatched.value,
guideShown: guideShown.value, guideShown: guideShown.value,
practiceScrollPage: practiceScrollPage.value, practiceScrollPage: practiceScrollPage.value,
workSelectedTab: workSelectedTab.value,
practiceNoteContent: practiceNoteContent.value,
practiceSelfEvaluationScores: practiceSelfEvaluationScores.value,
practiceWorkRatings: practiceWorkRatings.value,
} }
getYibanApi() getYibanApi()
@@ -103,8 +145,15 @@ export const useInterviewStore = defineStore(
isInterviewUnlock.value = data.isInterviewUnlock ?? false isInterviewUnlock.value = data.isInterviewUnlock ?? false
InterviewUnlockProgress.value = data.InterviewUnlockProgress ?? 0 InterviewUnlockProgress.value = data.InterviewUnlockProgress ?? 0
interviewMenuVisible.value = data.interviewMenuVisible ?? false interviewMenuVisible.value = data.interviewMenuVisible ?? false
professionVideosWatched.value = data.professionVideosWatched ?? {}
workVideosWatched.value = data.workVideosWatched ?? {}
guideShown.value = data.guideShown ?? {} guideShown.value = data.guideShown ?? {}
practiceScrollPage.value = data.practiceScrollPage ?? 0 practiceScrollPage.value = data.practiceScrollPage ?? 0
workSelectedTab.value = data.workSelectedTab ?? 'case'
practiceNoteContent.value = data.practiceNoteContent ?? ''
practiceSelfEvaluationScores.value =
data.practiceSelfEvaluationScores ?? {}
practiceWorkRatings.value = data.practiceWorkRatings ?? {}
} catch (error) { } catch (error) {
console.error('Failed to load Interview store', error) console.error('Failed to load Interview store', error)
} }
@@ -120,8 +169,48 @@ export const useInterviewStore = defineStore(
saveInterviewStore() saveInterviewStore()
} }
function areProfessionVideosWatched(keys: readonly string[]): boolean {
return keys.every((key) => professionVideosWatched.value[key] === true)
}
function markVideoCompletionRouteIfReady(
routeName: string,
keys: readonly string[],
): void {
if (areProfessionVideosWatched(keys)) {
markViewed(routeName)
return
}
saveInterviewStore()
}
// 基于“子页面是否已查看”的大步骤完成度(1..3) // 基于“子页面是否已查看”的大步骤完成度(1..3)
// disabled 子项不计入(在这里直接不放进列表) // disabled 子项不计入(在这里直接不放进列表)
function setPracticeNoteContent(value: string): void {
practiceNoteContent.value = value
saveInterviewStore()
}
function setPracticeSelfEvaluationScores(
scores: PracticeRatingScores,
): void {
practiceSelfEvaluationScores.value = scores
saveInterviewStore()
}
function setPracticeWorkRating(
workKey: string,
scores: PracticeRatingScores,
): void {
if (!workKey) return
practiceWorkRatings.value[workKey] = scores
saveInterviewStore()
}
function getPracticeWorkRating(workKey: string): PracticeRatingScores {
return practiceWorkRatings.value[workKey] ?? {}
}
const stepRouteNames = moduleStepMap.interview const stepRouteNames = moduleStepMap.interview
const completedStep = computed<number>(() => { const completedStep = computed<number>(() => {
@@ -177,6 +266,40 @@ export const useInterviewStore = defineStore(
saveInterviewStore() saveInterviewStore()
} }
function markProfessionVideoWatched(key: string): void {
if (!key) return
if (professionVideosWatched.value[key]) return
professionVideosWatched.value[key] = true
saveInterviewStore()
}
function markWorkVideoWatched(key: string): void {
if (!key) return
if (workVideosWatched.value[key]) return
workVideosWatched.value[key] = true
saveInterviewStore()
}
function markOnsiteVideoWatched(key: string): void {
if (!key) return
if (professionVideosWatched.value[key]) return
professionVideosWatched.value[key] = true
markVideoCompletionRouteIfReady(
'InterviewOnsiteShooting',
INTERVIEW_ONSITE_VIDEO_KEYS,
)
}
function markEditingVideoWatched(key: string): void {
if (!key) return
if (professionVideosWatched.value[key]) return
professionVideosWatched.value[key] = true
markVideoCompletionRouteIfReady(
'InterviewEditing',
INTERVIEW_EDITING_VIDEO_KEYS,
)
}
function markGuideShown(key: string): void { function markGuideShown(key: string): void {
if (!key) return if (!key) return
guideShown.value[key] = true guideShown.value[key] = true
@@ -188,6 +311,11 @@ export const useInterviewStore = defineStore(
saveInterviewStore() saveInterviewStore()
} }
function setWorkSelectedTab(value: InterviewWorkTab): void {
workSelectedTab.value = value
saveInterviewStore()
}
function resetInterviewState(): void { function resetInterviewState(): void {
viewed.value = {} viewed.value = {}
isGuideInterviewTask.value = true isGuideInterviewTask.value = true
@@ -197,8 +325,14 @@ export const useInterviewStore = defineStore(
isInterviewUnlock.value = false isInterviewUnlock.value = false
InterviewUnlockProgress.value = 0 InterviewUnlockProgress.value = 0
interviewMenuVisible.value = false interviewMenuVisible.value = false
professionVideosWatched.value = {}
workVideosWatched.value = {}
guideShown.value = {} guideShown.value = {}
practiceScrollPage.value = 0 practiceScrollPage.value = 0
workSelectedTab.value = 'case'
practiceNoteContent.value = ''
practiceSelfEvaluationScores.value = {}
practiceWorkRatings.value = {}
saveInterviewStore() saveInterviewStore()
} }
@@ -212,8 +346,17 @@ export const useInterviewStore = defineStore(
setIsInterviewUnlock, setIsInterviewUnlock,
setInterviewUnlockProgress, setInterviewUnlockProgress,
setInterviewMenuVisible, setInterviewMenuVisible,
markProfessionVideoWatched,
markWorkVideoWatched,
markOnsiteVideoWatched,
markEditingVideoWatched,
markGuideShown, markGuideShown,
setPracticeScrollPage, setPracticeScrollPage,
setWorkSelectedTab,
setPracticeNoteContent,
setPracticeSelfEvaluationScores,
setPracticeWorkRating,
getPracticeWorkRating,
resetInterviewState, resetInterviewState,
loadInterviewStore, loadInterviewStore,
saveInterviewStore, saveInterviewStore,
@@ -228,8 +371,14 @@ export const useInterviewStore = defineStore(
isInterviewUnlock, isInterviewUnlock,
InterviewUnlockProgress, InterviewUnlockProgress,
interviewMenuVisible, interviewMenuVisible,
professionVideosWatched,
workVideosWatched,
guideShown, guideShown,
practiceScrollPage, practiceScrollPage,
workSelectedTab,
practiceNoteContent,
practiceSelfEvaluationScores,
practiceWorkRatings,
} }
}, },
{ {
+139
View File
@@ -5,6 +5,26 @@ import { moduleStepMap } from '@/config/moduleStepMap'
export const VLOG_LESSON_ID = 'vlog_rwqjk' export const VLOG_LESSON_ID = 'vlog_rwqjk'
export const VLOG_KEY = 2 export const VLOG_KEY = 2
type VlogWorkTab = 'vlog' | 'case' | 'profession' | 'idea'
export const VLOG_ONSITE_VIDEO_KEYS = [
'vlog-onsite-opening-shot',
'vlog-onsite-lens-language',
'vlog-onsite-motion-capture',
] as const
export const VLOG_EDITING_VIDEO_KEYS = [
'vlog-editing-start',
'vlog-editing-audio-picture-match',
'vlog-editing-rhythm',
'vlog-editing-sound-transition',
] as const
export const VLOG_WORK_COMPLETION_KEYS = ['case', 'profession', 'idea'] as const
type PracticeRatingScores = Record<string, number>
type PracticeWorkRatings = Record<string, PracticeRatingScores>
interface VlogSavePayload { interface VlogSavePayload {
viewed?: Record<string, boolean> viewed?: Record<string, boolean>
isGuideVlogTask?: boolean isGuideVlogTask?: boolean
@@ -15,8 +35,14 @@ interface VlogSavePayload {
isVlogUnlock?: boolean isVlogUnlock?: boolean
vlogUnlockProgress?: number vlogUnlockProgress?: number
vlogMenuVisible?: boolean vlogMenuVisible?: boolean
professionVideosWatched?: Record<string, boolean>
workVideosWatched?: Record<string, boolean>
guideShown?: Record<string, boolean> guideShown?: Record<string, boolean>
practiceScrollPage?: number practiceScrollPage?: number
workSelectedTab?: VlogWorkTab
practiceNoteContent?: string
practiceSelfEvaluationScores?: PracticeRatingScores
practiceWorkRatings?: PracticeWorkRatings
} }
async function getYibanApi(): Promise<typeof import('@/utils/yiban') | null> { async function getYibanApi(): Promise<typeof import('@/utils/yiban') | null> {
@@ -60,8 +86,14 @@ export const useVlogStore = defineStore(
//vlog-发现与记录解锁进度 //vlog-发现与记录解锁进度
const vlogUnlockProgress = ref<number>(0) const vlogUnlockProgress = ref<number>(0)
const professionVideosWatched = ref<Record<string, boolean>>({})
const workVideosWatched = ref<Record<string, boolean>>({})
const guideShown = ref<Record<string, boolean>>({}) const guideShown = ref<Record<string, boolean>>({})
const practiceScrollPage = ref<number>(0) const practiceScrollPage = ref<number>(0)
const workSelectedTab = ref<VlogWorkTab>('case')
const practiceNoteContent = ref<string>('')
const practiceSelfEvaluationScores = ref<PracticeRatingScores>({})
const practiceWorkRatings = ref<PracticeWorkRatings>({})
//Actions //Actions
function saveVlogStore(): void { function saveVlogStore(): void {
@@ -75,8 +107,14 @@ export const useVlogStore = defineStore(
isVlogUnlock: isVlogUnlock.value, isVlogUnlock: isVlogUnlock.value,
vlogUnlockProgress: vlogUnlockProgress.value, vlogUnlockProgress: vlogUnlockProgress.value,
vlogMenuVisible: vlogMenuVisible.value, vlogMenuVisible: vlogMenuVisible.value,
professionVideosWatched: professionVideosWatched.value,
workVideosWatched: workVideosWatched.value,
guideShown: guideShown.value, guideShown: guideShown.value,
practiceScrollPage: practiceScrollPage.value, practiceScrollPage: practiceScrollPage.value,
workSelectedTab: workSelectedTab.value,
practiceNoteContent: practiceNoteContent.value,
practiceSelfEvaluationScores: practiceSelfEvaluationScores.value,
practiceWorkRatings: practiceWorkRatings.value,
} }
getYibanApi() getYibanApi()
@@ -104,8 +142,15 @@ export const useVlogStore = defineStore(
isVlogUnlock.value = data.isVlogUnlock ?? false isVlogUnlock.value = data.isVlogUnlock ?? false
vlogUnlockProgress.value = data.vlogUnlockProgress ?? 0 vlogUnlockProgress.value = data.vlogUnlockProgress ?? 0
vlogMenuVisible.value = data.vlogMenuVisible ?? false vlogMenuVisible.value = data.vlogMenuVisible ?? false
professionVideosWatched.value = data.professionVideosWatched ?? {}
workVideosWatched.value = data.workVideosWatched ?? {}
guideShown.value = data.guideShown ?? {} guideShown.value = data.guideShown ?? {}
practiceScrollPage.value = data.practiceScrollPage ?? 0 practiceScrollPage.value = data.practiceScrollPage ?? 0
workSelectedTab.value = data.workSelectedTab ?? 'case'
practiceNoteContent.value = data.practiceNoteContent ?? ''
practiceSelfEvaluationScores.value =
data.practiceSelfEvaluationScores ?? {}
practiceWorkRatings.value = data.practiceWorkRatings ?? {}
} catch (error) { } catch (error) {
console.error('Failed to load Vlog store', error) console.error('Failed to load Vlog store', error)
} }
@@ -121,6 +166,21 @@ export const useVlogStore = defineStore(
saveVlogStore() saveVlogStore()
} }
function areProfessionVideosWatched(keys: readonly string[]): boolean {
return keys.every((key) => professionVideosWatched.value[key] === true)
}
function markVideoCompletionRouteIfReady(
routeName: string,
keys: readonly string[],
): void {
if (areProfessionVideosWatched(keys)) {
markViewed(routeName)
return
}
saveVlogStore()
}
function setIsGuideVlogTask(value: boolean): void { function setIsGuideVlogTask(value: boolean): void {
isGuideVlogTask.value = value isGuideVlogTask.value = value
saveVlogStore() saveVlogStore()
@@ -151,6 +211,34 @@ export const useVlogStore = defineStore(
saveVlogStore() saveVlogStore()
} }
function markProfessionVideoWatched(key: string): void {
if (!key) return
if (professionVideosWatched.value[key]) return
professionVideosWatched.value[key] = true
saveVlogStore()
}
function markWorkVideoWatched(key: string): void {
if (!key) return
if (workVideosWatched.value[key]) return
workVideosWatched.value[key] = true
saveVlogStore()
}
function markOnsiteVideoWatched(key: string): void {
if (!key) return
if (professionVideosWatched.value[key]) return
professionVideosWatched.value[key] = true
markVideoCompletionRouteIfReady('VlogShooting', VLOG_ONSITE_VIDEO_KEYS)
}
function markEditingVideoWatched(key: string): void {
if (!key) return
if (professionVideosWatched.value[key]) return
professionVideosWatched.value[key] = true
markVideoCompletionRouteIfReady('VlogEditing', VLOG_EDITING_VIDEO_KEYS)
}
function setIsVlogUnlock(value: boolean): void { function setIsVlogUnlock(value: boolean): void {
isVlogUnlock.value = value isVlogUnlock.value = value
saveVlogStore() saveVlogStore()
@@ -173,8 +261,38 @@ export const useVlogStore = defineStore(
saveVlogStore() saveVlogStore()
} }
function setWorkSelectedTab(value: VlogWorkTab): void {
workSelectedTab.value = value
saveVlogStore()
}
// 基于“子页面是否已查看”的大步骤完成度(1..3) // 基于“子页面是否已查看”的大步骤完成度(1..3)
// disabled 子项不计入(在这里直接不放进列表) // disabled 子项不计入(在这里直接不放进列表)
function setPracticeNoteContent(value: string): void {
practiceNoteContent.value = value
saveVlogStore()
}
function setPracticeSelfEvaluationScores(
scores: PracticeRatingScores,
): void {
practiceSelfEvaluationScores.value = scores
saveVlogStore()
}
function setPracticeWorkRating(
workKey: string,
scores: PracticeRatingScores,
): void {
if (!workKey) return
practiceWorkRatings.value[workKey] = scores
saveVlogStore()
}
function getPracticeWorkRating(workKey: string): PracticeRatingScores {
return practiceWorkRatings.value[workKey] ?? {}
}
const stepRouteNames = moduleStepMap.vlog const stepRouteNames = moduleStepMap.vlog
const completedStep = computed<number>(() => { const completedStep = computed<number>(() => {
@@ -205,8 +323,14 @@ export const useVlogStore = defineStore(
isGuideVlogStage3.value = true isGuideVlogStage3.value = true
isVlogUnlock.value = false isVlogUnlock.value = false
vlogMenuVisible.value = false vlogMenuVisible.value = false
professionVideosWatched.value = {}
workVideosWatched.value = {}
guideShown.value = {} guideShown.value = {}
practiceScrollPage.value = 0 practiceScrollPage.value = 0
workSelectedTab.value = 'case'
practiceNoteContent.value = ''
practiceSelfEvaluationScores.value = {}
practiceWorkRatings.value = {}
saveVlogStore() saveVlogStore()
} }
@@ -220,12 +344,21 @@ export const useVlogStore = defineStore(
setIsGuideVlogStage3, setIsGuideVlogStage3,
setIsVlogUnlock, setIsVlogUnlock,
setVlogMenuVisible, setVlogMenuVisible,
markProfessionVideoWatched,
markWorkVideoWatched,
markOnsiteVideoWatched,
markEditingVideoWatched,
saveVlogStore, saveVlogStore,
loadVlogStore, loadVlogStore,
resetVlogState, resetVlogState,
setVlogUnlockProgress, setVlogUnlockProgress,
markGuideShown, markGuideShown,
setPracticeScrollPage, setPracticeScrollPage,
setWorkSelectedTab,
setPracticeNoteContent,
setPracticeSelfEvaluationScores,
setPracticeWorkRating,
getPracticeWorkRating,
//Actions //Actions
completedStep, completedStep,
progressPercent, progressPercent,
@@ -238,8 +371,14 @@ export const useVlogStore = defineStore(
isVlogUnlock, isVlogUnlock,
vlogUnlockProgress, vlogUnlockProgress,
vlogMenuVisible, vlogMenuVisible,
professionVideosWatched,
workVideosWatched,
guideShown, guideShown,
practiceScrollPage, practiceScrollPage,
workSelectedTab,
practiceNoteContent,
practiceSelfEvaluationScores,
practiceWorkRatings,
} }
}, },
{ {
@@ -1,9 +1,10 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, nextTick, ref } from 'vue' import { computed, nextTick, ref } from 'vue'
import { useRouter } from 'vue-router' import { onBeforeRouteLeave, useRouter } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue' import ImgButton from '@/components/Button/ImgButton.vue'
import { GuideSteps, GuideStepsItem } from '@/components/Guide' import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import StepNavigation from '@/components/Guide/StepNavigation.vue' import StepNavigation from '@/components/Guide/StepNavigation.vue'
import UnfinishedLearningPrompt from '@/components/UnfinishedLearningPrompt'
import { Video } from '@/components' import { Video } from '@/components'
import QuestionQuiz, { import QuestionQuiz, {
type Question, type Question,
@@ -79,11 +80,35 @@ const navigationSteps = ref(rawSteps)
// 步骤导航相关状态 // 步骤导航相关状态
const showStepNavigation = ref(false) const showStepNavigation = ref(false)
const showUnfinishedPrompt = ref(false)
const pendingLeavePath = ref<string | null>(null)
const allowRouteLeave = ref(false)
const currentNavigationStep = ref(0) // 对应视频索引,从0开始 const currentNavigationStep = ref(0) // 对应视频索引,从0开始
// 视频列表 // 视频列表
const currentVideoIndex = ref(0) const currentVideoIndex = ref(0)
const getLearningVideoKey = (index: number) =>
`basic-shooting-skills:${navigationSteps.value[index]?.id ?? index}`
const completedNavigationSteps = computed(() =>
navigationSteps.value.map(
(_, index) =>
basicElementsStore.learningVideosWatched[getLearningVideoKey(index)] ===
true,
),
)
const hasUnfinishedLearningVideos = computed(() =>
completedNavigationSteps.value.some((completed) => !completed),
)
const markCurrentLearningVideoWatched = () => {
basicElementsStore.markLearningVideoWatched(
getLearningVideoKey(currentVideoIndex.value),
)
}
//按顺序播放 //按顺序播放
const currentVideo = computed( const currentVideo = computed(
() => navigationSteps.value[currentVideoIndex.value].videoUrl, () => navigationSteps.value[currentVideoIndex.value].videoUrl,
@@ -101,7 +126,7 @@ const getQuestionsForVideo = (index: number): Question[] => {
id: 1, id: 1,
title: '', title: '',
description: description:
'(单选)如果你想让观众“看清环境、理解空间关系”,更适合用哪种镜头?', '【单选题】如果你想让观众“看清环境、理解空间关系”,更适合用哪种镜头?',
options: [ options: [
{ {
id: 'a', id: 'a',
@@ -129,7 +154,7 @@ const getQuestionsForVideo = (index: number): Question[] => {
id: 2, id: 2,
title: '', title: '',
description: description:
'(多选题)下面这些拍摄场景中,哪些更适合使用"慢动作"来表现?', '【多选题】下面这些拍摄场景中,哪些更适合使用"慢动作"来表现?',
options: [ options: [
{ {
id: 'a', id: 'a',
@@ -163,7 +188,8 @@ const getQuestionsForVideo = (index: number): Question[] => {
{ {
id: 3, id: 3,
title: '', title: '',
description: '(单选)你觉得下面哪句话,更接近“新手拍视频的正确思路”?', description:
'【单选题】你觉得下面哪句话,更接近“新手拍视频的正确思路”?',
options: [ options: [
{ {
id: 'a', id: 'a',
@@ -209,6 +235,8 @@ const handleClose = () => {
const handlePlay = () => {} const handlePlay = () => {}
const onEnded = () => { const onEnded = () => {
markCurrentLearningVideoWatched()
// 在指定视频结束时弹出答题 // 在指定视频结束时弹出答题
if ( if (
currentVideoIndex.value === 1 || currentVideoIndex.value === 1 ||
@@ -250,6 +278,25 @@ const handleQuestionsComplete = () => {
// 答题完成后继续播放下一个视频 // 答题完成后继续播放下一个视频
handleVideoChange() handleVideoChange()
} }
const handleContinueLearning = () => {
showUnfinishedPrompt.value = false
pendingLeavePath.value = null
}
const handleConfirmLeave = () => {
if (!pendingLeavePath.value) return
allowRouteLeave.value = true
showUnfinishedPrompt.value = false
router.push(pendingLeavePath.value)
}
onBeforeRouteLeave((to) => {
if (allowRouteLeave.value || !hasUnfinishedLearningVideos.value) return true
pendingLeavePath.value = to.fullPath
showUnfinishedPrompt.value = true
return false
})
</script> </script>
<template> <template>
@@ -313,10 +360,17 @@ const handleQuestionsComplete = () => {
:background-image="getStaticImage('work/vlog背景全屏待定')" :background-image="getStaticImage('work/vlog背景全屏待定')"
:steps="navigationSteps" :steps="navigationSteps"
:current-step-index="currentNavigationStep" :current-step-index="currentNavigationStep"
:completed-steps="completedNavigationSteps"
@step-click="handleStepClick" @step-click="handleStepClick"
@close="handleStepNavigationClose" @close="handleStepNavigationClose"
/> />
<UnfinishedLearningPrompt
:visible="showUnfinishedPrompt"
@continue="handleContinueLearning"
@leave="handleConfirmLeave"
/>
<GuideSteps <GuideSteps
:visible="showIntroGuide" :visible="showIntroGuide"
position-offset-y="85%" position-offset-y="85%"
@@ -326,7 +380,7 @@ const handleQuestionsComplete = () => {
欢迎来到基础拍摄训练区! 欢迎来到基础拍摄训练区!
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_019.MP3')"> <GuideStepsItem :audio-src="getPublicPath('audio/py_1_019.MP3')">
我会带你依次认识手机摄像头、看懂景别、用好慢动作与延时。 我会带你认识手机摄像头、看懂景别、了解慢动作与延迟摄影。
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_020.MP3')"> <GuideStepsItem :audio-src="getPublicPath('audio/py_1_020.MP3')">
还会教你拍出更稳的微距和更亮的夜景。 还会教你拍出更稳的微距和更亮的夜景。
@@ -1,8 +1,9 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, nextTick, ref } from 'vue' import { computed, nextTick, ref } from 'vue'
import { useRouter } from 'vue-router' import { onBeforeRouteLeave, useRouter } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue' import ImgButton from '@/components/Button/ImgButton.vue'
import StepNavigation from '@/components/Guide/StepNavigation.vue' import StepNavigation from '@/components/Guide/StepNavigation.vue'
import UnfinishedLearningPrompt from '@/components/UnfinishedLearningPrompt'
import { Video } from '@/components' import { Video } from '@/components'
import { getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils' import { getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
// 运镜初识 - 暂时用运镜的基本方式代替 // 运镜初识 - 暂时用运镜的基本方式代替
@@ -73,11 +74,35 @@ const navigationSteps = ref([
// 步骤导航相关状态 // 步骤导航相关状态
const showStepNavigation = ref(false) const showStepNavigation = ref(false)
const showUnfinishedPrompt = ref(false)
const pendingLeavePath = ref<string | null>(null)
const allowRouteLeave = ref(false)
const currentNavigationStep = ref(0) // 对应视频索引,从0开始 const currentNavigationStep = ref(0) // 对应视频索引,从0开始
// 视频列表 // 视频列表
const currentVideoIndex = ref(0) const currentVideoIndex = ref(0)
const getLearningVideoKey = (index: number) =>
`basic-camera-movement:${navigationSteps.value[index]?.id ?? index}`
const completedNavigationSteps = computed(() =>
navigationSteps.value.map(
(_, index) =>
basicElementsStore.learningVideosWatched[getLearningVideoKey(index)] ===
true,
),
)
const hasUnfinishedLearningVideos = computed(() =>
completedNavigationSteps.value.some((completed) => !completed),
)
const markCurrentLearningVideoWatched = () => {
basicElementsStore.markLearningVideoWatched(
getLearningVideoKey(currentVideoIndex.value),
)
}
//按顺序播放 //按顺序播放
const currentVideo = computed( const currentVideo = computed(
() => navigationSteps.value[currentVideoIndex.value].videoUrl, () => navigationSteps.value[currentVideoIndex.value].videoUrl,
@@ -113,6 +138,7 @@ const label = computed(() => props.label as string)
const handlePlay = () => {} const handlePlay = () => {}
const onEnded = () => { const onEnded = () => {
markCurrentLearningVideoWatched()
handleVideoChange() handleVideoChange()
} }
@@ -128,6 +154,25 @@ const handleStepClick = (index: number) => {
const handleStepNavigationClose = () => { const handleStepNavigationClose = () => {
showStepNavigation.value = false showStepNavigation.value = false
} }
const handleContinueLearning = () => {
showUnfinishedPrompt.value = false
pendingLeavePath.value = null
}
const handleConfirmLeave = () => {
if (!pendingLeavePath.value) return
allowRouteLeave.value = true
showUnfinishedPrompt.value = false
router.push(pendingLeavePath.value)
}
onBeforeRouteLeave((to) => {
if (allowRouteLeave.value || !hasUnfinishedLearningVideos.value) return true
pendingLeavePath.value = to.fullPath
showUnfinishedPrompt.value = true
return false
})
</script> </script>
<template> <template>
@@ -184,10 +229,17 @@ const handleStepNavigationClose = () => {
:background-image="getStaticImage('work/vlog背景全屏待定')" :background-image="getStaticImage('work/vlog背景全屏待定')"
:steps="navigationSteps" :steps="navigationSteps"
:current-step-index="currentNavigationStep" :current-step-index="currentNavigationStep"
:completed-steps="completedNavigationSteps"
@step-click="handleStepClick" @step-click="handleStepClick"
@close="handleStepNavigationClose" @close="handleStepNavigationClose"
/> />
<UnfinishedLearningPrompt
:visible="showUnfinishedPrompt"
@continue="handleContinueLearning"
@leave="handleConfirmLeave"
/>
<GuideSteps <GuideSteps
:visible="showIntroGuide" :visible="showIntroGuide"
position-offset-y="85%" position-offset-y="85%"
+53 -1
View File
@@ -1,8 +1,9 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, nextTick, onMounted, ref } from 'vue' import { computed, nextTick, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { onBeforeRouteLeave, useRouter } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue' import ImgButton from '@/components/Button/ImgButton.vue'
import StepNavigation from '@/components/Guide/StepNavigation.vue' import StepNavigation from '@/components/Guide/StepNavigation.vue'
import UnfinishedLearningPrompt from '@/components/UnfinishedLearningPrompt'
import { Video } from '@/components' import { Video } from '@/components'
import { getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils' import { getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
import { GuideSteps, GuideStepsItem } from '@/components/Guide' import { GuideSteps, GuideStepsItem } from '@/components/Guide'
@@ -45,11 +46,35 @@ const navigationSteps = ref([
// 步骤导航相关状态 // 步骤导航相关状态
const showStepNavigation = ref(false) const showStepNavigation = ref(false)
const showUnfinishedPrompt = ref(false)
const pendingLeavePath = ref<string | null>(null)
const allowRouteLeave = ref(false)
const currentNavigationStep = ref(0) // 对应视频索引,从0开始 const currentNavigationStep = ref(0) // 对应视频索引,从0开始
// 视频列表 // 视频列表
const currentVideoIndex = ref(0) const currentVideoIndex = ref(0)
const getLearningVideoKey = (index: number) =>
`basic-composition:${navigationSteps.value[index]?.id ?? index}`
const completedNavigationSteps = computed(() =>
navigationSteps.value.map(
(_, index) =>
basicElementsStore.learningVideosWatched[getLearningVideoKey(index)] ===
true,
),
)
const hasUnfinishedLearningVideos = computed(() =>
completedNavigationSteps.value.some((completed) => !completed),
)
const markCurrentLearningVideoWatched = () => {
basicElementsStore.markLearningVideoWatched(
getLearningVideoKey(currentVideoIndex.value),
)
}
//按顺序播放 //按顺序播放
const currentVideo = computed( const currentVideo = computed(
() => navigationSteps.value[currentVideoIndex.value].videoUrl, () => navigationSteps.value[currentVideoIndex.value].videoUrl,
@@ -77,6 +102,7 @@ const label = computed(() => props.label as string)
const handlePlay = () => {} const handlePlay = () => {}
const onEnded = () => { const onEnded = () => {
markCurrentLearningVideoWatched()
handleVideoChange() handleVideoChange()
} }
@@ -101,6 +127,25 @@ const handleIntroGuideComplete = () => {
}) })
} }
const handleContinueLearning = () => {
showUnfinishedPrompt.value = false
pendingLeavePath.value = null
}
const handleConfirmLeave = () => {
if (!pendingLeavePath.value) return
allowRouteLeave.value = true
showUnfinishedPrompt.value = false
router.push(pendingLeavePath.value)
}
onBeforeRouteLeave((to) => {
if (allowRouteLeave.value || !hasUnfinishedLearningVideos.value) return true
pendingLeavePath.value = to.fullPath
showUnfinishedPrompt.value = true
return false
})
onMounted(() => {}) onMounted(() => {})
</script> </script>
@@ -158,10 +203,17 @@ onMounted(() => {})
:background-image="getStaticImage('work/vlog背景全屏待定')" :background-image="getStaticImage('work/vlog背景全屏待定')"
:steps="navigationSteps" :steps="navigationSteps"
:current-step-index="currentNavigationStep" :current-step-index="currentNavigationStep"
:completed-steps="completedNavigationSteps"
@step-click="handleStepClick" @step-click="handleStepClick"
@close="handleStepNavigationClose" @close="handleStepNavigationClose"
/> />
<UnfinishedLearningPrompt
:visible="showUnfinishedPrompt"
@continue="handleContinueLearning"
@leave="handleConfirmLeave"
/>
<GuideSteps <GuideSteps
:visible="showIntroGuide" :visible="showIntroGuide"
:highlight-selector="guideStepIndex === 3 ? '.step-navigation-btn' : ''" :highlight-selector="guideStepIndex === 3 ? '.step-navigation-btn' : ''"
+55 -2
View File
@@ -1,8 +1,9 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, nextTick, ref } from 'vue' import { computed, nextTick, ref } from 'vue'
import { useRouter } from 'vue-router' import { onBeforeRouteLeave, useRouter } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue' import ImgButton from '@/components/Button/ImgButton.vue'
import StepNavigation from '@/components/Guide/StepNavigation.vue' import StepNavigation from '@/components/Guide/StepNavigation.vue'
import UnfinishedLearningPrompt from '@/components/UnfinishedLearningPrompt'
import { Video } from '@/components' import { Video } from '@/components'
import QuestionQuiz, { import QuestionQuiz, {
type Question, type Question,
@@ -76,11 +77,35 @@ const navigationSteps = ref([
// 步骤导航相关状态 // 步骤导航相关状态
const showStepNavigation = ref(false) const showStepNavigation = ref(false)
const showUnfinishedPrompt = ref(false)
const pendingLeavePath = ref<string | null>(null)
const allowRouteLeave = ref(false)
const currentNavigationStep = ref(0) // 对应视频索引,从0开始 const currentNavigationStep = ref(0) // 对应视频索引,从0开始
// 视频列表 // 视频列表
const currentVideoIndex = ref(0) const currentVideoIndex = ref(0)
const getLearningVideoKey = (index: number) =>
`basic-light:${navigationSteps.value[index]?.id ?? index}`
const completedNavigationSteps = computed(() =>
navigationSteps.value.map(
(_, index) =>
basicElementsStore.learningVideosWatched[getLearningVideoKey(index)] ===
true,
),
)
const hasUnfinishedLearningVideos = computed(() =>
completedNavigationSteps.value.some((completed) => !completed),
)
const markCurrentLearningVideoWatched = () => {
basicElementsStore.markLearningVideoWatched(
getLearningVideoKey(currentVideoIndex.value),
)
}
//按顺序播放 //按顺序播放
const currentVideo = computed( const currentVideo = computed(
() => navigationSteps.value[currentVideoIndex.value].videoUrl, () => navigationSteps.value[currentVideoIndex.value].videoUrl,
@@ -98,7 +123,7 @@ const getQuestionsForVideo = (index: number): Question[] => {
id: 1, id: 1,
title: '', title: '',
description: description:
'(单选)同一条街道,在不同时间拍摄,哪一张画面更适合表现"温暖、舒服的日常感"?', '【单选题】同一条街道,在不同时间拍摄,哪一张画面更适合表现"温暖、舒服的日常感"?',
options: [ options: [
{ {
id: 'a', id: 'a',
@@ -153,6 +178,8 @@ const handlePlay = () => {
} }
const onEnded = () => { const onEnded = () => {
markCurrentLearningVideoWatched()
// 检查是否是"拍摄的黄金时间"(index 2) // 检查是否是"拍摄的黄金时间"(index 2)
if (currentVideoIndex.value === 2) { if (currentVideoIndex.value === 2) {
// 暂停播放并显示答题窗口 // 暂停播放并显示答题窗口
@@ -186,6 +213,25 @@ const handleQuestionsComplete = () => {
// 答题完成后继续播放下一个视频 // 答题完成后继续播放下一个视频
handleVideoChange() handleVideoChange()
} }
const handleContinueLearning = () => {
showUnfinishedPrompt.value = false
pendingLeavePath.value = null
}
const handleConfirmLeave = () => {
if (!pendingLeavePath.value) return
allowRouteLeave.value = true
showUnfinishedPrompt.value = false
router.push(pendingLeavePath.value)
}
onBeforeRouteLeave((to) => {
if (allowRouteLeave.value || !hasUnfinishedLearningVideos.value) return true
pendingLeavePath.value = to.fullPath
showUnfinishedPrompt.value = true
return false
})
</script> </script>
<template> <template>
@@ -248,10 +294,17 @@ const handleQuestionsComplete = () => {
:background-image="getStaticImage('work/vlog背景全屏待定')" :background-image="getStaticImage('work/vlog背景全屏待定')"
:steps="navigationSteps" :steps="navigationSteps"
:current-step-index="currentNavigationStep" :current-step-index="currentNavigationStep"
:completed-steps="completedNavigationSteps"
@step-click="handleStepClick" @step-click="handleStepClick"
@close="handleStepNavigationClose" @close="handleStepNavigationClose"
/> />
<UnfinishedLearningPrompt
:visible="showUnfinishedPrompt"
@continue="handleContinueLearning"
@leave="handleConfirmLeave"
/>
<!-- 引导与高亮 --> <!-- 引导与高亮 -->
<GuideSteps <GuideSteps
@@ -1,9 +1,10 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, nextTick, ref } from 'vue' import { computed, nextTick, ref } from 'vue'
import { useRouter } from 'vue-router' import { onBeforeRouteLeave, useRouter } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue' import ImgButton from '@/components/Button/ImgButton.vue'
import { GuideSteps, GuideStepsItem } from '@/components/Guide' import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import StepNavigation from '@/components/Guide/StepNavigation.vue' import StepNavigation from '@/components/Guide/StepNavigation.vue'
import UnfinishedLearningPrompt from '@/components/UnfinishedLearningPrompt'
import { Video } from '@/components' import { Video } from '@/components'
import { getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils' import { getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
import { useBasicElementsStore } from '@/stores/modules/basic-elements' import { useBasicElementsStore } from '@/stores/modules/basic-elements'
@@ -113,11 +114,36 @@ const navigationSteps = ref(rawSteps)
// 步骤导航相关状态 // 步骤导航相关状态
const showStepNavigation = ref(false) const showStepNavigation = ref(false)
const showUnfinishedPrompt = ref(false)
const showCompletionGuide = ref(false)
const pendingLeavePath = ref<string | null>(null)
const allowRouteLeave = ref(false)
const currentNavigationStep = ref(0) // 对应视频索引,从0开始 const currentNavigationStep = ref(0) // 对应视频索引,从0开始
// 视频列表 // 视频列表
const currentVideoIndex = ref(0) const currentVideoIndex = ref(0)
const getLearningVideoKey = (index: number) =>
`basic-short-video-editing-skills:${navigationSteps.value[index]?.id ?? index}`
const completedNavigationSteps = computed(() =>
navigationSteps.value.map(
(_, index) =>
basicElementsStore.learningVideosWatched[getLearningVideoKey(index)] ===
true,
),
)
const hasUnfinishedLearningVideos = computed(() =>
completedNavigationSteps.value.some((completed) => !completed),
)
const markCurrentLearningVideoWatched = () => {
basicElementsStore.markLearningVideoWatched(
getLearningVideoKey(currentVideoIndex.value),
)
}
//按顺序播放 //按顺序播放
const currentVideo = computed(() => rawSteps[currentVideoIndex.value].videoUrl) const currentVideo = computed(() => rawSteps[currentVideoIndex.value].videoUrl)
@@ -127,6 +153,7 @@ const handleVideoChange = () => {
currentNavigationStep.value = currentVideoIndex.value currentNavigationStep.value = currentVideoIndex.value
} else { } else {
status.value = true status.value = true
showCompletionGuide.value = true
return return
} }
nextTick(() => { nextTick(() => {
@@ -142,6 +169,7 @@ const handleClose = () => {
const handlePlay = () => {} const handlePlay = () => {}
const onEnded = () => { const onEnded = () => {
markCurrentLearningVideoWatched()
handleVideoChange() handleVideoChange()
} }
@@ -163,6 +191,29 @@ const handleIntroGuideComplete = () => {
playing.value = true playing.value = true
}) })
} }
const handleCompletionGuideComplete = () => {
showCompletionGuide.value = false
}
const handleContinueLearning = () => {
showUnfinishedPrompt.value = false
pendingLeavePath.value = null
}
const handleConfirmLeave = () => {
if (!pendingLeavePath.value) return
allowRouteLeave.value = true
showUnfinishedPrompt.value = false
router.push(pendingLeavePath.value)
}
onBeforeRouteLeave((to) => {
if (allowRouteLeave.value || !hasUnfinishedLearningVideos.value) return true
pendingLeavePath.value = to.fullPath
showUnfinishedPrompt.value = true
return false
})
</script> </script>
<template> <template>
@@ -220,10 +271,17 @@ const handleIntroGuideComplete = () => {
:background-image="getStaticImage('work/vlog背景全屏待定')" :background-image="getStaticImage('work/vlog背景全屏待定')"
:steps="navigationSteps" :steps="navigationSteps"
:current-step-index="currentNavigationStep" :current-step-index="currentNavigationStep"
:completed-steps="completedNavigationSteps"
@step-click="handleStepClick" @step-click="handleStepClick"
@close="handleStepNavigationClose" @close="handleStepNavigationClose"
/> />
<UnfinishedLearningPrompt
:visible="showUnfinishedPrompt"
@continue="handleContinueLearning"
@leave="handleConfirmLeave"
/>
<GuideSteps <GuideSteps
:visible="showIntroGuide" :visible="showIntroGuide"
position-offset-y="85%" position-offset-y="85%"
@@ -242,6 +300,16 @@ const handleIntroGuideComplete = () => {
准备好了吗?让零散的素材在你的手中拼接成精彩作品! 准备好了吗?让零散的素材在你的手中拼接成精彩作品!
</GuideStepsItem> </GuideStepsItem>
</GuideSteps> </GuideSteps>
<GuideSteps
:visible="showCompletionGuide"
position-offset-y="85%"
@complete="handleCompletionGuideComplete"
>
<GuideStepsItem :audio-src="getPublicPath('audio/py_1_025.MP3')">
新手导航模块完成啦,快去首页解锁进阶任务吧!
</GuideStepsItem>
</GuideSteps>
</div> </div>
</template> </template>
+72 -103
View File
@@ -1,31 +1,68 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { getBackgroundImage, getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils' import {
getBackgroundImage,
getPublicPath,
getStaticImage,
IMAGE_PATHS,
} from '@/utils'
import { ImgButton } from '@/components/Button' import { ImgButton } from '@/components/Button'
import { GuideSteps, type GuideStep } from '@/components/Guide' import { GuideSteps, type GuideStep } from '@/components/Guide'
import { Video } from '@/components'
import { useFilmStore } from '@/stores' import { useFilmStore } from '@/stores'
const router = useRouter() const router = useRouter()
const film = useFilmStore() const film = useFilmStore()
const completionIcon = (completed: boolean) =>
getStaticImage(`midterm/${completed ? '完成' : '未完成'}.png`)
const editingItems = computed(() => [
{
image: 'film/after-editing/剪辑初始',
videoKey: 'start',
onClick: goStart,
class:
'absolute left-[0px] bottom-[42%] translate-y-1/2 bg-cover bg-center bg-no-repeat',
},
{
image: 'film/after-editing/节奏型剪辑',
videoKey: 'rhythm',
onClick: goRhythm,
class: 'absolute right-[0px] top-[160px] bg-cover bg-center bg-no-repeat',
},
{
image: 'film/after-editing/声音转场',
videoKey: 'soundTransition',
onClick: goSoundTransition,
class: 'absolute right-[42px] top-[410px] bg-cover bg-center bg-no-repeat',
},
{
image: 'film/after-editing/快切',
videoKey: 'cut',
onClick: goCut,
class: 'absolute right-[520px] top-[580px] bg-cover bg-center bg-no-repeat',
},
{
image: 'film/after-editing/关联剪辑',
videoKey: 'association',
onClick: goAssociation,
class:
'absolute right-[42px] bottom-[270px] bg-cover bg-center bg-no-repeat',
},
{
image: 'film/after-editing/连续剪辑',
videoKey: 'continuity',
onClick: goContinuity,
class:
'absolute right-[42px] bottom-[16px] bg-cover bg-center bg-no-repeat',
},
])
const handleClose = () => { const handleClose = () => {
router.back() 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 = () => { const goStart = () => {
router.push({ name: 'FilmEditingStart' }) router.push({ name: 'FilmEditingStart' })
} }
@@ -56,20 +93,20 @@ const editingCompleteSteps: GuideStep[] = [
id: 'editing-complete-step1', id: 'editing-complete-step1',
content: content:
'刚才我们提到的剪辑方式,并不是孤立存在的技巧,而是针对不同问题的解决方案。所以,理解剪辑逻辑才是最重要的!', '刚才我们提到的剪辑方式,并不是孤立存在的技巧,而是针对不同问题的解决方案。所以,理解剪辑逻辑才是最重要的!',
audioSrc: getPublicPath('audio/py_4_018.MP3') audioSrc: getPublicPath('audio/py_4_018.MP3'),
} },
] ]
const showEditingCompleteGuide = ref(false) const showEditingCompleteGuide = ref(false)
watch( watch(
() => film.allEditingVideosWatched, () => film.allEditingVideosWatched,
watched => { (watched) => {
if (watched && film.isGuideFilmEditingComplete) { if (watched && film.isGuideFilmEditingComplete) {
showEditingCompleteGuide.value = true showEditingCompleteGuide.value = true
} }
}, },
{ immediate: true } { immediate: true },
) )
const handleEditingCompleteGuideDone = () => { const handleEditingCompleteGuideDone = () => {
@@ -83,7 +120,9 @@ const handleEditingCompleteGuideDone = () => {
<!-- 背景 --> <!-- 背景 -->
<div <div
class="absolute inset-0 bg-cover bg-center bg-no-repeat" class="absolute inset-0 bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: getBackgroundImage('film/after-editing/剪辑底图') }" :style="{
backgroundImage: getBackgroundImage('film/after-editing/剪辑底图'),
}"
></div> ></div>
<!-- 标题 --> <!-- 标题 -->
@@ -93,90 +132,20 @@ const handleEditingCompleteGuideDone = () => {
alt="" alt=""
/> />
<!-- 视频播放区域 --> <div
<!-- <div v-if="!videoEnded" class="absolute inset-0 flex items-center justify-center"> v-for="item in editingItems"
<div class="w-full h-full bg-black/40 backdrop-blur-md rounded-3xl border border-white/20"> :key="item.videoKey"
<Video :class="item.class"
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 <ImgButton
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" :image="item.image"
> @click="item.onClick"
<div class="flex items-baseline justify-between gap-6"> />
<h1 class="text-5xl font-extrabold tracking-wide">剪辑</h1> <img
</div> class="pointer-events-none absolute left-0 top-0 z-10 h-12 w-12"
<p class="text-2xl leading-8 opacity-90"> :src="completionIcon(film.editingVideosWatched[item.videoKey] === true)"
选择下面任意一个练习模块,进入对应的剪辑场景,按提示完成小任务,逐步建立自己的剪辑手感和创作节奏。 alt=""
</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> -->
<div class="absolute left-[0px] bottom-[42%] translate-y-1/2 bg-cover bg-center bg-no-repeat">
<ImgButton :image="'film/after-editing/剪辑初始'" @click="goStart" />
</div>
<div class="absolute right-[0px] top-[160px] bg-cover bg-center bg-no-repeat">
<ImgButton :image="'film/after-editing/节奏型剪辑'" @click="goRhythm" />
</div>
<div class="absolute right-[42px] top-[410px] bg-cover bg-center bg-no-repeat">
<ImgButton :image="'film/after-editing/声音转场'" @click="goSoundTransition" />
</div>
<div class="absolute right-[520px] top-[580px] bg-cover bg-center bg-no-repeat">
<ImgButton :image="'film/after-editing/快切'" @click="goCut" />
</div>
<div class="absolute right-[42px] bottom-[270px] bg-cover bg-center bg-no-repeat">
<ImgButton :image="'film/after-editing/关联剪辑'" @click="goAssociation" />
</div>
<div class="absolute right-[42px] bottom-[16px] bg-cover bg-center bg-no-repeat">
<ImgButton :image="'film/after-editing/连续剪辑'" @click="goContinuity" />
</div> </div>
<!-- 关闭 --> <!-- 关闭 -->
+50 -32
View File
@@ -16,61 +16,61 @@ const category = ref([
{ {
label: '前期准备', label: '前期准备',
value: 1, value: 1,
icon: getStaticImage('midterm/准备图标.png') icon: getStaticImage('midterm/准备图标.png'),
}, },
{ {
label: '中期拍摄', label: '中期拍摄',
value: 2, value: 2,
icon: getStaticImage('midterm/拍摄图标.png') icon: getStaticImage('midterm/拍摄图标.png'),
}, },
{ {
label: '后期剪辑', label: '后期剪辑',
value: 3, value: 3,
icon: getStaticImage('midterm/剪辑图标.png') icon: getStaticImage('midterm/剪辑图标.png'),
} },
]) ])
const menuItems = ref([ const menuItems = ref([
{ {
label: '资源筹备', label: '资源筹备',
routeName: 'FilmResource', routeName: 'FilmResource',
parentValue: 1, parentValue: 1,
disabled: false disabled: false,
}, },
{ {
label: '创意构想', label: '创意构想',
routeName: 'FilmCreative', routeName: 'FilmCreative',
parentValue: 1, parentValue: 1,
disabled: false disabled: false,
}, },
{ {
label: '脚本撰写', label: '脚本撰写',
routeName: 'FilmScriptWriting', routeName: 'FilmScriptWriting',
parentValue: 1, parentValue: 1,
disabled: false disabled: false,
}, },
{ {
label: '分镜制作', label: '分镜制作',
routeName: 'FilmStoryboard', routeName: 'FilmStoryboard',
parentValue: 1, parentValue: 1,
disabled: false disabled: false,
}, },
{ {
label: '设备准备', label: '设备准备',
routeName: 'FilmMidtermEquipment', routeName: 'FilmMidtermEquipment',
parentValue: 2, parentValue: 2,
disabled: false disabled: false,
}, },
{ {
label: '实地取景', label: '实地取景',
routeName: 'FilmMidtermShooting', routeName: 'FilmMidtermShooting',
parentValue: 2, parentValue: 2,
disabled: false disabled: false,
}, },
{ {
label: '现场拍摄', label: '现场拍摄',
routeName: 'FilmOnsiteShooting', routeName: 'FilmOnsiteShooting',
parentValue: 2, parentValue: 2,
disabled: false disabled: false,
}, },
// { // {
// label: '素材管理', // label: '素材管理',
@@ -82,27 +82,27 @@ const menuItems = ref([
label: '剪辑', label: '剪辑',
routeName: 'FilmEditing', routeName: 'FilmEditing',
parentValue: 3, parentValue: 3,
disabled: false disabled: false,
}, },
{ {
label: '调色', label: '调色',
routeName: 'FilmColorPractice', routeName: 'FilmColorPractice',
parentValue: 3, parentValue: 3,
disabled: false disabled: false,
}, },
//音效 //音效
{ {
label: '音效', label: '音效',
routeName: 'FilmSoundEffect', routeName: 'FilmSoundEffect',
parentValue: 3, parentValue: 3,
disabled: false disabled: false,
}, },
{ {
label: '导出发布', label: '导出发布',
routeName: 'FilmExportPublish', routeName: 'FilmExportPublish',
parentValue: 3, parentValue: 3,
disabled: false disabled: false,
} },
]) ])
const props = defineProps<{ const props = defineProps<{
@@ -112,9 +112,8 @@ const props = defineProps<{
const router = useRouter() const router = useRouter()
const film = useFilmStore() const film = useFilmStore()
const isViewed = (routeName: string) => { const isItemCompleted = (routeName: string): boolean =>
return film.viewed?.[routeName] === true film.isRouteViewed(routeName)
}
const currentParentValue = computed<number>(() => { const currentParentValue = computed<number>(() => {
return props.currentStep return props.currentStep
@@ -123,7 +122,9 @@ const currentParentValue = computed<number>(() => {
const currentItemValue = ref<string>('') // 当前选中的子级菜单项 const currentItemValue = ref<string>('') // 当前选中的子级菜单项
const getMenuItems = computed(() => { const getMenuItems = computed(() => {
return menuItems.value.filter(item => item.parentValue === currentParentValue.value) return menuItems.value.filter(
(item) => item.parentValue === currentParentValue.value,
)
}) })
const handleItemClick = (item: { routeName: string; disabled: boolean }) => { const handleItemClick = (item: { routeName: string; disabled: boolean }) => {
@@ -134,13 +135,20 @@ const handleItemClick = (item: { routeName: string; disabled: boolean }) => {
</script> </script>
<template> <template>
<div class="absolute left-20 top-0 bottom-0 w-[520px] pt-28" v-if="currentStep"> <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"> <div class="absolute top-3 left-24 flex items-center space-x-2">
<span class="text-4xl text-[#fbbf24] font-black"> <span class="text-4xl text-[#fbbf24] font-black">
{{ category[currentParentValue - 1].label }} {{ category[currentParentValue - 1].label }}
</span> </span>
<img class="filter-blue" :src="category[currentParentValue - 1].icon" alt="" /> <img
class="filter-blue"
:src="category[currentParentValue - 1].icon"
alt=""
/>
</div> </div>
<!-- 背景图片 --> <!-- 背景图片 -->
<div <div
@@ -148,12 +156,17 @@ const handleItemClick = (item: { routeName: string; disabled: boolean }) => {
:style="{ backgroundImage: getBackgroundImage('components/menu-bg.png') }" :style="{ backgroundImage: getBackgroundImage('components/menu-bg.png') }"
></div> ></div>
<div class="flex items-center flex-col pl-[60px]"> <div class="flex items-center flex-col pl-[60px]">
<template v-for="(item, index) in getMenuItems" :key="item.routeName"> <template
v-for="(item, index) in getMenuItems"
:key="item.routeName"
>
<div <div
class="relative w-[373px] h-[143px] pl-14 flex items-center justify-center group text-4xl font-black transition-colors" class="relative w-[373px] h-[143px] pl-14 flex items-center justify-center group text-4xl font-black transition-colors"
:class="[ :class="[
currentItemValue === item.routeName ? 'text-blue-400' : 'text-white', currentItemValue === item.routeName
item.disabled ? 'opacity-20 cursor-not-allowed' : 'cursor-pointer' ? 'text-blue-400'
: 'text-white',
item.disabled ? 'opacity-20 cursor-not-allowed' : 'cursor-pointer',
]" ]"
:style="{ marginLeft: -index * 40 + 'px' }" :style="{ marginLeft: -index * 40 + 'px' }"
@click="handleItemClick(item)" @click="handleItemClick(item)"
@@ -161,16 +174,21 @@ const handleItemClick = (item: { routeName: string; disabled: boolean }) => {
<div <div
v-if="!item.disabled" 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" 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') }" :style="{
backgroundImage: getBackgroundImage('components/menu-hover.png'),
}"
></div> ></div>
<span class="flex items-center gap-2" :class="[!item.disabled ? 'menu-glow-text' : '']"> <span
class="flex items-center gap-2"
:class="[!item.disabled ? 'menu-glow-text' : '']"
>
<span>{{ item.label }}</span> <span>{{ item.label }}</span>
<img <img
class="w-10" class="w-10"
:src=" :src="
isViewed(item.routeName) isItemCompleted(item.routeName)
? '/src/assets/images/midterm/完成.png' ? getStaticImage('midterm/完成.png')
: '/src/assets/images/midterm/未完成.png' : getStaticImage('midterm/未完成.png')
" "
alt="" alt=""
/> />
@@ -183,8 +201,8 @@ const handleItemClick = (item: { routeName: string; disabled: boolean }) => {
<style scoped> <style scoped>
.filter-blue { .filter-blue {
filter: brightness(0) saturate(100%) invert(79%) sepia(29%) saturate(1190%) hue-rotate(330deg) filter: brightness(0) saturate(100%) invert(79%) sepia(29%) saturate(1190%)
brightness(102%) contrast(98%); hue-rotate(330deg) brightness(102%) contrast(98%);
} }
.menu-glow-text { .menu-glow-text {
+77 -28
View File
@@ -1,5 +1,10 @@
<script lang="tsx" setup> <script lang="tsx" setup>
import { getBackgroundImage, getStaticImage, getPublicPath, IMAGE_PATHS } from '@/utils' import {
getBackgroundImage,
getStaticImage,
getPublicPath,
IMAGE_PATHS,
} from '@/utils'
import { ImgButton } from '@/components/Button' import { ImgButton } from '@/components/Button'
import { computed, onMounted, ref, watch, type Ref } from 'vue' import { computed, onMounted, ref, watch, type Ref } from 'vue'
@@ -15,7 +20,7 @@ const film = useFilmStore()
const visible = computed({ const visible = computed({
get: () => film.filmMenuVisible, get: () => film.filmMenuVisible,
set: value => film.setFilmMenuVisible(value) set: (value) => film.setFilmMenuVisible(value),
}) })
// const status = ref(true) // const status = ref(true)
@@ -26,25 +31,34 @@ const progress = computed(() => film.progressPercent)
const guideIndex = ref<number>(0) const guideIndex = ref<number>(0)
const taskGuideKey = 'film-task-guide' const taskGuideKey = 'film-task-guide'
const practiceGuideKey = 'film-practice-guide'
const hasShownTaskGuide = computed(() => film.guideShown[taskGuideKey] === true) const hasShownTaskGuide = computed(() => film.guideShown[taskGuideKey] === true)
const hasShownPracticeGuide = computed(
() => film.guideShown[practiceGuideKey] === true,
)
const showTaskGuide = computed( const showTaskGuide = computed(
() => currentStep.value === 1 && guideIndex.value < 2 && film.isGuideFilmTask () => currentStep.value === 1 && guideIndex.value < 2 && film.isGuideFilmTask,
)
const showPracticeGuide = computed(
() => currentStep.value === 4 && !hasShownPracticeGuide.value,
) )
// 思路提点完成回到首页的一次性引导 // 思路提点完成回到首页的一次性引导
const showReturnGuide = ref(route.query.guide === 'return' && film.isGuideFilmReturn) const showReturnGuide = ref(
route.query.guide === 'return' && film.isGuideFilmReturn,
)
const returnSteps: GuideStep[] = [ const returnSteps: GuideStep[] = [
{ {
id: 'return-step1', id: 'return-step1',
content: '现在,我们把镜头带到了徐汇滨江。', content: '现在,我们把镜头带到了徐汇滨江。',
audioSrc: getPublicPath('audio/py_4_010.MP3') audioSrc: getPublicPath('audio/py_4_010.MP3'),
}, },
{ {
id: 'return-step2', id: 'return-step2',
content: '这里既保留着工业时代的痕迹,也承载着当代生活的节奏。', content: '这里既保留着工业时代的痕迹,也承载着当代生活的节奏。',
audioSrc: getPublicPath('audio/py_4_011.MP3') audioSrc: getPublicPath('audio/py_4_011.MP3'),
} },
] ]
const handleReturnGuideComplete = () => { const handleReturnGuideComplete = () => {
@@ -61,15 +75,16 @@ const stageGuideMap = {
steps: [ steps: [
{ {
id: 'stage2-step1', id: 'stage2-step1',
content: '拍摄工具本身不分好坏,关键在于是否适合当下的拍摄内容和使用习惯。', content:
audioSrc: getPublicPath('audio/py_4_012.MP3') '拍摄工具本身不分好坏,关键在于是否适合当下的拍摄内容和使用习惯。',
audioSrc: getPublicPath('audio/py_4_012.MP3'),
}, },
{ {
id: 'stage2-step2', id: 'stage2-step2',
content: '我们一起看看在这个模块中会用到哪些常见、实用的拍摄工具。', content: '我们一起看看在这个模块中会用到哪些常见、实用的拍摄工具。',
audioSrc: getPublicPath('audio/py_4_013.MP3') audioSrc: getPublicPath('audio/py_4_013.MP3'),
} },
] ],
}, },
3: { 3: {
flagKey: 'isGuideFilmStage3', flagKey: 'isGuideFilmStage3',
@@ -78,17 +93,17 @@ const stageGuideMap = {
{ {
id: 'stage3-step1', id: 'stage3-step1',
content: '拍摄完成啦!接下来进入剪辑环节。', content: '拍摄完成啦!接下来进入剪辑环节。',
audioSrc: getPublicPath('audio/py_4_017.MP3') audioSrc: getPublicPath('audio/py_4_017.MP3'),
} },
] ],
} },
} as const } as const
type StageKey = keyof typeof stageGuideMap type StageKey = keyof typeof stageGuideMap
const stageGuideStep = ref<StageKey | null>(null) const stageGuideStep = ref<StageKey | null>(null)
const stageGuideSteps = computed(() => const stageGuideSteps = computed(() =>
stageGuideStep.value ? stageGuideMap[stageGuideStep.value].steps : [] stageGuideStep.value ? stageGuideMap[stageGuideStep.value].steps : [],
) as Ref<GuideStep[]> ) as Ref<GuideStep[]>
const triggerStageGuide = (step: number) => { const triggerStageGuide = (step: number) => {
@@ -145,12 +160,12 @@ onMounted(() => {
// Cache the guide after it pops up once so it does not re-appear // Cache the guide after it pops up once so it does not re-appear
watch( watch(
showTaskGuide, showTaskGuide,
val => { (val) => {
if (val && !hasShownTaskGuide.value) { if (val && !hasShownTaskGuide.value) {
film.markGuideShown(taskGuideKey) film.markGuideShown(taskGuideKey)
} }
}, },
{ immediate: true } { immediate: true },
) )
// 阶段引导触发:当 Overlay 菜单打开且当前步骤变化时检查是否需要弹阶段引导 // 阶段引导触发:当 Overlay 菜单打开且当前步骤变化时检查是否需要弹阶段引导
@@ -161,7 +176,17 @@ watch(
triggerStageGuide(step) triggerStageGuide(step)
} }
}, },
{ immediate: true } { immediate: true },
)
watch(
showPracticeGuide,
(val) => {
if (val) {
film.markGuideShown(practiceGuideKey)
}
},
{ immediate: true },
) )
</script> </script>
@@ -198,24 +223,34 @@ watch(
<!-- 标题 --> <!-- 标题 -->
<div class="absolute top-2 left-1/2 -translate-x-1/2"> <div class="absolute top-2 left-1/2 -translate-x-1/2">
<img :src="getStaticImage('film/电影-创作与表达.png')" alt="" /> <img
:src="getStaticImage('film/电影-创作与表达.png')"
alt=""
/>
</div> </div>
<!-- 任务取景按钮 --> <!-- 任务取景按钮 -->
<div <div
class="absolute left-24 top-24" class="absolute left-24 top-24"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.TASK_CAMERA) }" :style="{
backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.TASK_CAMERA),
}"
> >
<ImgButton <ImgButton
:image="currentStep === 4 ? IMAGE_PATHS.VLOG.TASK_BTN_OK : IMAGE_PATHS.VLOG.TASK_BTN" :image="
currentStep > 0
? IMAGE_PATHS.VLOG.TASK_BTN_OK
: IMAGE_PATHS.VLOG.TASK_BTN
"
@click="handleWorkClick" @click="handleWorkClick"
/> />
</div> </div>
<GuideSteps <GuideSteps
:visible="currentStep === 4" :visible="showPracticeGuide"
highlight-selector=".right-section" highlight-selector=".right-section"
position-offset-y="50%" position-offset-y="50%"
@complete="film.markGuideShown(practiceGuideKey)"
> >
<GuideStepsItem>点击这里进入实战操作台</GuideStepsItem> <GuideStepsItem>点击这里进入实战操作台</GuideStepsItem>
</GuideSteps> </GuideSteps>
@@ -228,7 +263,10 @@ watch(
:image="IMAGE_PATHS.VLOG.TASK_PERSON" :image="IMAGE_PATHS.VLOG.TASK_PERSON"
/> />
<Overlay content-class="w-full h-full" :visible="visible && currentStep < 4"> <Overlay
content-class="w-full h-full"
:visible="visible && currentStep < 4"
>
<!-- 关闭按钮 --> <!-- 关闭按钮 -->
<ImgButton <ImgButton
class="absolute right-0 top-0 z-20" class="absolute right-0 top-0 z-20"
@@ -241,9 +279,20 @@ watch(
class="absolute top-0 left-0 w-full h-full z-40 pointer-events-none" class="absolute top-0 left-0 w-full h-full z-40 pointer-events-none"
v-if="showTaskGuide" v-if="showTaskGuide"
> >
<div class="absolute pointer-events-auto w-full h-full" @click="handleGuideClick"> <div
<img v-if="guideIndex === 0" :src="getStaticImage('film/on-site/1.png')" alt="" /> class="absolute pointer-events-auto w-full h-full"
<img v-if="guideIndex === 1" :src="getStaticImage('film/on-site/2.png')" alt="" /> @click="handleGuideClick"
>
<img
v-if="guideIndex === 0"
:src="getStaticImage('film/on-site/1.png')"
alt=""
/>
<img
v-if="guideIndex === 1"
:src="getStaticImage('film/on-site/2.png')"
alt=""
/>
</div> </div>
</div> </div>
@@ -1,5 +1,10 @@
<script lang="tsx" setup> <script lang="tsx" setup>
import { getBackgroundImage, getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils' import {
getBackgroundImage,
getPublicPath,
getStaticImage,
IMAGE_PATHS,
} from '@/utils'
import { GuideSteps } from '@/components/Guide' import { GuideSteps } from '@/components/Guide'
import { onBeforeUnmount, ref, computed } from 'vue' import { onBeforeUnmount, ref, computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -15,8 +20,8 @@ const steps = ref([
{ {
id: 'step1', id: 'step1',
content: '设备调试完毕,我们如约抵达徐汇滨江,一起开启拍摄之旅吧!', content: '设备调试完毕,我们如约抵达徐汇滨江,一起开启拍摄之旅吧!',
audioSrc: getPublicPath('audio/py_4_014.MP3') audioSrc: getPublicPath('audio/py_4_014.MP3'),
} },
]) ])
const showGuide = ref(false) const showGuide = ref(false)
@@ -27,7 +32,13 @@ const hiddenbar = ref(false)
const currentIndex = ref(0) // 当前显示的图片索引 const currentIndex = ref(0) // 当前显示的图片索引
const selectedMenuIndex = ref(0) // 当前选中的菜单项索引 const selectedMenuIndex = ref(0) // 当前选中的菜单项索引
type EquipmentCategoryId = 'camera' | 'slider' | 'tripod' | 'stabilizer' | 'light' | 'filter' type EquipmentCategoryId =
| 'camera'
| 'slider'
| 'tripod'
| 'stabilizer'
| 'light'
| 'filter'
interface EquipmentProduct { interface EquipmentProduct {
img: string img: string
} }
@@ -51,8 +62,8 @@ const categories = ref<EquipmentCategory[]>([
products: [ products: [
{ img: getStaticImage('equipment/相机产品.png') }, { img: getStaticImage('equipment/相机产品.png') },
{ img: getStaticImage('equipment/摄像机产品.png') }, { img: getStaticImage('equipment/摄像机产品.png') },
{ img: getStaticImage('equipment/微单产品.png') } { img: getStaticImage('equipment/微单产品.png') },
] ],
}, },
{ {
id: 'slider', id: 'slider',
@@ -62,8 +73,8 @@ const categories = ref<EquipmentCategory[]>([
contentImg: getStaticImage('equipment/滑轨内容.png'), contentImg: getStaticImage('equipment/滑轨内容.png'),
products: [ products: [
{ img: getStaticImage('equipment/便携滑轨产品.png') }, { img: getStaticImage('equipment/便携滑轨产品.png') },
{ img: getStaticImage('equipment/脚架式长滑轨产品.png') } { img: getStaticImage('equipment/脚架式长滑轨产品.png') },
] ],
}, },
{ {
id: 'tripod', id: 'tripod',
@@ -71,7 +82,7 @@ const categories = ref<EquipmentCategory[]>([
disabled: false, disabled: false,
titleImg: getStaticImage('equipment/三脚架标题.png'), titleImg: getStaticImage('equipment/三脚架标题.png'),
contentImg: getStaticImage('equipment/三脚架内容.png'), contentImg: getStaticImage('equipment/三脚架内容.png'),
products: [{ img: getStaticImage('equipment/三角架产品.png') }] products: [{ img: getStaticImage('equipment/三角架产品.png') }],
}, },
{ {
id: 'stabilizer', id: 'stabilizer',
@@ -81,8 +92,8 @@ const categories = ref<EquipmentCategory[]>([
contentImg: getStaticImage('equipment/稳定器内容.png'), contentImg: getStaticImage('equipment/稳定器内容.png'),
products: [ products: [
{ img: getStaticImage('equipment/手机稳定器.png') }, { img: getStaticImage('equipment/手机稳定器.png') },
{ img: getStaticImage('equipment/相机稳定器.png') } { img: getStaticImage('equipment/相机稳定器.png') },
] ],
}, },
{ {
id: 'light', id: 'light',
@@ -92,8 +103,8 @@ const categories = ref<EquipmentCategory[]>([
contentImg: getStaticImage('equipment/灯内容.png'), contentImg: getStaticImage('equipment/灯内容.png'),
products: [ products: [
{ img: getStaticImage('equipment/led补光灯.png') }, { img: getStaticImage('equipment/led补光灯.png') },
{ img: getStaticImage('equipment/环形灯产品.png') } { img: getStaticImage('equipment/环形灯产品.png') },
] ],
}, },
{ {
id: 'filter', id: 'filter',
@@ -101,14 +112,14 @@ const categories = ref<EquipmentCategory[]>([
disabled: false, disabled: false,
titleImg: getStaticImage('equipment/滤镜标题.png'), titleImg: getStaticImage('equipment/滤镜标题.png'),
contentImg: getStaticImage('equipment/滤镜内容.png'), contentImg: getStaticImage('equipment/滤镜内容.png'),
products: [{ img: getStaticImage('equipment/滤镜产品.png') }] products: [{ img: getStaticImage('equipment/滤镜产品.png') }],
} },
]) ])
// 有资源的都解锁:如果某个类目没有任何产品资源,则自动禁用 // 有资源的都解锁:如果某个类目没有任何产品资源,则自动禁用
categories.value = categories.value.map(c => ({ categories.value = categories.value.map((c) => ({
...c, ...c,
disabled: (c.products?.length ?? 0) === 0 disabled: (c.products?.length ?? 0) === 0,
})) }))
// 默认选中的菜单也算作已查看 // 默认选中的菜单也算作已查看
@@ -118,9 +129,11 @@ if (categories.value[0] && !categories.value[0].disabled) {
const maybeTriggerGuide = () => { const maybeTriggerGuide = () => {
if (guideTriggered.value) return if (guideTriggered.value) return
const enabledIds = categories.value.filter(c => !c.disabled).map(c => c.id) const enabledIds = categories.value
.filter((c) => !c.disabled)
.map((c) => c.id)
if (enabledIds.length === 0) return if (enabledIds.length === 0) return
const allViewed = enabledIds.every(id => viewedMenuKeys.value.has(id)) const allViewed = enabledIds.every((id) => viewedMenuKeys.value.has(id))
if (!allViewed) return if (!allViewed) return
guideTriggered.value = true guideTriggered.value = true
guideTimer.value = setTimeout(() => { guideTimer.value = setTimeout(() => {
@@ -137,17 +150,22 @@ onBeforeUnmount(() => {
}) })
const currentCategory = computed( const currentCategory = computed(
() => categories.value[selectedMenuIndex.value] ?? categories.value[0] () => categories.value[selectedMenuIndex.value] ?? categories.value[0],
) )
const currentProducts = computed(() => currentCategory.value?.products ?? []) const currentProducts = computed(() => currentCategory.value?.products ?? [])
const currentTitleImg = computed(() => currentCategory.value?.titleImg ?? '') const currentTitleImg = computed(() => currentCategory.value?.titleImg ?? '')
const currentContentImg = computed(() => currentCategory.value?.contentImg ?? '') const currentContentImg = computed(
() => currentCategory.value?.contentImg ?? '',
)
const hiddenbarclick = () => { const hiddenbarclick = () => {
hiddenbar.value = !hiddenbar.value hiddenbar.value = !hiddenbar.value
} }
// 选择菜单项 // 选择菜单项
const selectMenuItem = (index: number, item: { id: string; name: string; disabled: boolean }) => { const selectMenuItem = (
index: number,
item: { id: string; name: string; disabled: boolean },
) => {
if (item.disabled) { if (item.disabled) {
return return
} }
@@ -180,9 +198,16 @@ const prevPhone = () => {
></div> ></div>
<div class="equipment-bar"> <div class="equipment-bar">
<img src="@/assets/images/equipment/侧边导航栏.png" alt="" :hidden="hiddenbar" /> <img
src="@/assets/images/equipment/侧边导航栏.png"
alt=""
:hidden="hiddenbar"
/>
<!-- 内容列表 --> <!-- 内容列表 -->
<div class="equipment-item" :hidden="hiddenbar"> <div
class="equipment-item"
:hidden="hiddenbar"
>
<div class="menu-list"> <div class="menu-list">
<div <div
v-for="(item, index) in categories" v-for="(item, index) in categories"
@@ -190,7 +215,7 @@ const prevPhone = () => {
class="menu-item" class="menu-item"
:class="{ :class="{
'menu-item--active': selectedMenuIndex === index, 'menu-item--active': selectedMenuIndex === index,
'menu-item--disabled': item.disabled 'menu-item--disabled': item.disabled,
}" }"
@click="selectMenuItem(index, item)" @click="selectMenuItem(index, item)"
> >
@@ -199,7 +224,10 @@ const prevPhone = () => {
v-if="selectedMenuIndex === index && !item.disabled" v-if="selectedMenuIndex === index && !item.disabled"
></div> ></div>
<span class="menu-item__text">{{ item.name }}</span> <span class="menu-item__text">{{ item.name }}</span>
<div class="menu-item__status" v-if="item.disabled"> <div
class="menu-item__status"
v-if="item.disabled"
>
<!-- <span class="status-text">未解锁</span> --> <!-- <span class="status-text">未解锁</span> -->
</div> </div>
</div> </div>
@@ -208,9 +236,15 @@ const prevPhone = () => {
<!-- 设备准备按钮 --> <!-- 设备准备按钮 -->
<div class="equipment-bt"> <div class="equipment-bt">
<img src="@/assets/images/equipment/按钮选中-设备准备.png" alt="" /> <img
src="@/assets/images/equipment/按钮选中-设备准备.png"
alt=""
/>
</div> </div>
<div :class="hiddenbar ? 'equipment-unfold1' : 'equipment-unfold2'" @click="hiddenbarclick()"> <div
:class="hiddenbar ? 'equipment-unfold1' : 'equipment-unfold2'"
@click="hiddenbarclick()"
>
<img <img
:src=" :src="
hiddenbar hiddenbar
@@ -228,36 +262,61 @@ const prevPhone = () => {
@click="prevPhone" @click="prevPhone"
:style="{ visibility: currentIndex > 0 ? 'visible' : 'hidden' }" :style="{ visibility: currentIndex > 0 ? 'visible' : 'hidden' }"
> >
<img src="@/assets/images/equipment/按钮-左滑.png" alt="" /> <img
src="@/assets/images/equipment/按钮-左滑.png"
alt=""
/>
</div> </div>
<div class="equipment-phoneImg"> <div class="equipment-phoneImg">
<img :src="currentProducts[currentIndex]?.img" alt="" /> <img
:src="currentProducts[currentIndex]?.img"
alt=""
/>
</div> </div>
<div <div
class="equipment-rightbutton" class="equipment-rightbutton"
@click="nextPhone" @click="nextPhone"
:style="{ visibility: currentIndex < currentProducts.length - 1 ? 'visible' : 'hidden' }" :style="{
visibility:
currentIndex < currentProducts.length - 1 ? 'visible' : 'hidden',
}"
> >
<img src="@/assets/images/equipment/按钮-右滑.png" alt="" /> <img
src="@/assets/images/equipment/按钮-右滑.png"
alt=""
/>
</div> </div>
</div> </div>
<div class="equipment-purpose"> <div class="equipment-purpose">
<div class="plate"> <div class="plate">
<img src="@/assets/images/equipment/浮空屏.png" alt="" /> <img
src="@/assets/images/equipment/浮空屏.png"
alt=""
/>
</div> </div>
<div class="title"> <div class="title">
<img :src="currentTitleImg" alt="" /> <img
:src="currentTitleImg"
alt=""
/>
</div> </div>
<div class="plate-content"> <div class="plate-content">
<div class="text"> <div class="text">
<img :src="currentContentImg" alt="" /> <img
:src="currentContentImg"
alt=""
/>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<ImgButton class="absolute right-0 top-0" :image="IMAGE_PATHS.COMPONENTS.BACK" @click="back" /> <ImgButton
class="absolute right-0 top-0"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="back"
/>
<!-- 调试按钮组 --> <!-- 调试按钮组 -->
<!-- <div class="debug-controls"> <!-- <div class="debug-controls">
<button @click="enableAllMenuItems" class="debug-btn">启用全部</button> <button @click="enableAllMenuItems" class="debug-btn">启用全部</button>
@@ -1,7 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { computed, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { getBackgroundImage, getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils' import {
getBackgroundImage,
getPublicPath,
getStaticImage,
IMAGE_PATHS,
} from '@/utils'
import { ImgButton } from '@/components/Button' import { ImgButton } from '@/components/Button'
import { GuideSteps, GuideStepsItem } from '@/components/Guide' import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { useFilmStore } from '@/stores/modules/film' import { useFilmStore } from '@/stores/modules/film'
@@ -11,6 +16,54 @@ const filmStore = useFilmStore()
const guideKey = 'film-onsite-intro' const guideKey = 'film-onsite-intro'
const showGuide = ref(!filmStore.guideShown[guideKey]) const showGuide = ref(!filmStore.guideShown[guideKey])
const completionIcon = (completed: boolean) =>
getStaticImage(`midterm/${completed ? '完成' : '未完成'}.png`)
const onsiteItems = computed(() => [
{
image: 'film/on-site/延时拍摄',
routeName: 'FilmOnsiteTimeLapse',
onClick: goTimeLapse,
class:
'absolute left-[100px] top-[210px] w-[446px] h-[273px] bg-cover bg-center bg-no-repeat',
},
{
image: 'film/on-site/滑轨拍摄',
routeName: 'FilmOnsiteSlider',
onClick: goSlider,
class:
'absolute left-[640px] top-[240px] w-[541px] h-[273px] bg-cover bg-center bg-no-repeat',
},
{
image: 'film/on-site/慢门拍摄-白天',
routeName: 'FilmOnsiteSlowShutterDay',
onClick: goSlowShutterDay,
class:
'absolute right-[0px] top-[220px] w-[541px] h-[273px] bg-cover bg-center bg-no-repeat',
},
{
image: 'film/on-site/慢门拍摄-夜间.png',
routeName: 'FilmOnsiteSlowShutterNight',
onClick: goSlowShutterNight,
class:
'absolute right-[0px] bottom-[160px] w-[541px] h-[273px] bg-cover bg-center bg-no-repeat',
},
{
image: 'film/on-site/低角度拍摄',
routeName: 'FilmOnsiteLowAngle',
onClick: goLowAngle,
class:
'absolute left-[100px] bottom-[80px] w-[446px] h-[273px] bg-cover bg-center bg-no-repeat',
},
{
image: 'film/on-site/创作随机性',
routeName: 'FilmOnsiteRandom',
onClick: goRandom,
class:
'absolute left-[700px] bottom-[40px] w-[446px] h-[273px] bg-cover bg-center bg-no-repeat',
},
])
if (showGuide.value) { if (showGuide.value) {
filmStore.markGuideShown(guideKey) filmStore.markGuideShown(guideKey)
} }
@@ -53,86 +106,27 @@ const goRandom = () => {
></div> ></div>
<!-- 标题 --> <!-- 标题 -->
<img class="absolute left-0 top-0" :src="getStaticImage('film/on-site/标题.png')" alt="" /> <img
class="absolute left-0 top-0"
:src="getStaticImage('film/on-site/标题.png')"
alt=""
/>
<div <div
class="absolute left-[100px] top-[210px] w-[446px] h-[273px] bg-cover bg-center bg-no-repeat" v-for="item in onsiteItems"
:key="item.routeName"
:class="item.class"
> >
<ImgButton :image="'film/on-site/延时拍摄'" @click="goTimeLapse" /> <ImgButton
:image="item.image"
@click="item.onClick"
/>
<img
class="pointer-events-none absolute left-6 top-2 z-10 h-12 w-12"
:src="completionIcon(filmStore.viewed[item.routeName] === true)"
alt=""
/>
</div> </div>
<div
class="absolute left-[640px] top-[240px] w-[541px] h-[273px] bg-cover bg-center bg-no-repeat"
>
<ImgButton :image="'film/on-site/滑轨拍摄'" @click="goSlider" />
</div>
<div
class="absolute right-[0px] top-[220px] w-[541px] h-[273px] bg-cover bg-center bg-no-repeat"
>
<ImgButton :image="'film/on-site/慢门拍摄-白天'" @click="goSlowShutterDay" />
</div>
<div
class="absolute right-[0px] bottom-[160px] w-[541px] h-[273px] bg-cover bg-center bg-no-repeat"
>
<ImgButton :image="'film/on-site/慢门拍摄-夜间.png'" @click="goSlowShutterNight" />
</div>
<div
class="absolute left-[100px] bottom-[80px] w-[446px] h-[273px] bg-cover bg-center bg-no-repeat"
>
<ImgButton :image="'film/on-site/低角度拍摄'" @click="goLowAngle" />
</div>
<div
class="absolute left-[700px] bottom-[40px] w-[446px] h-[273px] bg-cover bg-center bg-no-repeat"
>
<ImgButton :image="'film/on-site/创作随机性'" @click="goRandom" />
</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 <ImgButton
@@ -142,7 +136,10 @@ const goRandom = () => {
/> />
</div> </div>
<GuideSteps :visible="showGuide" position-offset-y="85%"> <GuideSteps
:visible="showGuide"
position-offset-y="85%"
>
<GuideStepsItem :audio-src="getPublicPath('audio/py_4_016.MP3')"> <GuideStepsItem :audio-src="getPublicPath('audio/py_4_016.MP3')">
接下来进入核心拍摄环节,我们依次来学习! 接下来进入核心拍摄环节,我们依次来学习!
</GuideStepsItem> </GuideStepsItem>
@@ -11,8 +11,10 @@ import {
getStaticImage, getStaticImage,
IMAGE_PATHS, IMAGE_PATHS,
} from '@/utils' } from '@/utils'
import { useFilmStore } from '@/stores/modules/film'
const router = useRouter() const router = useRouter()
const filmStore = useFilmStore()
const handleBack = () => { const handleBack = () => {
router.back() router.back()
@@ -53,6 +55,7 @@ const showSummaryStep = ref(false)
// 答题完成,进入交互三(总结) // 答题完成,进入交互三(总结)
const handleQuestionsComplete = () => { const handleQuestionsComplete = () => {
showSummaryStep.value = true showSummaryStep.value = true
filmStore.markViewed('FilmOnsiteLowAngle')
} }
</script> </script>
@@ -4,8 +4,10 @@ import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button' import { ImgButton } from '@/components/Button'
import { Video } from '@/components' import { Video } from '@/components'
import { getPublicPath, IMAGE_PATHS } from '@/utils' import { getPublicPath, IMAGE_PATHS } from '@/utils'
import { useFilmStore } from '@/stores/modules/film'
const router = useRouter() const router = useRouter()
const filmStore = useFilmStore()
const handleBack = () => { const handleBack = () => {
router.back() router.back()
@@ -16,10 +18,16 @@ const randomVideoUrl = getPublicPath('video/film/on-site/dygmk_czsjx_batch.mp4')
// 视频播放状态 // 视频播放状态
const videoPlaying = ref(false) const videoPlaying = ref(false)
const handleVideoEnded = () => {
filmStore.markViewed('FilmOnsiteRandom')
}
</script> </script>
<template> <template>
<div class="relative w-full h-full flex items-center justify-center overflow-hidden"> <div
class="relative w-full h-full flex items-center justify-center overflow-hidden"
>
<!-- 视频播放区域 --> <!-- 视频播放区域 -->
<div class="w-full h-full"> <div class="w-full h-full">
<Video <Video
@@ -31,6 +39,7 @@ const videoPlaying = ref(false)
:controls="true" :controls="true"
:hideNativeControls="true" :hideNativeControls="true"
:autoplay="true" :autoplay="true"
@ended="handleVideoEnded"
/> />
</div> </div>
@@ -7,8 +7,10 @@ import QuestionQuiz, {
type Question, type Question,
} from '@/components/QuestionQuiz/QuestionQuiz.vue' } from '@/components/QuestionQuiz/QuestionQuiz.vue'
import { getPublicPath, IMAGE_PATHS } from '@/utils' import { getPublicPath, IMAGE_PATHS } from '@/utils'
import { useFilmStore } from '@/stores/modules/film'
const router = useRouter() const router = useRouter()
const filmStore = useFilmStore()
const handleBack = () => { const handleBack = () => {
router.back() router.back()
@@ -54,6 +56,7 @@ const questions = ref<Question[]>([
// 答题完成 // 答题完成
const handleQuestionsComplete = () => { const handleQuestionsComplete = () => {
showQuiz.value = false // 答题成功后关闭答题窗口 showQuiz.value = false // 答题成功后关闭答题窗口
filmStore.markViewed('FilmOnsiteSlider')
} }
</script> </script>
@@ -11,8 +11,10 @@ import {
getStaticImage, getStaticImage,
IMAGE_PATHS, IMAGE_PATHS,
} from '@/utils' } from '@/utils'
import { useFilmStore } from '@/stores/modules/film'
const router = useRouter() const router = useRouter()
const filmStore = useFilmStore()
const handleBack = () => { const handleBack = () => {
router.back() router.back()
@@ -55,6 +57,7 @@ const showSummaryStep = ref(false)
// 答题完成,进入交互三(总结) // 答题完成,进入交互三(总结)
const handleQuestionsComplete = () => { const handleQuestionsComplete = () => {
showSummaryStep.value = true showSummaryStep.value = true
filmStore.markViewed('FilmOnsiteSlowShutterDay')
} }
</script> </script>
@@ -4,18 +4,26 @@ import { useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button' import { ImgButton } from '@/components/Button'
import { Video } from '@/components' import { Video } from '@/components'
import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils' import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils'
import { useFilmStore } from '@/stores/modules/film'
const router = useRouter() const router = useRouter()
const filmStore = useFilmStore()
const handleBack = () => { const handleBack = () => {
router.back() router.back()
} }
// 慢门拍摄夜晚视频 URL(暂时用其他视频代替) // 慢门拍摄夜晚视频 URL(暂时用其他视频代替)
const slowShutterNightVideoUrl = getPublicPath('video/film/on-site/dygmk_mmps_batch.mp4') const slowShutterNightVideoUrl = getPublicPath(
'video/film/on-site/dygmk_mmps_batch.mp4',
)
// 视频播放状态 // 视频播放状态
const videoPlaying = ref(false) const videoPlaying = ref(false)
const handleVideoEnded = () => {
filmStore.markViewed('FilmOnsiteSlowShutterNight')
}
</script> </script>
<template> <template>
@@ -37,6 +45,7 @@ const videoPlaying = ref(false)
:controls="true" :controls="true"
:hideNativeControls="true" :hideNativeControls="true"
:autoplay="true" :autoplay="true"
@ended="handleVideoEnded"
/> />
</div> </div>
@@ -7,8 +7,10 @@ import QuestionQuiz, {
type Question, type Question,
} from '@/components/QuestionQuiz/QuestionQuiz.vue' } from '@/components/QuestionQuiz/QuestionQuiz.vue'
import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils' import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils'
import { useFilmStore } from '@/stores/modules/film'
const router = useRouter() const router = useRouter()
const filmStore = useFilmStore()
// 延时拍摄视频 URL(占位,后续可替换) // 延时拍摄视频 URL(占位,后续可替换)
const timeLapseVideoUrl = getPublicPath( const timeLapseVideoUrl = getPublicPath(
@@ -57,6 +59,7 @@ const videoPlaying = ref(false) // 视频播放状态
// 答题完成,进入交互三(视频) // 答题完成,进入交互三(视频)
const handleQuestionsComplete = () => { const handleQuestionsComplete = () => {
showObserveStep.value = true showObserveStep.value = true
filmStore.markViewed('FilmOnsiteTimeLapse')
} }
</script> </script>
@@ -137,7 +140,7 @@ const handleQuestionsComplete = () => {
<!-- 返回按钮 --> <!-- 返回按钮 -->
<ImgButton <ImgButton
class="absolute right-0 top-0 z-20" class="absolute right-0 top-0 z-50"
:image="IMAGE_PATHS.COMPONENTS.BACK" :image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleBack" @click="handleBack"
/> />
@@ -1,29 +1,15 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref, computed } from 'vue' import { ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { GuideSteps } from '@/components/Guide' import { GuideSteps, type GuideStep } from '@/components/Guide'
import { Overlay } from '@/components/Overlay'
import { ImgButton } from '@/components/Button' import { ImgButton } from '@/components/Button'
import { import { getBackgroundImage, getPublicPath } from '@/utils'
getBackgroundImage,
getPublicPath,
getStaticImage,
IMAGE_PATHS,
} from '@/utils'
import shootingtitle from '@/assets/images/shooting/标题-实地取景.png' import shootingtitle from '@/assets/images/shooting/标题-实地取景.png'
import videoSrc from '/video/new/composition/xsdh_jzgtf_batch.mp4' import videoSrc from '/video/new/composition/xsdh_jzgtf_batch.mp4'
import KnowledgeCard from '@/components/KnowledgeCard/FilmKnowledgeCard.vue' import KnowledgeCard from '@/components/KnowledgeCard/FilmKnowledgeCard.vue'
import { Video } from '@/components' import CompositionGuide from '@/components/CompositionGuide.vue'
import { useFilmStore } from '@/stores/modules/film' import { useFilmStore } from '@/stores/modules/film'
type Btn = {
key: string
name: string
btnImage: string
images: string[]
desc: string
}
const router = useRouter() const router = useRouter()
const filmStore = useFilmStore() const filmStore = useFilmStore()
const introGuideKey = 'film-shooting-intro' const introGuideKey = 'film-shooting-intro'
@@ -35,7 +21,7 @@ if (showIntroGuide.value) {
filmStore.markGuideShown(introGuideKey) filmStore.markGuideShown(introGuideKey)
} }
const steps = ref([ const steps = ref<GuideStep[]>([
{ {
id: '1', id: '1',
audioSrc: getPublicPath('audio/py_4_015.MP3'), audioSrc: getPublicPath('audio/py_4_015.MP3'),
@@ -44,7 +30,7 @@ const steps = ref([
}, },
]) ])
const guideSteps = ref([ const guideSteps = ref<GuideStep[]>([
{ {
id: '1', id: '1',
content: '点击框架构图,查看构图方法', content: '点击框架构图,查看构图方法',
@@ -63,17 +49,7 @@ const guideSteps = ref([
]) ])
const showModal = ref(false) const showModal = ref(false)
const showModal2 = ref(false) const btnList = ref([
const showModal3 = ref(false)
const currentItem = ref<Btn>({
key: '',
name: '',
btnImage: '',
images: [],
desc: '',
})
const btnList = ref<Btn[]>([
{ {
key: '1', key: '1',
name: '框架构图', name: '框架构图',
@@ -100,10 +76,6 @@ const btnList = ref<Btn[]>([
}, },
]) ])
const handleModalClose = () => {
showModal.value = false
}
const openCompositionGuide = () => { const openCompositionGuide = () => {
showModal.value = true showModal.value = true
if (showCompositionGuide.value) { if (showCompositionGuide.value) {
@@ -111,26 +83,13 @@ const openCompositionGuide = () => {
} }
} }
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 = () => { const handleClickA = () => {
// 跳转设备准备
router.push({ name: 'FilmFilmingLocationA' }) router.push({ name: 'FilmFilmingLocationA' })
} }
const handleClickB = () => { const handleClickB = () => {
// 跳转设备准备
router.push({ name: 'FilmFilmingLocationB' }) router.push({ name: 'FilmFilmingLocationB' })
} }
const back = () => { const back = () => {
// 跳转设备准备
router.back() router.back()
} }
</script> </script>
@@ -142,13 +101,13 @@ const back = () => {
:style="{ backgroundImage: getBackgroundImage('film/shooting/底图') }" :style="{ backgroundImage: getBackgroundImage('film/shooting/底图') }"
></div> ></div>
<div class="shooting-title"> <div class="absolute top-0 left-0">
<img <img
:src="shootingtitle" :src="shootingtitle"
alt="" alt=""
/> />
</div> </div>
<div class="shooting-return"> <div class="absolute top-0 right-0">
<ImgButton <ImgButton
image="shooting/返回.png" image="shooting/返回.png"
@click="back" @click="back"
@@ -168,7 +127,7 @@ const back = () => {
@click="handleClickB" @click="handleClickB"
/> />
</div> </div>
<div class="shooting-composition btn-1"> <div class="absolute bottom-4 left-4 btn-1">
<ImgButton <ImgButton
image="shooting/构图指南.png" image="shooting/构图指南.png"
@click="openCompositionGuide" @click="openCompositionGuide"
@@ -184,127 +143,16 @@ const back = () => {
position-offset-y="20%" position-offset-y="20%"
/> />
<!-- 保存图片弹窗 --> <!-- 构图指南弹窗 -->
<Overlay <CompositionGuide
:visible="showModal" :visible="showModal"
:clickable="false" bg-image="film/shooting/构图指南底图"
:customStyle="{ :btn-list="btnList"
backgroundImage: getBackgroundImage('film/shooting/构图指南底图'), :knowledge-card-component="KnowledgeCard"
}" :video-src="videoSrc"
customClass="inset-0 bg-cover bg-center bg-no-repeat" :guide-steps="guideSteps"
contentClass="relative w-full h-full" :show-internal-guide="showCompositionGuide"
> @close="showModal = false"
<!-- 返回 --> />
<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/标题-构图指南"
/>
<!-- 新手导航视频 -->
<div class="absolute left-28 top-[calc(50%-40px)] -translate-y-1/2">
<ImgButton
image="shooting/新手导航-视频回顾"
@click="showModal3 = true"
/>
<div
class="absolute left-[40%] top-[42%] -translate-x-1/2 -translate-y-1/2 z-10 animate-bounce pointer-events-none"
>
<img
class="w-32"
:src="getStaticImage('work/小手')"
alt=""
/>
<span class="text-white text-2xl font-bold ml-5">点击进入</span>
</div>
</div>
<div class="absolute top-[30%] right-10">
<ImgButton
v-for="item in btnList"
:key="item.key"
:class="`btn-item-${item.key}`"
:image="item.btnImage"
@click="handleClick(item)"
/>
</div>
<GuideSteps
:visible="showCompositionGuide"
:steps="guideSteps"
:keyboard-navigation="true"
:highlight-padding="12"
:highlight-radius="16"
position-offset-y="85%"
/>
<!-- 知识卡片 -->
<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
:volume="1"
:muted="false"
></Video>
<!-- 返回 -->
<ImgButton
class="absolute right-0 top-0 z-10"
:image="IMAGE_PATHS.COMPONENTS.CLOSE"
@click="showModal3 = false"
/>
</Overlay>
</Overlay>
</div> </div>
</template> </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>
+12 -1
View File
@@ -81,6 +81,17 @@ const worksList = ref<WorkItem[]>([
}, },
]) ])
const getCachedWorkScore = (work: WorkItem): number => {
const scores = filmStore.practiceWorkRatings[work.path]
const values = scores ? Object.values(scores) : []
if (values.length === 0) return work.stars
return Math.round(
values.reduce((total, score) => total + score, 0) / values.length,
)
}
/** 左滑动作品列表 */ /** 左滑动作品列表 */
const scrollWorksLeft = (): void => { const scrollWorksLeft = (): void => {
if (worksScrollIndex.value > 0) { if (worksScrollIndex.value > 0) {
@@ -306,7 +317,7 @@ onUnmounted(() => {
</h3> </h3>
<!-- 星星评分 --> <!-- 星星评分 -->
<StarRating <StarRating
:score="work.stars" :score="getCachedWorkScore(work)"
size="lg" size="lg"
readonly readonly
/> />
+50 -7
View File
@@ -1,19 +1,37 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref } from 'vue' import { computed, onBeforeUnmount, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { getBackgroundImage, IMAGE_PATHS } from '@/utils' import { getBackgroundImage, IMAGE_PATHS } from '@/utils'
import { ImgButton } from '@/components/Button' import { ImgButton } from '@/components/Button'
import PracticeRatingModal, { import PracticeRatingModal, {
type PracticeRatingItem, type PracticeRatingItem,
} from '@/components/PracticeRatingModal' } from '@/components/PracticeRatingModal'
import { useFilmStore } from '@/stores/modules/film'
const router = useRouter() const router = useRouter()
const store = useFilmStore()
type PracticeRatingScores = Record<string, number>
const applyScores = (
items: PracticeRatingItem[],
scores: PracticeRatingScores,
): PracticeRatingItem[] =>
items.map((item) => ({
...item,
score: scores[item.title] ?? item.score,
}))
const toScores = (items: PracticeRatingItem[]): PracticeRatingScores =>
Object.fromEntries(items.map((item) => [item.title, item.score]))
/** 笔记内容 */ /** 笔记内容 */
const noteContent = ref<string>('') const noteContent = ref<string>(store.practiceNoteContent)
/** 保存弹窗 */ /** 保存弹窗 */
const showModal = ref<boolean>(false) const showModal = ref<boolean>(false)
const showSuccess = ref<boolean>(false)
let successTimer: number | undefined
/** 返回上一页 */ /** 返回上一页 */
const handleBack = (): void => { const handleBack = (): void => {
@@ -23,7 +41,7 @@ const handleBack = (): void => {
const disabled = computed<boolean>(() => noteContent.value.trim() === '') const disabled = computed<boolean>(() => noteContent.value.trim() === '')
/** 评估项目列表 */ /** 评估项目列表 */
const evaluationItems = ref<PracticeRatingItem[]>([ const defaultEvaluationItems: PracticeRatingItem[] = [
{ {
title: '画面氛围', title: '画面氛围',
description: '画面具有明确情绪或空间气质,整体风格统一', description: '画面具有明确情绪或空间气质,整体风格统一',
@@ -49,11 +67,28 @@ const evaluationItems = ref<PracticeRatingItem[]>([
description: '作品具有“电影感”,完成度高', description: '作品具有“电影感”,完成度高',
score: 5, score: 5,
}, },
]) ]
const evaluationItems = ref<PracticeRatingItem[]>(
applyScores(defaultEvaluationItems, store.practiceSelfEvaluationScores),
)
const handleSave = (): void => { const handleSave = (): void => {
console.log('保存笔记') store.setPracticeNoteContent(noteContent.value)
handleBack() showSuccess.value = true
window.clearTimeout(successTimer)
successTimer = window.setTimeout(() => {
showSuccess.value = false
}, 1500)
} }
const handleSaveEvaluation = (items: PracticeRatingItem[]): void => {
store.setPracticeSelfEvaluationScores(toScores(items))
}
onBeforeUnmount(() => {
window.clearTimeout(successTimer)
})
</script> </script>
<template> <template>
@@ -76,6 +111,13 @@ const handleSave = (): void => {
placeholder="点击输入创作笔记" placeholder="点击输入创作笔记"
></textarea> ></textarea>
<div
v-if="showSuccess"
class="absolute left-1/2 top-1/2 z-[100] -translate-x-1/2 -translate-y-1/2 rounded-2xl bg-black/70 px-16 py-8 text-5xl font-bold text-white"
>
提交成功
</div>
<div class="absolute bottom-24 left-1/2 -translate-x-1/2 flex gap-60"> <div class="absolute bottom-24 left-1/2 -translate-x-1/2 flex gap-60">
<ImgButton <ImgButton
image="vlog/practice/保存" image="vlog/practice/保存"
@@ -90,10 +132,11 @@ const handleSave = (): void => {
<PracticeRatingModal <PracticeRatingModal
:visible="showModal" :visible="showModal"
:items="evaluationItems" v-model:items="evaluationItems"
background-image="film/practice/电影感短视频创作-作品评分表" background-image="film/practice/电影感短视频创作-作品评分表"
top-tip="评分时要关注5个维度哦~分别是画面氛围、抽摄方法、剪辑逻辑、创作意识、综合观感,每个维度满分5分,能打1-5分,记得对照作品表现来打分呀~" top-tip="评分时要关注5个维度哦~分别是画面氛围、抽摄方法、剪辑逻辑、创作意识、综合观感,每个维度满分5分,能打1-5分,记得对照作品表现来打分呀~"
bottom-tip="己评价的实战作品,从封面入口就能直接看到具体评分!打了多少分一目了然,不用再点进详情页翻找啦~" bottom-tip="己评价的实战作品,从封面入口就能直接看到具体评分!打了多少分一目了然,不用再点进详情页翻找啦~"
@save="handleSaveEvaluation"
@close="showModal = false" @close="showModal = false"
/> />
</div> </div>
+33 -4
View File
@@ -6,11 +6,12 @@ import PracticeRatingModal, {
type PracticeRatingItem, type PracticeRatingItem,
} from '@/components/PracticeRatingModal' } from '@/components/PracticeRatingModal'
import { IMAGE_PATHS, getPublicPath } from '@/utils' import { IMAGE_PATHS, getPublicPath } from '@/utils'
import { ref } from 'vue' import { computed, ref } from 'vue'
import { GuideSteps, GuideStepsItem } from '@/components/Guide' import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { useFilmStore } from '@/stores/modules/film' import { useFilmStore } from '@/stores/modules/film'
const router = useRouter() const router = useRouter()
const filmStore = useFilmStore() const filmStore = useFilmStore()
const store = filmStore
const guideKey = 'film-practice-video-rating' const guideKey = 'film-practice-video-rating'
const showGuide = ref(!filmStore.guideShown[guideKey]) const showGuide = ref(!filmStore.guideShown[guideKey])
@@ -25,8 +26,24 @@ const props = defineProps({
}, },
}) })
type PracticeRatingScores = Record<string, number>
const workKey = computed<string>(() => decodeURIComponent(props.path))
const applyScores = (
items: PracticeRatingItem[],
scores: PracticeRatingScores,
): PracticeRatingItem[] =>
items.map((item) => ({
...item,
score: scores[item.title] ?? item.score,
}))
const toScores = (items: PracticeRatingItem[]): PracticeRatingScores =>
Object.fromEntries(items.map((item) => [item.title, item.score]))
/** 评估项目列表 */ /** 评估项目列表 */
const evaluationItems = ref<PracticeRatingItem[]>([ const defaultEvaluationItems: PracticeRatingItem[] = [
{ {
title: '画面氛围', title: '画面氛围',
description: '画面具有明确情绪或空间气质,整体风格统一', description: '画面具有明确情绪或空间气质,整体风格统一',
@@ -52,7 +69,18 @@ const evaluationItems = ref<PracticeRatingItem[]>([
description: '作品具有“电影感”,完成度高', description: '作品具有“电影感”,完成度高',
score: 5, score: 5,
}, },
]) ]
const evaluationItems = ref<PracticeRatingItem[]>(
applyScores(
defaultEvaluationItems,
store.getPracticeWorkRating(workKey.value),
),
)
const handleSaveEvaluation = (items: PracticeRatingItem[]): void => {
store.setPracticeWorkRating(workKey.value, toScores(items))
}
/** 保存弹窗 */ /** 保存弹窗 */
const showModal = ref<boolean>(false) const showModal = ref<boolean>(false)
@@ -85,10 +113,11 @@ const handleBack = (): void => {
<PracticeRatingModal <PracticeRatingModal
:visible="showModal" :visible="showModal"
:items="evaluationItems" v-model:items="evaluationItems"
background-image="film/practice/电影感短视频创作-作品评分表" background-image="film/practice/电影感短视频创作-作品评分表"
top-tip="评分时要关注5个维度哦~分别是画面氛围、抽摄方法、剪辑逻辑、创作意识、综合观感,每个维度满分5分,能打1-5分,记得对照作品表现来打分呀~" top-tip="评分时要关注5个维度哦~分别是画面氛围、抽摄方法、剪辑逻辑、创作意识、综合观感,每个维度满分5分,能打1-5分,记得对照作品表现来打分呀~"
bottom-tip="己评价的实战作品,从封面入口就能直接看到具体评分!打了多少分一目了然,不用再点进详情页翻找啦~" bottom-tip="己评价的实战作品,从封面入口就能直接看到具体评分!打了多少分一目了然,不用再点进详情页翻找啦~"
@save="handleSaveEvaluation"
@close="showModal = false" @close="showModal = false"
/> />
<GuideSteps <GuideSteps
+78 -5
View File
@@ -1,9 +1,10 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, nextTick, onMounted, ref } from 'vue' import { computed, nextTick, onMounted, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { onBeforeRouteLeave, useRouter, useRoute } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue' import ImgButton from '@/components/Button/ImgButton.vue'
import { GuideSteps, GuideStepsItem } from '@/components/Guide' import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import StepNavigation from '@/components/Guide/StepNavigation.vue' import StepNavigation from '@/components/Guide/StepNavigation.vue'
import UnfinishedLearningPrompt from '@/components/UnfinishedLearningPrompt'
import { Video } from '@/components' import { Video } from '@/components'
import QuestionQuiz, { import QuestionQuiz, {
type Question, type Question,
@@ -64,13 +65,13 @@ const navigationSteps = ref([
content: '分镜头', content: '分镜头',
}, },
{ {
id: 'step5', id: 'step6',
label: '后期制作', label: '后期制作',
videoUrl: getPublicPath('video/film/work/dygmk_lj_batch.mp4'), videoUrl: getPublicPath('video/film/work/dygmk_lj_batch.mp4'),
content: '滤镜', content: '滤镜',
}, },
{ {
id: 'step5', id: 'step7',
label: '拍摄执行', label: '拍摄执行',
videoUrl: getPublicPath('video/film/work/dygmk_tqs_batch.mp4'), videoUrl: getPublicPath('video/film/work/dygmk_tqs_batch.mp4'),
content: '同期声', content: '同期声',
@@ -79,10 +80,47 @@ const navigationSteps = ref([
// 步骤导航相关状态 // 步骤导航相关状态
const showStepNavigation = ref(false) const showStepNavigation = ref(false)
const showUnfinishedPrompt = ref(false)
const pendingLeavePath = ref<string | null>(null)
const allowRouteLeave = ref(false)
const currentNavigationStep = ref(0) // 对应视频索引,从0开始 const currentNavigationStep = ref(0) // 对应视频索引,从0开始
const currentVideoIndex = ref(0) const currentVideoIndex = ref(0)
const professionVideoKeys = [
'film-profession-zhefu',
'film-profession-guangxian',
'film-profession-xushi',
'film-profession-texiejingtou',
'film-profession-fenjingtou',
'film-profession-lvjing',
'film-profession-tongqisheng',
] as const
const completedNavigationSteps = computed(() =>
navigationSteps.value.map(
(_, index) =>
film.professionVideosWatched[professionVideoKeys[index]] === true,
),
)
const hasUnfinishedLearningVideos = computed(
() =>
key.value === 'profession' &&
completedNavigationSteps.value.some((completed) => !completed),
)
const markCurrentProfessionVideoWatched = () => {
const videoKey = professionVideoKeys[currentVideoIndex.value]
if (!videoKey) return
film.markProfessionVideoWatched(videoKey)
const isAllWatched = professionVideoKeys.every(
(key) => key === videoKey || film.professionVideosWatched[key] === true,
)
if (isAllWatched) film.markWorkVideoWatched('profession')
}
// 题目数据:虚实(index 2)和分镜头(index 4) // 题目数据:虚实(index 2)和分镜头(index 4)
const questions = ref<Question[]>([]) const questions = ref<Question[]>([])
@@ -94,7 +132,7 @@ const getQuestionsForVideo = (index: number): Question[] => {
{ {
id: 1, id: 1,
title: '', title: '',
description: '(单选题)下面两个画面,哪一个更具电影叙事感?', description: '【单选题】下面两个画面,哪一个更具电影叙事感?',
options: [ options: [
{ {
id: 'a', id: 'a',
@@ -117,7 +155,7 @@ const getQuestionsForVideo = (index: number): Question[] => {
{ {
id: 2, id: 2,
title: '', title: '',
description: '(单选题)下面哪一组镜头顺序,更符合电影叙事逻辑?', description: '【单选题】下面哪一组镜头顺序,更符合电影叙事逻辑?',
options: [ options: [
{ {
id: 'a', id: 'a',
@@ -169,6 +207,7 @@ const handleVideoChange = () => {
const handleClose = () => { const handleClose = () => {
if (key.value === 'idea') { if (key.value === 'idea') {
film.markWorkVideoWatched('idea')
film.setFilmUnlockProgress(1) film.setFilmUnlockProgress(1)
router.push({ router.push({
name: 'Film', name: 'Film',
@@ -190,7 +229,14 @@ const handlePlay = () => {
} }
const onEnded = () => { const onEnded = () => {
if (key.value === 'case') {
film.markWorkVideoWatched('case')
return
}
if (key.value === 'profession') { if (key.value === 'profession') {
markCurrentProfessionVideoWatched()
// 检查是否是"虚实"(index 2)或"分镜头"(index 4) // 检查是否是"虚实"(index 2)或"分镜头"(index 4)
if (currentVideoIndex.value === 2 || currentVideoIndex.value === 4) { if (currentVideoIndex.value === 2 || currentVideoIndex.value === 4) {
// 暂停播放并显示答题窗口 // 暂停播放并显示答题窗口
@@ -206,6 +252,7 @@ const onEnded = () => {
const handleComplete = () => { const handleComplete = () => {
if (key.value === 'profession') { if (key.value === 'profession') {
film.markWorkVideoWatched('profession')
if (!film.isFilmUnlock) film.setIsFilmUnlock(true) if (!film.isFilmUnlock) film.setIsFilmUnlock(true)
router.push({ router.push({
name: 'FilmWork', name: 'FilmWork',
@@ -243,6 +290,25 @@ const handleProfessionGuideComplete = () => {
}) })
} }
const handleContinueLearning = () => {
showUnfinishedPrompt.value = false
pendingLeavePath.value = null
}
const handleConfirmLeave = () => {
if (!pendingLeavePath.value) return
allowRouteLeave.value = true
showUnfinishedPrompt.value = false
router.push(pendingLeavePath.value)
}
onBeforeRouteLeave((to) => {
if (allowRouteLeave.value || !hasUnfinishedLearningVideos.value) return true
pendingLeavePath.value = to.fullPath
showUnfinishedPrompt.value = true
return false
})
onMounted(() => {}) onMounted(() => {})
</script> </script>
@@ -330,6 +396,7 @@ onMounted(() => {})
:background-image="getStaticImage('work/vlog背景全屏待定')" :background-image="getStaticImage('work/vlog背景全屏待定')"
:steps="navigationSteps" :steps="navigationSteps"
:current-step-index="currentNavigationStep" :current-step-index="currentNavigationStep"
:completed-steps="completedNavigationSteps"
@step-click="handleStepClick" @step-click="handleStepClick"
@close="handleStepNavigationClose" @close="handleStepNavigationClose"
/> />
@@ -379,6 +446,12 @@ onMounted(() => {})
</GuideSteps> </GuideSteps>
<!-- 引导与高亮 --> <!-- 引导与高亮 -->
<UnfinishedLearningPrompt
:visible="showUnfinishedPrompt"
@continue="handleContinueLearning"
@leave="handleConfirmLeave"
/>
<GuideSteps <GuideSteps
highlight-selector="" highlight-selector=""
v-if="visibleGuide2 && key === 'idea'" v-if="visibleGuide2 && key === 'idea'"
+5 -2
View File
@@ -21,7 +21,10 @@ const film = useFilmStore()
type WorkTab = 'case' | 'profession' | 'idea' type WorkTab = 'case' | 'profession' | 'idea'
const selected = ref<WorkTab>('case') const selected = computed({
get: () => film.workSelectedTab as WorkTab,
set: (value: WorkTab) => film.setWorkSelectedTab(value),
})
const stepIndex = ref(0) const stepIndex = ref(0)
const workSideSwitcherRef = ref<InstanceType<typeof WorkSideSwitcher>>() const workSideSwitcherRef = ref<InstanceType<typeof WorkSideSwitcher>>()
@@ -218,7 +221,7 @@ function openImages(urls: string[], startIndex = 0) {
但所谓高级感,到底来自哪里? 但所谓高级感,到底来自哪里?
</GuideStepsItem> </GuideStepsItem>
<GuideStepsItem :audio-src="getPublicPath('audio/py_4_003.MP3')"> <GuideStepsItem :audio-src="getPublicPath('audio/py_4_003.MP3')">
带着这个问题,我们一起来看看“钟表博物馆”的案例,看看普通画面是如何被赋予质感与故事感的。 带着这个问题,我们一起来看看“钟表文化科普馆”的案例,看看普通画面是如何被赋予质感与故事感的。
</GuideStepsItem> </GuideStepsItem>
</GuideSteps> </GuideSteps>
+68 -7
View File
@@ -11,7 +11,7 @@ import {
import { ImgButton } from '@/components/Button' import { ImgButton } from '@/components/Button'
import { ModuleSwitcher } from './components/ModuleSwitcher' import { ModuleSwitcher } from './components/ModuleSwitcher'
import StudyProgressPopup from './components/StudyProgressPopup.vue' import StudyProgressPopup from './components/StudyProgressPopup.vue'
import { computed, ref, onMounted } from 'vue' import { computed, ref, nextTick, onMounted } from 'vue'
import { useRouter, type RouteRecordName } from 'vue-router' import { useRouter, type RouteRecordName } from 'vue-router'
import { import {
useUserStore, useUserStore,
@@ -22,6 +22,11 @@ import {
} from '@/stores' } from '@/stores'
import { GuideSteps } from '@/components/Guide' import { GuideSteps } from '@/components/Guide'
import type { GuideStep } from '@/components/Guide/types' import type { GuideStep } from '@/components/Guide/types'
import {
getBasicElementsProgress,
getOverallStudyProgress,
getProductionModuleProgress,
} from './components/studyProgressUtils'
type GuideChildNode = { type GuideChildNode = {
id: number id: number
@@ -87,7 +92,7 @@ const homeGuideSteps: GuideStep[] = [
const unlockedHomeGuideSteps: GuideStep[] = [ const unlockedHomeGuideSteps: GuideStep[] = [
{ {
id: 'home-guide-welcome', id: 'home-guide-welcome',
content: '欢迎来到新手村!在这里,我会带着你解锁短视频创作的核心基本功!', content: '欢迎来到新手模块!在这里,我会带着你解锁短视频创作的核心基本功!',
audioSrc: getPublicPath('audio/py_1_001.MP3'), audioSrc: getPublicPath('audio/py_1_001.MP3'),
}, },
{ {
@@ -108,6 +113,7 @@ const unlockedHomeGuideSteps: GuideStep[] = [
const elementGuideSteps: GuideStep[] = [ const elementGuideSteps: GuideStep[] = [
{ {
id: 'home-guide-start-element', id: 'home-guide-start-element',
highlightSelector: '.child-node-buttons',
content: '选择任意要素开始学习~', content: '选择任意要素开始学习~',
positionOffsetY: '20%', positionOffsetY: '20%',
audioSrc: getPublicPath('audio/py_1_004.MP3'), audioSrc: getPublicPath('audio/py_1_004.MP3'),
@@ -210,6 +216,32 @@ const selectedImage = computed(() => {
return getBackgroundImage(imageMap[selectedModule.value]) return getBackgroundImage(imageMap[selectedModule.value])
}) })
const filmViewed = computed(() => ({
...film.viewed,
FilmOnsiteShooting: film.allOnsiteScenesViewed,
FilmEditing: film.allEditingVideosWatched,
}))
const totalStudyPercent = computed(() =>
getOverallStudyProgress([
getBasicElementsProgress(
basicElements.basicElementsUnlockProgress,
basicElements.isBasicElementsUnlock,
basicElements.learningVideosWatched,
),
getProductionModuleProgress('vlog', vlog.viewed, vlog.workVideosWatched),
getProductionModuleProgress(
'interview',
interview.viewed,
interview.workVideosWatched,
),
getProductionModuleProgress(
'film',
filmViewed.value,
film.workVideosWatched,
),
]),
)
const isSkillDisabled = computed(() => !hasFinishedActionGuide.value) const isSkillDisabled = computed(() => !hasFinishedActionGuide.value)
const handleOpenStudyProgress = () => { const handleOpenStudyProgress = () => {
@@ -291,6 +323,7 @@ const handleNodeClick = (node: GuideNode, event?: MouseEvent) => {
event?.stopPropagation() event?.stopPropagation()
if (!node.hasChildren) { if (!node.hasChildren) {
showChildrenNodeId.value = null
if (node.routeName) { if (node.routeName) {
router.push({ name: node.routeName }) router.push({ name: node.routeName })
} }
@@ -304,7 +337,11 @@ const handleNodeClick = (node: GuideNode, event?: MouseEvent) => {
showChildrenNodeId.value = node.id showChildrenNodeId.value = node.id
if (activeActionGuide.value !== 'unlocked' && !hasFinishedActionGuide.value) { if (activeActionGuide.value !== 'unlocked' && !hasFinishedActionGuide.value) {
activeActionGuide.value = 'element' nextTick(() => {
requestAnimationFrame(() => {
activeActionGuide.value = 'element'
})
})
} }
} }
@@ -365,6 +402,10 @@ onMounted(() => {
<StudyProgressPopup v-model="studyProgressVisible" /> <StudyProgressPopup v-model="studyProgressVisible" />
<div class="absolute top-[7.5rem] left-48 pointer-events-none">
<span class="text-5xl italic text-white">{{ totalStudyPercent }}%</span>
</div>
<div <div
class="absolute top-4 left-4 flex items-center text-white text-xl bg-black/10 px-4 py-2 rounded-xl" class="absolute top-4 left-4 flex items-center text-white text-xl bg-black/10 px-4 py-2 rounded-xl"
> >
@@ -451,7 +492,10 @@ onMounted(() => {
@click="handleClickOutside" @click="handleClickOutside"
> >
<!-- 标题区域 --> <!-- 标题区域 -->
<div class="text-center mt-10 mb-8"> <div
class="text-center mt-10 mb-8 transition-opacity duration-300"
:class="showChildrenNodeId !== null ? 'opacity-35' : 'opacity-100'"
>
<h2 <h2
class="text-5xl font-bold mb-3 text-[#7bfffd] tracking-wider drop-shadow-lg" class="text-5xl font-bold mb-3 text-[#7bfffd] tracking-wider drop-shadow-lg"
> >
@@ -485,6 +529,10 @@ onMounted(() => {
stroke="#7bfffd" stroke="#7bfffd"
stroke-width="5" stroke-width="5"
stroke-linecap="round" stroke-linecap="round"
class="transition-opacity duration-300"
:class="
showChildrenNodeId !== null ? 'opacity-35' : 'opacity-100'
"
/> />
<!-- 节点2 -> 节点3 --> <!-- 节点2 -> 节点3 -->
<line <line
@@ -495,6 +543,10 @@ onMounted(() => {
stroke="#7bfffd" stroke="#7bfffd"
stroke-width="5" stroke-width="5"
stroke-linecap="round" stroke-linecap="round"
class="transition-opacity duration-300"
:class="
showChildrenNodeId !== null ? 'opacity-35' : 'opacity-100'
"
/> />
<!-- 节点3 -> 右侧装饰线 --> <!-- 节点3 -> 右侧装饰线 -->
<line <line
@@ -505,6 +557,10 @@ onMounted(() => {
stroke="#7bfffd" stroke="#7bfffd"
stroke-width="5" stroke-width="5"
stroke-linecap="round" stroke-linecap="round"
class="transition-opacity duration-300"
:class="
showChildrenNodeId !== null ? 'opacity-35' : 'opacity-100'
"
/> />
</svg> </svg>
@@ -512,7 +568,12 @@ onMounted(() => {
<div <div
v-for="node in nodes" v-for="node in nodes"
:key="node.id" :key="node.id"
class="absolute transform -translate-x-1/2 -translate-y-1/2" class="absolute transform -translate-x-1/2 -translate-y-1/2 transition-opacity duration-300"
:class="
showChildrenNodeId !== null && showChildrenNodeId !== node.id
? 'opacity-35'
: 'opacity-100'
"
:style="{ left: node.x + '%', top: node.y + '%' }" :style="{ left: node.x + '%', top: node.y + '%' }"
@click="handleNodeClick(node, $event)" @click="handleNodeClick(node, $event)"
> >
@@ -547,10 +608,10 @@ onMounted(() => {
<Transition name="child-node-pop"> <Transition name="child-node-pop">
<div <div
v-if="node.hasChildren && showChildrenNodeId === node.id" v-if="node.hasChildren && showChildrenNodeId === node.id"
class="child-node-buttons btn absolute -top-48 left-1/2 z-20 flex -translate-x-1/2 flex-col items-center" class="child-node-buttons absolute -top-48 left-1/2 z-20 flex -translate-x-1/2 flex-col items-center"
> >
<div <div
class="relative flex rounded-[30px] bg-[rgba(8,40,58,0.82)] px-3 py-2 shadow-[0_0_0_1px_rgba(123,255,253,0.28),0_0_20px_rgba(123,255,253,0.28)] after:absolute after:left-1/2 after:top-[calc(100%-1px)] after:h-0 after:w-0 after:-translate-x-1/2 after:border-x-[12px] after:border-t-[14px] after:border-x-transparent after:border-t-[rgba(8,40,58,0.82)]" class="relative flex rounded-[30px] bg-[rgba(78,195,217,1)] backdrop-filter px-3 py-2 shadow-[0_0_0_2px_rgba(250,250,250,1),0_0_20px_rgba(121,252,249,1)] after:absolute after:left-1/2 after:top-[calc(100%-9px)] after:h-5 after:w-5 after:-translate-x-1/2 after:rotate-45 after:border-r-2 after:border-b-2 after:border-white after:bg-[rgba(78,195,217,1)]"
> >
<div <div
v-for="childNode in node.children" v-for="childNode in node.children"
@@ -1,13 +1,29 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue' import { computed } from 'vue'
import { getBackgroundImage } from '@/utils' import { getBackgroundImage } from '@/utils'
import { useBasicElementsStore, useFilmStore, useInterviewStore, useVlogStore } from '@/stores' import {
import { AnchoredProgressBar, ImgButton, type ProgressAnchor } from '@/components' useBasicElementsStore,
import { studyModuleAnchors, type StudyProgressModuleKey } from './studyProgressConfig' useFilmStore,
import { getProductionModuleProgress } from './studyProgressUtils' useInterviewStore,
useVlogStore,
} from '@/stores'
import {
AnchoredProgressBar,
ImgButton,
type ProgressAnchor,
} from '@/components'
import {
studyModuleAnchors,
type StudyProgressModuleKey,
} from './studyProgressConfig'
import {
getBasicElementsProgress,
getOverallStudyProgress,
getProductionModuleProgress,
} from './studyProgressUtils'
defineOptions({ defineOptions({
name: 'StudyProgressPopup' name: 'StudyProgressPopup',
}) })
type StudyProgressItem = { type StudyProgressItem = {
@@ -27,42 +43,68 @@ const rowPositions = {
basic: { height: 168 }, basic: { height: 168 },
vlog: { height: 148 }, vlog: { height: 148 },
interview: { height: 146 }, interview: { height: 146 },
film: { height: 146 } film: { height: 146 },
} as const } as const
const labelTextShadow = const labelTextShadow =
'0 0 5px rgba(255,255,255,0.95), 0 0 10px rgba(54,111,255,0.75), 0 3px 0 rgba(0, 48, 151, 0.75)' '0 0 5px rgba(255,255,255,0.95), 0 0 10px rgba(54,111,255,0.75), 0 3px 0 rgba(0, 48, 151, 0.75)'
const totalTextShadow = '4px 5px 0 rgba(40, 130, 253, 0.75)' const totalTextShadow = '4px 5px 0 rgba(40, 130, 253, 0.75)'
const childTextShadow = '0 0 6px rgba(54,250,255,0.9), 0 2px 0 rgba(0, 68, 151, 0.7)' const childTextShadow =
'0 0 6px rgba(54,250,255,0.9), 0 2px 0 rgba(0, 68, 151, 0.7)'
const basicProgress = computed(() => const basicProgress = computed(() =>
basicElements.isBasicElementsUnlock ? 100 : basicElements.basicElementsUnlockProgress getBasicElementsProgress(
basicElements.basicElementsUnlockProgress,
basicElements.isBasicElementsUnlock,
basicElements.learningVideosWatched,
),
)
const vlogProgress = computed(() =>
getProductionModuleProgress('vlog', vlog.viewed, vlog.workVideosWatched),
)
const interviewProgress = computed(() =>
getProductionModuleProgress(
'interview',
interview.viewed,
interview.workVideosWatched,
),
)
const filmViewed = computed(() => ({
...film.viewed,
FilmOnsiteShooting: film.allOnsiteScenesViewed,
FilmEditing: film.allEditingVideosWatched,
}))
const filmProgress = computed(() =>
getProductionModuleProgress('film', filmViewed.value, film.workVideosWatched),
)
const totalProgress = computed(() =>
getOverallStudyProgress([
basicProgress.value,
vlogProgress.value,
interviewProgress.value,
filmProgress.value,
]),
) )
const totalProgress = computed(() => {
const total =
basicProgress.value + vlog.progressPercent + interview.progressPercent + film.progressPercent
return Math.round(total / 4)
})
const progressItems = computed<StudyProgressItem[]>(() => [ const progressItems = computed<StudyProgressItem[]>(() => [
{ {
key: 'basic', key: 'basic',
progress: basicProgress.value, progress: basicProgress.value,
anchors: studyModuleAnchors.basic anchors: studyModuleAnchors.basic,
}, },
{ {
key: 'vlog', key: 'vlog',
progress: getProductionModuleProgress('vlog', vlog.viewed), progress: vlogProgress.value,
anchors: studyModuleAnchors.vlog anchors: studyModuleAnchors.vlog,
}, },
{ {
key: 'interview', key: 'interview',
progress: getProductionModuleProgress('interview', interview.viewed), progress: interviewProgress.value,
anchors: studyModuleAnchors.interview anchors: studyModuleAnchors.interview,
}, },
{ {
key: 'film', key: 'film',
progress: getProductionModuleProgress('film', film.viewed), progress: filmProgress.value,
anchors: studyModuleAnchors.film anchors: studyModuleAnchors.film,
} },
]) ])
const handleClose = () => { const handleClose = () => {
@@ -95,10 +137,14 @@ const handleClose = () => {
:style="{ textShadow: totalTextShadow }" :style="{ textShadow: totalTextShadow }"
> >
<span class="text-[clamp(36px,4.5vw,72px)] leading-none">总进度</span> <span class="text-[clamp(36px,4.5vw,72px)] leading-none">总进度</span>
<span class="text-[clamp(36px,4.5vw,72px)] leading-none">{{ totalProgress }}%</span> <span class="text-[clamp(36px,4.5vw,72px)] leading-none">
{{ totalProgress }}%
</span>
</div> </div>
<div class="w-[1580px] mx-auto mt-[215px] flex flex-col pr-16 pl-64 gap-2"> <div
class="w-[1580px] mx-auto mt-[215px] flex flex-col pr-16 pl-64 gap-2"
>
<AnchoredProgressBar <AnchoredProgressBar
v-for="item in progressItems" v-for="item in progressItems"
:key="item.key" :key="item.key"
+82 -21
View File
@@ -1,41 +1,102 @@
import { moduleStepMap } from '@/config/moduleStepMap' import { moduleStepMap } from '@/config/moduleStepMap'
type ProductionModuleKey = 'vlog' | 'interview' | 'film' export type ProductionModuleKey = 'vlog' | 'interview' | 'film'
type StepKey = 1 | 2 | 3 type StepKey = 1 | 2 | 3
const countViewedRoutes = (routes: readonly string[], viewed: Record<string, boolean>) => { const countViewedRoutes = (
routes: readonly string[],
viewed: Record<string, boolean>,
) => {
if (routes.length === 0) return 0 if (routes.length === 0) return 0
return routes.filter(routeName => viewed[routeName]).length return routes.filter((routeName) => viewed[routeName]).length
} }
const getStepProgress = (routes: readonly string[], viewed: Record<string, boolean>) => { const getStepProgress = (
routes: readonly string[],
viewed: Record<string, boolean>,
) => {
if (routes.length === 0) return 0 if (routes.length === 0) return 0
return countViewedRoutes(routes, viewed) / routes.length return countViewedRoutes(routes, viewed) / routes.length
} }
const interpolate = (start: number, end: number, progress: number) => const WORK_COMPLETION_KEYS = ['case', 'profession', 'idea'] as const
start + (end - start) * progress
const getWorkProgress = (workVideosWatched: Record<string, boolean>) =>
countViewedRoutes(WORK_COMPLETION_KEYS, workVideosWatched) /
WORK_COMPLETION_KEYS.length
export const getProductionModuleProgress = ( export const getProductionModuleProgress = (
moduleKey: ProductionModuleKey, moduleKey: ProductionModuleKey,
viewed: Record<string, boolean> viewed: Record<string, boolean>,
workVideosWatched: Record<string, boolean> = {},
) => { ) => {
const stepRoutes = moduleStepMap[moduleKey] const stepRoutes = moduleStepMap[moduleKey]
const hasAnyViewed = Object.values(stepRoutes).some(routes => const taskProgress = getWorkProgress(workVideosWatched) * 20
routes.some(routeName => viewed[routeName]) const stepProgress = (step: StepKey) =>
) getStepProgress(stepRoutes[step], viewed)
const step1Progress = stepProgress(1) * 22
const step2Progress = stepProgress(2) * 22
const step3Progress = stepProgress(3) * 21
const progress = taskProgress + step1Progress + step2Progress + step3Progress
if (!hasAnyViewed) return 0 if (
progress >= 85 &&
step1Progress === 22 &&
step2Progress === 22 &&
step3Progress === 21
) {
return 100
}
const stepProgress = (step: StepKey) => getStepProgress(stepRoutes[step], viewed) return Math.round(progress)
const step1Progress = stepProgress(1) }
const step2Progress = stepProgress(2)
const step3Progress = stepProgress(3) const BASIC_ELEMENTS_VIDEO_KEYS = [
'basic-composition:step1',
if (step1Progress < 1) return Math.round(interpolate(20, 42, step1Progress)) 'basic-composition:step2',
if (step2Progress < 1) return Math.round(interpolate(42, 64, step2Progress)) 'basic-light:step2',
if (step3Progress < 1) return Math.round(interpolate(64, 85, step3Progress)) 'basic-light:step3',
'basic-light:step4',
return 100 'basic-camera-movement:step2',
'basic-camera-movement:step3',
'basic-shooting-skills:step2',
'basic-shooting-skills:step3',
'basic-shooting-skills:step4',
'basic-shooting-skills:step5',
'basic-shooting-skills:step6',
'basic-shooting-skills:step7',
'basic-short-video-editing-skills:step2',
'basic-short-video-editing-skills:step3',
'basic-short-video-editing-skills:step4',
'basic-short-video-editing-skills:step5',
'basic-short-video-editing-skills:step6',
'basic-short-video-editing-skills:step7',
'basic-short-video-editing-skills:step8',
'basic-short-video-editing-skills:step9',
'basic-short-video-editing-skills:step10',
'basic-short-video-editing-skills:step11',
] as const
export const getBasicElementsProgress = (
unlockProgress: number,
isUnlocked: boolean,
learningVideosWatched: Record<string, boolean> = {},
) => {
if (isUnlocked) return 100
const watchedProgress =
(countViewedRoutes(BASIC_ELEMENTS_VIDEO_KEYS, learningVideosWatched) /
BASIC_ELEMENTS_VIDEO_KEYS.length) *
100
return Math.round(
Math.max(0, Math.min(100, Math.max(unlockProgress, watchedProgress))),
)
}
export const getOverallStudyProgress = (progresses: readonly number[]) => {
if (progresses.length === 0) return 0
const total = progresses.reduce((sum, progress) => sum + progress, 0)
return Math.round(total / progresses.length)
} }
+152 -39
View File
@@ -1,46 +1,121 @@
<script lang="ts" setup> <script lang="ts" setup>
import { nextTick, ref } from 'vue' import { computed, nextTick, ref } from 'vue'
import { useRouter } from 'vue-router' import { onBeforeRouteLeave, useRouter } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue' import ImgButton from '@/components/Button/ImgButton.vue'
import QuestionQuiz, {
type Question,
} from '@/components/QuestionQuiz/QuestionQuiz.vue'
import StepNavigation from '@/components/Guide/StepNavigation.vue' import StepNavigation from '@/components/Guide/StepNavigation.vue'
import { IMAGE_PATHS } from '@/utils' import UnfinishedLearningPrompt from '@/components/UnfinishedLearningPrompt'
import { IMAGE_PATHS, getPublicPath } from '@/utils'
import { Video } from '@/components' import { Video } from '@/components'
import { useInterviewStore } from '@/stores'
const router = useRouter() const router = useRouter()
const interview = useInterviewStore()
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 navigationSteps = [ const navigationSteps = [
{ {
id: 'step1', id: 'interview-editing-basic-flow',
label: '后期制作', label: '后期制作',
content: '声画剪辑流程', content: '声画剪辑流程',
videoUrl: getPublicPath('video/interview/editing/ftmk_shjjlc_batch.mp4'),
type: 'post' as const, type: 'post' as const,
}, },
{ {
id: 'step2', id: 'interview-editing-j-cut-l-cut',
label: '后期制作', label: '后期制作',
content: 'J-cut/L-cut运用', content: 'J-cut/L-cut运用',
videoUrl: getPublicPath('video/interview/editing/ftmk_jlcutyy_batch.mp4'),
type: 'post' as const, type: 'post' as const,
}, },
{ {
id: 'step3', id: 'interview-editing-rhythm',
label: '后期制作', label: '后期制作',
content: '【Q3】节奏剪辑技巧', content: '节奏剪辑技巧',
videoUrl: getPublicPath('video/interview/editing/ftmk_jzjjjq_batch.mp4'),
type: 'post' as const, type: 'post' as const,
}, },
] ]
const currentVideoIndex = ref(0) const currentVideoIndex = ref(0)
const playing = ref(false) const playing = ref(false)
const showQuiz = ref(false)
const questions = ref<Question[]>([])
// 只给“节奏剪辑技巧”(index 2)出题
const getQuestionsForVideo = (): Question[] => {
return [
{
id: 1,
title: '',
description: '【多选题】以下哪些方式,可以帮助影片产生节奏变化?',
options: [
{ id: 'a', label: '镜头切换快慢变化' },
{ id: 'b', label: '音乐强弱变化' },
{ id: 'c', label: '所有镜头长度完全一致' },
{ id: 'd', label: '景别远近变化' },
{ id: 'e', label: '字幕出现节奏变化' },
],
correctOptionId: '',
correctOptionIds: ['a', 'b', 'd', 'e'],
isMultiple: true,
explanation: '节奏来自变化与对比,如果所有镜头都一样长,反而容易单调。',
},
]
}
const showStepNavigation = ref(false) const showStepNavigation = ref(false)
const showUnfinishedPrompt = ref(false)
const pendingLeavePath = ref<string | null>(null)
const allowRouteLeave = ref(false)
const currentVideo = computed(
() => navigationSteps[currentVideoIndex.value].videoUrl,
)
const completedNavigationSteps = computed(() =>
navigationSteps.map(
(step) => interview.professionVideosWatched[step.id] === true,
),
)
const hasUnfinishedLearningVideos = computed(() =>
completedNavigationSteps.value.some((completed) => !completed),
)
const markCurrentVideoWatched = () => {
const videoKey = navigationSteps[currentVideoIndex.value]?.id
if (videoKey) interview.markEditingVideoWatched(videoKey)
}
const handleQuestionsComplete = () => {
markCurrentVideoWatched()
showQuiz.value = false
if (currentVideoIndex.value >= navigationSteps.length - 1) {
playing.value = false
return
}
currentVideoIndex.value++
nextTick(() => {
playing.value = true
})
}
const playNextVideo = () => { const playNextVideo = () => {
if (currentVideoIndex.value >= videoList.length - 1) { // “节奏剪辑技巧”(index 2)播完后先出题,答完才算完成并切下一个
if (currentVideoIndex.value === 2) {
playing.value = false
questions.value = getQuestionsForVideo()
showQuiz.value = true
return
}
markCurrentVideoWatched()
if (currentVideoIndex.value >= navigationSteps.length - 1) {
playing.value = false playing.value = false
return return
} }
@@ -52,8 +127,8 @@ const playNextVideo = () => {
} }
const handleStepClick = (index: number) => { const handleStepClick = (index: number) => {
showQuiz.value = false
currentVideoIndex.value = index currentVideoIndex.value = index
// showStepNavigation.value = false
nextTick(() => { nextTick(() => {
playing.value = true playing.value = true
}) })
@@ -62,48 +137,86 @@ const handleStepClick = (index: number) => {
const handleClose = () => { const handleClose = () => {
router.back() router.back()
} }
const handleContinueLearning = () => {
showUnfinishedPrompt.value = false
pendingLeavePath.value = null
}
const handleConfirmLeave = () => {
if (!pendingLeavePath.value) return
allowRouteLeave.value = true
showUnfinishedPrompt.value = false
router.push(pendingLeavePath.value)
}
onBeforeRouteLeave((to) => {
if (allowRouteLeave.value || !hasUnfinishedLearningVideos.value) return true
pendingLeavePath.value = to.fullPath
showUnfinishedPrompt.value = true
return false
})
</script> </script>
<template> <template>
<div class="relative w-full h-full overflow-hidden bg-black"> <div class="relative w-full h-full overflow-hidden bg-black">
<Video <Video
:src="videoList[currentVideoIndex]" :src="currentVideo"
v-model="playing" v-model="playing"
:muted="false" :muted="false"
:volume="1" :volume="1"
:controls="true"
:hideNativeControls="true"
@ended="playNextVideo" @ended="playNextVideo"
/>
<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 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-blue-500/80 text-white transition-all duration-200 hover:bg-blue-500"
title="步骤导航"
@click="showStepNavigation = true"
> >
<svg <template #overlay>
width="20" <ImgButton
height="20" class="absolute right-0 top-0 z-10 pointer-events-auto"
viewBox="0 0 20 20" :image="IMAGE_PATHS.COMPONENTS.BACK"
fill="none" @click="handleClose"
>
<path
d="M3 4H17M3 8H17M3 12H17M3 16H17"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/> />
</svg> <button
</button> class="absolute right-4 top-1/2 z-10 -translate-y-1/2 flex h-12 w-12 items-center justify-center rounded-full bg-blue-500/80 pointer-events-auto text-white transition-all duration-200 hover:bg-blue-500"
title="步骤导航"
@click="showStepNavigation = true"
>
<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>
<QuestionQuiz
v-if="showQuiz"
:show-question-number="false"
:questions="questions"
@complete="handleQuestionsComplete"
/>
</template>
</Video>
<StepNavigation <StepNavigation
:visible="showStepNavigation" :visible="showStepNavigation"
:background-image="''" :background-image="''"
:steps="navigationSteps" :steps="navigationSteps"
:current-step-index="currentVideoIndex" :current-step-index="currentVideoIndex"
:completed-steps="completedNavigationSteps"
@step-click="handleStepClick" @step-click="handleStepClick"
@close="showStepNavigation = false" @close="showStepNavigation = false"
/> />
<UnfinishedLearningPrompt
:visible="showUnfinishedPrompt"
@continue="handleContinueLearning"
@leave="handleConfirmLeave"
/>
</div> </div>
</template> </template>
+4 -4
View File
@@ -92,8 +92,8 @@ const menuItems = ref([
disabled: false, disabled: false,
}, },
{ {
label: '音频', label: '音效',
routeName: 'InterviewAudio', routeName: 'InterviewSoundEffect',
parentValue: 3, parentValue: 3,
disabled: false, disabled: false,
}, },
@@ -188,8 +188,8 @@ const handleItemClick = (item: { routeName: string; disabled: boolean }) => {
class="w-10" class="w-10"
:src=" :src="
isViewed(item.routeName) isViewed(item.routeName)
? '/src/assets/images/midterm/完成.png' ? getStaticImage('midterm/完成.png')
: '/src/assets/images/midterm/未完成.png' : getStaticImage('midterm/未完成.png')
" "
alt="" alt=""
/> />
+66 -20
View File
@@ -5,7 +5,7 @@ import { ImgButton } from '@/components/Button'
import { computed, ref, watch, type Ref } from 'vue' import { computed, ref, watch, type Ref } from 'vue'
import { ProgressBar, Overlay } from '@/components' import { ProgressBar, Overlay } from '@/components'
import { GuideSteps, type GuideStep } from '@/components/Guide' import { GuideSteps, GuideStepsItem, type GuideStep } from '@/components/Guide'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useInterviewStore } from '@/stores' import { useInterviewStore } from '@/stores'
import InterviewMenu from './components/menu.vue' import InterviewMenu from './components/menu.vue'
@@ -14,14 +14,26 @@ const route = useRoute()
const router = useRouter() const router = useRouter()
const interview = useInterviewStore() const interview = useInterviewStore()
const showReturnGuide = ref(route.query.guide === 'return' && interview.isGuideInterviewReturn) const showReturnGuide = ref(
route.query.guide === 'return' && interview.isGuideInterviewReturn,
)
// 实战操作台一次性引导(currentStep === 4 时解锁)
const practiceGuideKey = 'interview-practice-guide'
const hasShownPracticeGuide = computed(
() => interview.guideShown[practiceGuideKey] === true,
)
const showPracticeGuide = computed(
() => currentStep.value === 4 && !hasShownPracticeGuide.value,
)
const returnSteps: GuideStep[] = [ const returnSteps: GuideStep[] = [
{ {
id: 'return-step1', id: 'return-step1',
content: '接下来,我们将进入真实项目流程,体验一条人物访谈作品是如何一步步完成的。', content:
audioSrc: getPublicPath('audio/py_3_007.MP3') '接下来,我们将进入真实项目流程,体验一条人物访谈作品是如何一步步完成的。',
} audioSrc: getPublicPath('audio/py_3_007.MP3'),
},
] ]
const handleReturnGuideComplete = () => { const handleReturnGuideComplete = () => {
@@ -30,6 +42,10 @@ const handleReturnGuideComplete = () => {
router.replace({ name: 'Interview' }) router.replace({ name: 'Interview' })
} }
const handlePracticeGuideComplete = () => {
interview.markGuideShown(practiceGuideKey)
}
const stageGuideMap = { const stageGuideMap = {
2: { 2: {
flagKey: 'isGuideInterviewStage2', flagKey: 'isGuideInterviewStage2',
@@ -38,14 +54,15 @@ const stageGuideMap = {
{ {
id: 'stage2-step1', id: 'stage2-step1',
content: '前期准备阶段,我们已经完成了主题确认、脚本结构与镜头规划。', content: '前期准备阶段,我们已经完成了主题确认、脚本结构与镜头规划。',
audioSrc: getPublicPath('audio/py_3_010.MP3') audioSrc: getPublicPath('audio/py_3_010.MP3'),
}, },
{ {
id: 'stage2-step2', id: 'stage2-step2',
content: '进入中期拍摄后就要按照既定方案,把后期需要用的素材尽可能拍完整。', content:
audioSrc: getPublicPath('audio/py_3_011.MP3') '进入中期拍摄后就要按照既定方案,把后期需要用的素材尽可能拍完整。',
} audioSrc: getPublicPath('audio/py_3_011.MP3'),
] },
],
}, },
3: { 3: {
flagKey: 'isGuideInterviewStage3', flagKey: 'isGuideInterviewStage3',
@@ -54,17 +71,17 @@ const stageGuideMap = {
{ {
id: 'stage3-step1', id: 'stage3-step1',
content: '拍摄完成啦!现在我们正式进入剪辑环节。', content: '拍摄完成啦!现在我们正式进入剪辑环节。',
audioSrc: getPublicPath('audio/py_3_012.MP3') audioSrc: getPublicPath('audio/py_3_012.MP3'),
} },
] ],
} },
} as const } as const
type StageKey = keyof typeof stageGuideMap type StageKey = keyof typeof stageGuideMap
const stageGuideStep = ref<StageKey | null>(null) const stageGuideStep = ref<StageKey | null>(null)
const stageGuideSteps = computed(() => const stageGuideSteps = computed(() =>
stageGuideStep.value ? stageGuideMap[stageGuideStep.value].steps : [] stageGuideStep.value ? stageGuideMap[stageGuideStep.value].steps : [],
) as Ref<GuideStep[]> ) as Ref<GuideStep[]>
const triggerStageGuide = (step: number) => { const triggerStageGuide = (step: number) => {
@@ -84,7 +101,7 @@ const handleStageGuideComplete = () => {
const visible = computed({ const visible = computed({
get: () => interview.interviewMenuVisible, get: () => interview.interviewMenuVisible,
set: value => interview.setInterviewMenuVisible(value) set: (value) => interview.setInterviewMenuVisible(value),
}) })
const currentStep = computed(() => interview.InterviewUnlockProgress) const currentStep = computed(() => interview.InterviewUnlockProgress)
@@ -111,6 +128,16 @@ const handleClose = () => {
router.push({ name: 'Action' }) router.push({ name: 'Action' })
} }
watch(
showPracticeGuide,
(val) => {
if (val) {
interview.markGuideShown(practiceGuideKey)
}
},
{ immediate: true },
)
watch( watch(
[visible, currentStep], [visible, currentStep],
([isVisible, step]) => { ([isVisible, step]) => {
@@ -118,7 +145,7 @@ watch(
triggerStageGuide(step) triggerStageGuide(step)
} }
}, },
{ immediate: true } { immediate: true },
) )
</script> </script>
@@ -161,22 +188,41 @@ watch(
<!-- 任务取景按钮 --> <!-- 任务取景按钮 -->
<div <div
class="absolute left-24 top-24" class="absolute left-24 top-24"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.TASK_CAMERA) }" :style="{
backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.TASK_CAMERA),
}"
> >
<ImgButton <ImgButton
:image="currentStep === 4 ? IMAGE_PATHS.VLOG.TASK_BTN_OK : IMAGE_PATHS.VLOG.TASK_BTN" :image="
currentStep > 0
? IMAGE_PATHS.VLOG.TASK_BTN_OK
: IMAGE_PATHS.VLOG.TASK_BTN
"
@click="handleWorkClick" @click="handleWorkClick"
/> />
</div> </div>
<GuideSteps
:visible="showPracticeGuide"
highlight-selector=".right-section"
position-offset-y="50%"
@complete="handlePracticeGuideComplete"
>
<GuideStepsItem>点击【实战操作台】进入实战操作!</GuideStepsItem>
</GuideSteps>
<!-- 引导任务 --> <!-- 引导任务 -->
<ImgButton <ImgButton
v-if="currentStep !== 4"
v-show="!showReturnGuide" v-show="!showReturnGuide"
class="absolute right-24 bottom-24" class="absolute right-24 bottom-24"
:image="IMAGE_PATHS.VLOG.TASK_PERSON" :image="IMAGE_PATHS.VLOG.TASK_PERSON"
/> />
<Overlay content-class="w-full h-full" :visible="visible && currentStep < 4"> <Overlay
content-class="w-full h-full"
:visible="visible && currentStep < 4"
>
<!-- 关闭按钮 --> <!-- 关闭按钮 -->
<ImgButton <ImgButton
class="absolute right-0 top-0 z-20" class="absolute right-0 top-0 z-20"
@@ -1,13 +1,44 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, nextTick, ref } from 'vue' import { computed, nextTick, ref } from 'vue'
import { useRouter } from 'vue-router' import { onBeforeRouteLeave, useRouter } from 'vue-router'
import { ImgButton, Video } from '@/components' import { ImgButton, Video } from '@/components'
import QuestionQuiz, {
type Question,
} from '@/components/QuestionQuiz/QuestionQuiz.vue'
import StepNavigation from '@/components/Guide/StepNavigation.vue' import StepNavigation from '@/components/Guide/StepNavigation.vue'
import UnfinishedLearningPrompt from '@/components/UnfinishedLearningPrompt'
import { IMAGE_PATHS, getPublicPath, getStaticImage } from '@/utils' import { IMAGE_PATHS, getPublicPath, getStaticImage } from '@/utils'
import { useInterviewStore } from '@/stores'
const router = useRouter() const router = useRouter()
const interview = useInterviewStore()
const playing = ref(false) const playing = ref(false)
const showQuiz = ref(false)
const questions = ref<Question[]>([])
// 只给“移动镜头的作用”(index 2)出题
const getQuestionsForVideo = (): Question[] => {
return [
{
id: 1,
title: '',
description: '【单选题】下面哪一种拍摄情境,最适合使用滑轨移动镜头?',
options: [
{ id: 'a', label: '人物正对镜头说话,背景简单,镜头静止' },
{ id: 'b', label: '人物站在窗边,前景有栏杆或墙体,镜头缓慢横向移动' },
{ id: 'c', label: '人物快速奔跑,摄影师跟着移动拍摄' },
],
correctOptionId: 'b',
explanation:
'滑轨镜头的作用在于稳定、克制地交代空间关系。当前景—人物—背景同时存在时,缓慢的横向移动,会让画面产生层次和叙事感。',
},
]
}
const showStepNavigation = ref(false) const showStepNavigation = ref(false)
const showUnfinishedPrompt = ref(false)
const pendingLeavePath = ref<string | null>(null)
const allowRouteLeave = ref(false)
const currentVideoIndex = ref(0) const currentVideoIndex = ref(0)
const navigationSteps = ref([ const navigationSteps = ref([
@@ -29,16 +60,41 @@ const navigationSteps = ref([
id: 'q2-moving-shot', id: 'q2-moving-shot',
label: '中期拍摄', label: '中期拍摄',
content: '移动镜头的作用', content: '移动镜头的作用',
videoUrl: getPublicPath('video/interview/on-site/ftmk_ydjtdzy_batch.mp4'),
type: 'shooting' as const, type: 'shooting' as const,
}, },
{ {
id: 'q2-close-up', id: 'q2-close-up',
label: '中期拍摄', label: '中期拍摄',
content: '特写镜头的作用', content: '特写镜头的作用',
videoUrl: getPublicPath('video/interview/on-site/ftmk_txjtdzy_batch.mp4'),
type: 'shooting' as const, type: 'shooting' as const,
}, },
]) ])
const onsiteVideoKeys = [
'interview-onsite-shoot-material',
'interview-onsite-required-shots',
'interview-onsite-moving-shot',
'interview-onsite-close-up',
] as const
const completedNavigationSteps = computed(() =>
navigationSteps.value.map(
(_, index) =>
interview.professionVideosWatched[onsiteVideoKeys[index]] === true,
),
)
const hasUnfinishedLearningVideos = computed(() =>
completedNavigationSteps.value.some((completed) => !completed),
)
const markCurrentOnsiteVideoWatched = () => {
const videoKey = onsiteVideoKeys[currentVideoIndex.value]
if (videoKey) interview.markOnsiteVideoWatched(videoKey)
}
const currentVideo = computed( const currentVideo = computed(
() => navigationSteps.value[currentVideoIndex.value].videoUrl, () => navigationSteps.value[currentVideoIndex.value].videoUrl,
) )
@@ -48,8 +104,8 @@ const back = () => {
} }
const handleStepClick = (index: number) => { const handleStepClick = (index: number) => {
showQuiz.value = false
currentVideoIndex.value = index currentVideoIndex.value = index
// showStepNavigation.value = false
nextTick(() => { nextTick(() => {
playing.value = true playing.value = true
}) })
@@ -59,7 +115,26 @@ const handleStepNavigationClose = () => {
showStepNavigation.value = false showStepNavigation.value = false
} }
const handleQuestionsComplete = () => {
markCurrentOnsiteVideoWatched()
showQuiz.value = false
currentVideoIndex.value++
nextTick(() => {
playing.value = true
})
}
const handleEnded = () => { const handleEnded = () => {
// “移动镜头的作用”(index 2)播完后先出题,答完再算完成并切下一个
if (currentVideoIndex.value === 2) {
playing.value = false
questions.value = getQuestionsForVideo()
showQuiz.value = true
return
}
markCurrentOnsiteVideoWatched()
if (currentVideoIndex.value >= navigationSteps.value.length - 1) { if (currentVideoIndex.value >= navigationSteps.value.length - 1) {
playing.value = false playing.value = false
return return
@@ -70,6 +145,25 @@ const handleEnded = () => {
playing.value = true playing.value = true
}) })
} }
const handleContinueLearning = () => {
showUnfinishedPrompt.value = false
pendingLeavePath.value = null
}
const handleConfirmLeave = () => {
if (!pendingLeavePath.value) return
allowRouteLeave.value = true
showUnfinishedPrompt.value = false
router.push(pendingLeavePath.value)
}
onBeforeRouteLeave((to) => {
if (allowRouteLeave.value || !hasUnfinishedLearningVideos.value) return true
pendingLeavePath.value = to.fullPath
showUnfinishedPrompt.value = true
return false
})
</script> </script>
<template> <template>
@@ -108,6 +202,12 @@ const handleEnded = () => {
/> />
</svg> </svg>
</button> </button>
<QuestionQuiz
v-if="showQuiz"
:show-question-number="false"
:questions="questions"
@complete="handleQuestionsComplete"
/>
</template> </template>
</Video> </Video>
@@ -116,8 +216,15 @@ const handleEnded = () => {
:background-image="getStaticImage('work/vlog背景全屏待定')" :background-image="getStaticImage('work/vlog背景全屏待定')"
:steps="navigationSteps" :steps="navigationSteps"
:current-step-index="currentVideoIndex" :current-step-index="currentVideoIndex"
:completed-steps="completedNavigationSteps"
@step-click="handleStepClick" @step-click="handleStepClick"
@close="handleStepNavigationClose" @close="handleStepNavigationClose"
/> />
<UnfinishedLearningPrompt
:visible="showUnfinishedPrompt"
@continue="handleContinueLearning"
@leave="handleConfirmLeave"
/>
</div> </div>
</template> </template>
@@ -1,23 +1,15 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref, computed } from 'vue' import { ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { GuideSteps, GuideStepsItem } from '@/components/Guide' import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { Overlay } from '@/components/Overlay'
import { ImgButton } from '@/components/Button' import { ImgButton } from '@/components/Button'
import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils' import { getBackgroundImage, getPublicPath } from '@/utils'
import shootingtitle from '@/assets/images/shooting/标题-实地取景.png' import shootingtitle from '@/assets/images/shooting/标题-实地取景.png'
import videoSrc from '/video/xinshou_jiuzhonggoutufa.mp4' import videoSrc from '/video/xinshou_jiuzhonggoutufa.mp4'
import KnowledgeCard from '@/components/KnowledgeCard/InterviewKnowledgeCard.vue' import KnowledgeCard from '@/components/KnowledgeCard/InterviewKnowledgeCard.vue'
import CompositionGuide from '@/components/CompositionGuide.vue'
import { useInterviewStore } from '@/stores/modules/interview' import { useInterviewStore } from '@/stores/modules/interview'
type Btn = {
key: string
name: string
btnImage: string
images: string[]
desc: string
}
const router = useRouter() const router = useRouter()
const interviewStore = useInterviewStore() const interviewStore = useInterviewStore()
const guideKey = 'interview-shooting-intro' const guideKey = 'interview-shooting-intro'
@@ -29,17 +21,7 @@ if (showIntroGuide.value) {
const showModal = ref(false) const showModal = ref(false)
const showModal2 = ref(false) const btnList = ref([
const showModal3 = ref(false)
const currentItem = ref<Btn>({
key: '',
name: '',
btnImage: '',
images: [],
desc: '',
})
const btnList = ref<Btn[]>([
{ {
key: '1', key: '1',
name: '前景构图', name: '前景构图',
@@ -63,30 +45,13 @@ const btnList = ref<Btn[]>([
}, },
]) ])
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 = () => { const handleClickA = () => {
// 跳转设备准备
router.push({ name: 'InterviewFilmingLocationA' }) router.push({ name: 'InterviewFilmingLocationA' })
} }
const handleClickB = () => { const handleClickB = () => {
// 跳转设备准备
router.push({ name: 'InterviewFilmingLocationB' }) router.push({ name: 'InterviewFilmingLocationB' })
} }
const back = () => { const back = () => {
// 跳转设备准备
router.back() router.back()
} }
</script> </script>
@@ -99,13 +64,13 @@ const back = () => {
backgroundImage: getBackgroundImage('interview/shooting/实地取景底图'), backgroundImage: getBackgroundImage('interview/shooting/实地取景底图'),
}" }"
> >
<div class="shooting-title"> <div class="absolute top-0 left-0">
<img <img
:src="shootingtitle" :src="shootingtitle"
alt="" alt=""
/> />
</div> </div>
<div class="shooting-return"> <div class="absolute top-0 right-0">
<ImgButton <ImgButton
image="shooting/返回.png" image="shooting/返回.png"
@click="back" @click="back"
@@ -123,7 +88,7 @@ const back = () => {
@click="handleClickB" @click="handleClickB"
/> />
</div> </div>
<div class="shooting-composition"> <div class="absolute bottom-4 left-4 btn-1">
<ImgButton <ImgButton
class="mybutton" class="mybutton"
image="shooting/构图指南.png" image="shooting/构图指南.png"
@@ -145,116 +110,14 @@ const back = () => {
</GuideStepsItem> </GuideStepsItem>
</GuideSteps> </GuideSteps>
<!-- 保存图片弹窗 --> <!-- 构图指南弹窗 -->
<Overlay <CompositionGuide
:visible="showModal" :visible="showModal"
:clickable="false" bg-image="interview/shooting/访谈-构图指南底图"
:customStyle="{ :btn-list="btnList"
backgroundImage: getBackgroundImage('shooting/构图指南底图'), :knowledge-card-component="KnowledgeCard"
}" :video-src="videoSrc"
customClass="inset-0 bg-cover bg-center bg-no-repeat" @close="showModal = false"
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> </div>
</template> </template>
<style lang="less" scoped>
.shooting-bg {
position: absolute;
.shooting-title {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
.shooting-return {
position: absolute;
right: -90%;
top: 0;
width: 100%;
height: 100%;
}
.shooting-left {
position: absolute;
left: 16%;
top: 50%;
width: 100%;
height: 100%;
}
.shooting-right {
position: absolute;
right: -55%;
top: 50%;
width: 100%;
height: 100%;
}
.shooting-composition {
position: absolute;
left: 24px;
bottom: -90%;
width: 100%;
height: 100%;
}
}
</style>
+12 -1
View File
@@ -67,6 +67,17 @@ const worksList = ref<WorkItem[]>([
// } // }
]) ])
const getCachedWorkScore = (work: WorkItem): number => {
const scores = interviewStore.practiceWorkRatings[work.path]
const values = scores ? Object.values(scores) : []
if (values.length === 0) return work.stars
return Math.round(
values.reduce((total, score) => total + score, 0) / values.length,
)
}
/** 左滑动作品列表 */ /** 左滑动作品列表 */
const scrollWorksLeft = (): void => { const scrollWorksLeft = (): void => {
if (worksScrollIndex.value > 0) { if (worksScrollIndex.value > 0) {
@@ -290,7 +301,7 @@ onUnmounted(() => {
</h3> </h3>
<!-- 星星评分 --> <!-- 星星评分 -->
<StarRating <StarRating
:score="work.stars" :score="getCachedWorkScore(work)"
size="lg" size="lg"
readonly readonly
/> />
@@ -1,19 +1,37 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref } from 'vue' import { computed, onBeforeUnmount, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { getBackgroundImage, IMAGE_PATHS } from '@/utils' import { getBackgroundImage, IMAGE_PATHS } from '@/utils'
import { ImgButton } from '@/components/Button' import { ImgButton } from '@/components/Button'
import PracticeRatingModal, { import PracticeRatingModal, {
type PracticeRatingItem, type PracticeRatingItem,
} from '@/components/PracticeRatingModal' } from '@/components/PracticeRatingModal'
import { useInterviewStore } from '@/stores/modules/interview'
const router = useRouter() const router = useRouter()
const store = useInterviewStore()
type PracticeRatingScores = Record<string, number>
const applyScores = (
items: PracticeRatingItem[],
scores: PracticeRatingScores,
): PracticeRatingItem[] =>
items.map((item) => ({
...item,
score: scores[item.title] ?? item.score,
}))
const toScores = (items: PracticeRatingItem[]): PracticeRatingScores =>
Object.fromEntries(items.map((item) => [item.title, item.score]))
/** 笔记内容 */ /** 笔记内容 */
const noteContent = ref<string>('') const noteContent = ref<string>(store.practiceNoteContent)
/** 保存弹窗 */ /** 保存弹窗 */
const showModal = ref<boolean>(false) const showModal = ref<boolean>(false)
const showSuccess = ref<boolean>(false)
let successTimer: number | undefined
/** 返回上一页 */ /** 返回上一页 */
const handleBack = (): void => { const handleBack = (): void => {
@@ -23,7 +41,7 @@ const handleBack = (): void => {
const disabled = computed<boolean>(() => noteContent.value.trim() === '') const disabled = computed<boolean>(() => noteContent.value.trim() === '')
/** 评估项目列表 */ /** 评估项目列表 */
const evaluationItems = ref<PracticeRatingItem[]>([ const defaultEvaluationItems: PracticeRatingItem[] = [
{ {
title: '人物表达', title: '人物表达',
description: '能清晰介绍人物身份、特点或故事,让观众认识人物', description: '能清晰介绍人物身份、特点或故事,让观众认识人物',
@@ -54,11 +72,28 @@ const evaluationItems = ref<PracticeRatingItem[]>([
description: '作品完整,有真实感与感染力,具备观看吸引力', description: '作品完整,有真实感与感染力,具备观看吸引力',
score: 5, score: 5,
}, },
]) ]
const evaluationItems = ref<PracticeRatingItem[]>(
applyScores(defaultEvaluationItems, store.practiceSelfEvaluationScores),
)
const handleSave = (): void => { const handleSave = (): void => {
console.log('保存笔记') store.setPracticeNoteContent(noteContent.value)
handleBack() showSuccess.value = true
window.clearTimeout(successTimer)
successTimer = window.setTimeout(() => {
showSuccess.value = false
}, 1500)
} }
const handleSaveEvaluation = (items: PracticeRatingItem[]): void => {
store.setPracticeSelfEvaluationScores(toScores(items))
}
onBeforeUnmount(() => {
window.clearTimeout(successTimer)
})
</script> </script>
<template> <template>
@@ -81,6 +116,13 @@ const handleSave = (): void => {
placeholder="点击输入创作笔记" placeholder="点击输入创作笔记"
></textarea> ></textarea>
<div
v-if="showSuccess"
class="absolute left-1/2 top-1/2 z-[100] -translate-x-1/2 -translate-y-1/2 rounded-2xl bg-black/70 px-16 py-8 text-5xl font-bold text-white"
>
提交成功
</div>
<div class="absolute bottom-24 left-1/2 -translate-x-1/2 flex gap-60"> <div class="absolute bottom-24 left-1/2 -translate-x-1/2 flex gap-60">
<ImgButton <ImgButton
image="vlog/practice/保存" image="vlog/practice/保存"
@@ -95,10 +137,11 @@ const handleSave = (): void => {
<PracticeRatingModal <PracticeRatingModal
:visible="showModal" :visible="showModal"
:items="evaluationItems" v-model:items="evaluationItems"
background-image="interview/practice/人物访谈短片创作-作品评分表" background-image="interview/practice/人物访谈短片创作-作品评分表"
top-tip="评分时要关注6个维度哦~分别是人物表达、素材拍摄、镜头语言、剪辑结构、音画关系、综合观感每个维度满分5分,能打1-5分,记得对照作品表现来打分呀~" top-tip="评分时要关注6个维度哦~分别是人物表达、素材拍摄、镜头语言、剪辑结构、音画关系、综合观感每个维度满分5分,能打1-5分,记得对照作品表现来打分呀~"
bottom-tip="己评价的实战作品,从封面入口就能直接看到具体评分!打了多少分一目了然,不用再点进详情页翻找啦~" bottom-tip="己评价的实战作品,从封面入口就能直接看到具体评分!打了多少分一目了然,不用再点进详情页翻找啦~"
@save="handleSaveEvaluation"
@close="showModal = false" @close="showModal = false"
/> />
</div> </div>
+34 -4
View File
@@ -6,9 +6,11 @@ import PracticeRatingModal, {
type PracticeRatingItem, type PracticeRatingItem,
} from '@/components/PracticeRatingModal' } from '@/components/PracticeRatingModal'
import { IMAGE_PATHS, getPublicPath } from '@/utils' import { IMAGE_PATHS, getPublicPath } from '@/utils'
import { ref } from 'vue' import { useInterviewStore } from '@/stores/modules/interview'
import { computed, ref } from 'vue'
const router = useRouter() const router = useRouter()
const store = useInterviewStore()
const props = defineProps({ const props = defineProps({
path: { path: {
@@ -17,8 +19,24 @@ const props = defineProps({
}, },
}) })
type PracticeRatingScores = Record<string, number>
const workKey = computed<string>(() => decodeURIComponent(props.path))
const applyScores = (
items: PracticeRatingItem[],
scores: PracticeRatingScores,
): PracticeRatingItem[] =>
items.map((item) => ({
...item,
score: scores[item.title] ?? item.score,
}))
const toScores = (items: PracticeRatingItem[]): PracticeRatingScores =>
Object.fromEntries(items.map((item) => [item.title, item.score]))
/** 评估项目列表 */ /** 评估项目列表 */
const evaluationItems = ref<PracticeRatingItem[]>([ const defaultEvaluationItems: PracticeRatingItem[] = [
{ {
title: '人物表达', title: '人物表达',
description: '能清晰介绍人物身份、特点或故事,让观众认识人物', description: '能清晰介绍人物身份、特点或故事,让观众认识人物',
@@ -49,7 +67,18 @@ const evaluationItems = ref<PracticeRatingItem[]>([
description: '作品完整,有真实感与感染力,具备观看吸引力', description: '作品完整,有真实感与感染力,具备观看吸引力',
score: 5, score: 5,
}, },
]) ]
const evaluationItems = ref<PracticeRatingItem[]>(
applyScores(
defaultEvaluationItems,
store.getPracticeWorkRating(workKey.value),
),
)
const handleSaveEvaluation = (items: PracticeRatingItem[]): void => {
store.setPracticeWorkRating(workKey.value, toScores(items))
}
/** 保存弹窗 */ /** 保存弹窗 */
const showModal = ref<boolean>(false) const showModal = ref<boolean>(false)
@@ -82,10 +111,11 @@ const handleBack = (): void => {
<PracticeRatingModal <PracticeRatingModal
:visible="showModal" :visible="showModal"
:items="evaluationItems" v-model:items="evaluationItems"
background-image="interview/practice/人物访谈短片创作-作品评分表" background-image="interview/practice/人物访谈短片创作-作品评分表"
top-tip="评分时要关注6个维度哦~分别是人物表达、素材拍摄、镜头语言、剪辑结构、音画关系、综合观感每个维度满分5分,能打1-5分,记得对照作品表现来打分呀~" top-tip="评分时要关注6个维度哦~分别是人物表达、素材拍摄、镜头语言、剪辑结构、音画关系、综合观感每个维度满分5分,能打1-5分,记得对照作品表现来打分呀~"
bottom-tip="己评价的实战作品,从封面入口就能直接看到具体评分!打了多少分一目了然,不用再点进详情页翻找啦~" bottom-tip="己评价的实战作品,从封面入口就能直接看到具体评分!打了多少分一目了然,不用再点进详情页翻找啦~"
@save="handleSaveEvaluation"
@close="showModal = false" @close="showModal = false"
/> />
</div> </div>
+190 -10
View File
@@ -1,10 +1,14 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, nextTick, onMounted, ref } from 'vue' import { computed, nextTick, onMounted, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { onBeforeRouteLeave, useRouter, useRoute } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue' import ImgButton from '@/components/Button/ImgButton.vue'
import { GuideSteps, type GuideStep } from '@/components/Guide' import { GuideSteps, type GuideStep } from '@/components/Guide'
import StepNavigation from '@/components/Guide/StepNavigation.vue' import StepNavigation from '@/components/Guide/StepNavigation.vue'
import UnfinishedLearningPrompt from '@/components/UnfinishedLearningPrompt'
import { Video } from '@/components' import { Video } from '@/components'
import QuestionQuiz, {
type Question,
} from '@/components/QuestionQuiz/QuestionQuiz.vue'
import { getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils' import { getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
import { useInterviewStore } from '@/stores' import { useInterviewStore } from '@/stores'
@@ -16,6 +20,84 @@ const interview = useInterviewStore()
// 受控播放开关(用于切换到下一个视频时自动播放) // 受控播放开关(用于切换到下一个视频时自动播放)
const playing = ref(false) const playing = ref(false)
const status = ref(false) const status = ref(false)
const showQuiz = ref(false) // 控制是否显示答题窗口
// 题目数据
const questions = ref<Question[]>([])
// 根据当前视频索引获取对应的题目
const getQuestionsForVideo = (index: number): Question[] => {
// 机位设置(index 0)
if (index === 0) {
return [
{
id: 1,
title: '',
description:
'【单选题】关于访谈拍摄中正面机位的中心对称构图,下列说法错误的是?',
options: [
{
id: 'a',
label: '中心对称构图能让画面更平稳,传递平等交流的访谈氛围',
},
{
id: 'b',
label: '拍摄时应让人物居中,头顶预留 3–4 指宽度,避免切头',
},
{ id: 'c', label: '为突出人物,应让人物紧贴背景,减少画面留白' },
{ id: 'd', label: '建议使用中长焦段拍摄,避免广角镜头的边缘畸变' },
],
correctOptionId: 'c',
explanation:
'中心对称构图拍摄时,需要让人物与背景保留一定距离,让画面更通透,更好地突出主体;紧贴背景会压缩画面空间,影响视觉效果,因此 C 选项错误。',
},
]
}
// 轴线原则(index 1)
if (index === 1) {
return [
{
id: 2,
title: '',
description: '【多选题】关于影视拍摄中的轴线原则,下列说法正确的有?',
options: [
{ id: 'a', label: '轴线是拍摄对象的视线、运动或关系形成的假想线' },
{
id: 'b',
label: '拍摄对话场景时,机位通常需保持在轴线同侧 180 度以内',
},
{ id: 'c', label: '越轴会导致画面空间关系混乱,出现视线错位等问题' },
{ id: 'd', label: '骑轴机位(轴线之上)不属于合规的拍摄机位' },
],
correctOptionId: '',
correctOptionIds: ['a', 'b', 'c'],
isMultiple: true,
explanation:
'轴线原则中,机位可保持在轴线同侧 180 度以内,也包含轴线之上的骑轴机位,因此 D 选项错误;A、B、C 均为轴线原则的核心内容。',
},
]
}
// 声音同步与收录(index 3)
if (index === 3) {
return [
{
id: 3,
title: '',
description: '【单选题】关于访谈拍摄的声音收录,下列做法不规范的是?',
options: [
{ id: 'a', label: '领夹麦夹在衣领,收音头朝向嘴部,距离 15–20 厘米' },
{ id: 'b', label: '拍摄前让被采访者试音,监听是否有爆音、底噪' },
{ id: 'c', label: '多机位拍摄时,开拍前拍手一次,方便后期对齐音画' },
{ id: 'd', label: '为节省时间,无需录制环境音,直接用后期音效填补' },
],
correctOptionId: 'd',
explanation:
'条件允许时,应额外录制 5–10 秒现场环境音,用于填补剪辑间隙,让音频过渡更自然;直接用后期音效会破坏现场真实感,因此 D 选项不规范。',
},
]
}
return []
}
//引导步骤 //引导步骤
const steps = ref<GuideStep[]>([ const steps = ref<GuideStep[]>([
@@ -97,11 +179,46 @@ const navigationSteps = ref([
// 步骤导航相关状态 // 步骤导航相关状态
const showStepNavigation = ref(false) const showStepNavigation = ref(false)
const currentNavigationStep = ref(1) // 对应视频索引,从0开始 const showUnfinishedPrompt = ref(false)
const pendingLeavePath = ref<string | null>(null)
const allowRouteLeave = ref(false)
const currentNavigationStep = ref(0) // 对应视频索引,从0开始
// 视频列表 // 视频列表
const currentVideoIndex = ref(0) const currentVideoIndex = ref(0)
const professionVideoKeys = [
'interview-profession-jiweishezhi',
'interview-profession-zhouxianyuanze',
'interview-profession-jingtouzujie',
'interview-profession-shengyintongbu',
] as const
const completedNavigationSteps = computed(() =>
navigationSteps.value.map(
(_, index) =>
interview.professionVideosWatched[professionVideoKeys[index]] === true,
),
)
const hasUnfinishedLearningVideos = computed(
() =>
key.value === 'profession' &&
completedNavigationSteps.value.some((completed) => !completed),
)
const markCurrentProfessionVideoWatched = () => {
const videoKey = professionVideoKeys[currentVideoIndex.value]
if (!videoKey) return
interview.markProfessionVideoWatched(videoKey)
const isAllWatched = professionVideoKeys.every(
(key) =>
key === videoKey || interview.professionVideosWatched[key] === true,
)
if (isAllWatched) interview.markWorkVideoWatched('profession')
}
//按顺序播放 //按顺序播放
const currentVideo = computed( const currentVideo = computed(
() => navigationSteps.value[currentVideoIndex.value].videoUrl, () => navigationSteps.value[currentVideoIndex.value].videoUrl,
@@ -116,13 +233,14 @@ const handleVideoChange = () => {
currentVideoIndex.value++ currentVideoIndex.value++
currentNavigationStep.value = currentVideoIndex.value currentNavigationStep.value = currentVideoIndex.value
} else { } else {
// status.value = true status.value = true
// steps.value = [ steps.value = [
// { {
// id: 'step1', id: 'step1',
// content: '恭喜你,已完成专业剖析的学习,点击【查看脚本】回顾视频脚本吧!' content:
// } '恭喜你,已完成专业剖析的学习,点击【查看脚本】回顾视频脚本吧!',
// ] },
]
return return
} }
nextTick(() => { nextTick(() => {
@@ -133,6 +251,7 @@ const handleVideoChange = () => {
const handleClose = () => { const handleClose = () => {
if (key.value === 'idea') { if (key.value === 'idea') {
interview.markWorkVideoWatched('idea')
interview.setInterviewUnlockProgress(1) interview.setInterviewUnlockProgress(1)
router.push({ router.push({
name: 'Interview', name: 'Interview',
@@ -151,11 +270,32 @@ const handleClick = () => {}
const handlePlay = () => {} const handlePlay = () => {}
const onEnded = () => { const onEnded = () => {
if (key.value === 'profession') handleVideoChange() if (key.value === 'case') {
interview.markWorkVideoWatched('case')
return
}
if (key.value === 'profession') {
// 机位设置(index 0)、轴线原则(index 1)、声音同步与收录(index 3)先出题,答完才算完成
if (
currentVideoIndex.value === 0 ||
currentVideoIndex.value === 1 ||
currentVideoIndex.value === 3
) {
playing.value = false
questions.value = getQuestionsForVideo(currentVideoIndex.value)
showQuiz.value = true
return
}
markCurrentProfessionVideoWatched()
handleVideoChange()
}
} }
const handleComplete = () => { const handleComplete = () => {
if (key.value === 'profession') { if (key.value === 'profession') {
interview.markWorkVideoWatched('profession')
if (!interview.isInterviewUnlock) interview.setIsInterviewUnlock(true) if (!interview.isInterviewUnlock) interview.setIsInterviewUnlock(true)
router.push({ router.push({
name: 'InterviewWork', name: 'InterviewWork',
@@ -163,8 +303,16 @@ const handleComplete = () => {
} }
} }
// 答题完成后的处理
const handleQuestionsComplete = () => {
markCurrentProfessionVideoWatched()
showQuiz.value = false
handleVideoChange()
}
// 步骤导航事件处理 // 步骤导航事件处理
const handleStepClick = (index: number) => { const handleStepClick = (index: number) => {
showQuiz.value = false
currentVideoIndex.value = index currentVideoIndex.value = index
currentNavigationStep.value = index currentNavigationStep.value = index
nextTick(() => { nextTick(() => {
@@ -176,6 +324,25 @@ const handleStepNavigationClose = () => {
showStepNavigation.value = false showStepNavigation.value = false
} }
const handleContinueLearning = () => {
showUnfinishedPrompt.value = false
pendingLeavePath.value = null
}
const handleConfirmLeave = () => {
if (!pendingLeavePath.value) return
allowRouteLeave.value = true
showUnfinishedPrompt.value = false
router.push(pendingLeavePath.value)
}
onBeforeRouteLeave((to) => {
if (allowRouteLeave.value || !hasUnfinishedLearningVideos.value) return true
pendingLeavePath.value = to.fullPath
showUnfinishedPrompt.value = true
return false
})
onMounted(() => { onMounted(() => {
if (key.value === 'idea') { if (key.value === 'idea') {
status.value = true status.value = true
@@ -243,6 +410,12 @@ onMounted(() => {
:image="IMAGE_PATHS.COMPONENTS.BACK" :image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleClose" @click="handleClose"
/> />
<QuestionQuiz
v-if="showQuiz && key === 'profession'"
:show-question-number="false"
:questions="questions"
@complete="handleQuestionsComplete"
/>
</template> </template>
</Video> </Video>
<div <div
@@ -279,9 +452,16 @@ onMounted(() => {
:background-image="getStaticImage('work/vlog背景全屏待定')" :background-image="getStaticImage('work/vlog背景全屏待定')"
:steps="navigationSteps" :steps="navigationSteps"
:current-step-index="currentNavigationStep" :current-step-index="currentNavigationStep"
:completed-steps="completedNavigationSteps"
@step-click="handleStepClick" @step-click="handleStepClick"
@close="handleStepNavigationClose" @close="handleStepNavigationClose"
/> />
<UnfinishedLearningPrompt
:visible="showUnfinishedPrompt"
@continue="handleContinueLearning"
@leave="handleConfirmLeave"
/>
</template> </template>
<style scoped> <style scoped>
+4 -1
View File
@@ -21,7 +21,10 @@ const interview = useInterviewStore()
type WorkTab = 'vlog' | 'case' | 'profession' | 'idea' type WorkTab = 'vlog' | 'case' | 'profession' | 'idea'
const selected = ref<WorkTab>('case') const selected = computed({
get: () => interview.workSelectedTab as WorkTab,
set: (value: WorkTab) => interview.setWorkSelectedTab(value),
})
const workSideSwitcherRef = ref<InstanceType<typeof WorkSideSwitcher>>() const workSideSwitcherRef = ref<InstanceType<typeof WorkSideSwitcher>>()
const isGuideInterviewTask = computed(() => interview.isGuideInterviewTask) const isGuideInterviewTask = computed(() => interview.isGuideInterviewTask)
+56 -9
View File
@@ -1,9 +1,10 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, nextTick, ref } from 'vue' import { computed, nextTick, ref } from 'vue'
import { useRouter } from 'vue-router' import { onBeforeRouteLeave, useRouter } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue' import ImgButton from '@/components/Button/ImgButton.vue'
import { GuideSteps, GuideStepsItem } from '@/components/Guide' import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import StepNavigation from '@/components/Guide/StepNavigation.vue' import StepNavigation from '@/components/Guide/StepNavigation.vue'
import UnfinishedLearningPrompt from '@/components/UnfinishedLearningPrompt'
import { getPublicPath, IMAGE_PATHS } from '@/utils' import { getPublicPath, IMAGE_PATHS } from '@/utils'
import { Video } from '@/components' import { Video } from '@/components'
import { useVlogStore } from '@/stores/modules/vlog' import { useVlogStore } from '@/stores/modules/vlog'
@@ -19,28 +20,28 @@ if (showIntroGuide.value) {
const navigationSteps = ref([ const navigationSteps = ref([
{ {
id: 'step1', id: 'vlog-editing-start',
label: '后期制作', label: '后期制作',
content: '剪辑起点', content: '剪辑起点',
videoUrl: getPublicPath('video/vlog/editing/Vlogmk_jjqd_batch.mp4'), videoUrl: getPublicPath('video/vlog/editing/Vlogmk_jjqd_batch.mp4'),
type: 'post' as const, type: 'post' as const,
}, },
{ {
id: 'step2', id: 'vlog-editing-audio-picture-match',
label: '后期制作', label: '后期制作',
content: '音画匹配', content: '音画匹配',
videoUrl: getPublicPath('video/vlog/editing/Vlogmk_yhpp_batch.mp4'), videoUrl: getPublicPath('video/vlog/editing/Vlogmk_yhpp_batch.mp4'),
type: 'post' as const, type: 'post' as const,
}, },
{ {
id: 'step3', id: 'vlog-editing-rhythm',
label: '后期制作', label: '后期制作',
content: '剪辑节奏', content: '剪辑节奏',
videoUrl: getPublicPath('video/vlog/editing/Vlogmk_jjjz_batch.mp4'), videoUrl: getPublicPath('video/vlog/editing/Vlogmk_jjjz_batch.mp4'),
type: 'post' as const, type: 'post' as const,
}, },
{ {
id: 'step4', id: 'vlog-editing-sound-transition',
label: '后期制作', label: '后期制作',
content: '声音与转场', content: '声音与转场',
videoUrl: getPublicPath('video/vlog/editing/Vlogmk_syyzc_batch.mp4'), videoUrl: getPublicPath('video/vlog/editing/Vlogmk_syyzc_batch.mp4'),
@@ -48,16 +49,36 @@ const navigationSteps = ref([
}, },
]) ])
const currentVideoIndex = ref(0)
const playing = ref(false)
const showStepNavigation = ref(false)
const showUnfinishedPrompt = ref(false)
const pendingLeavePath = ref<string | null>(null)
const allowRouteLeave = ref(false)
const showEndingGuide = ref(false)
const currentVideo = computed( const currentVideo = computed(
() => navigationSteps.value[currentVideoIndex.value].videoUrl, () => navigationSteps.value[currentVideoIndex.value].videoUrl,
) )
const currentVideoIndex = ref(0) const completedNavigationSteps = computed(() =>
const playing = ref(false) navigationSteps.value.map(
const showStepNavigation = ref(false) (step) => vlogStore.professionVideosWatched[step.id] === true,
const showEndingGuide = ref(false) ),
)
const hasUnfinishedLearningVideos = computed(() =>
completedNavigationSteps.value.some((completed) => !completed),
)
const markCurrentVideoWatched = () => {
const videoKey = navigationSteps.value[currentVideoIndex.value]?.id
if (videoKey) vlogStore.markEditingVideoWatched(videoKey)
}
const playNextVideo = () => { const playNextVideo = () => {
markCurrentVideoWatched()
if (currentVideoIndex.value >= navigationSteps.value.length - 1) { if (currentVideoIndex.value >= navigationSteps.value.length - 1) {
playing.value = false playing.value = false
showEndingGuide.value = true showEndingGuide.value = true
@@ -91,6 +112,25 @@ const handleIntroGuideComplete = () => {
const handleEndingGuideComplete = () => { const handleEndingGuideComplete = () => {
showEndingGuide.value = false showEndingGuide.value = false
} }
const handleContinueLearning = () => {
showUnfinishedPrompt.value = false
pendingLeavePath.value = null
}
const handleConfirmLeave = () => {
if (!pendingLeavePath.value) return
allowRouteLeave.value = true
showUnfinishedPrompt.value = false
router.push(pendingLeavePath.value)
}
onBeforeRouteLeave((to) => {
if (allowRouteLeave.value || !hasUnfinishedLearningVideos.value) return true
pendingLeavePath.value = to.fullPath
showUnfinishedPrompt.value = true
return false
})
</script> </script>
<template> <template>
@@ -131,10 +171,17 @@ const handleEndingGuideComplete = () => {
:background-image="''" :background-image="''"
:steps="navigationSteps" :steps="navigationSteps"
:current-step-index="currentVideoIndex" :current-step-index="currentVideoIndex"
:completed-steps="completedNavigationSteps"
@step-click="handleStepClick" @step-click="handleStepClick"
@close="showStepNavigation = false" @close="showStepNavigation = false"
/> />
<UnfinishedLearningPrompt
:visible="showUnfinishedPrompt"
@continue="handleContinueLearning"
@leave="handleConfirmLeave"
/>
<GuideSteps <GuideSteps
v-if="showIntroGuide" v-if="showIntroGuide"
position-offset-y="85%" position-offset-y="85%"
+46 -27
View File
@@ -16,18 +16,18 @@ const category = ref([
{ {
label: '前期准备', label: '前期准备',
value: 1, value: 1,
icon: getStaticImage('midterm/准备图标.png') icon: getStaticImage('midterm/准备图标.png'),
}, },
{ {
label: '中期拍摄', label: '中期拍摄',
value: 2, value: 2,
icon: getStaticImage('midterm/拍摄图标.png') icon: getStaticImage('midterm/拍摄图标.png'),
}, },
{ {
label: '后期剪辑', label: '后期剪辑',
value: 3, value: 3,
icon: getStaticImage('midterm/剪辑图标.png') icon: getStaticImage('midterm/剪辑图标.png'),
} },
]) ])
const menuItems = ref([ const menuItems = ref([
@@ -35,43 +35,43 @@ const menuItems = ref([
label: '资源筹备', label: '资源筹备',
routeName: 'VlogResource', routeName: 'VlogResource',
parentValue: 1, parentValue: 1,
disabled: false disabled: false,
}, },
{ {
label: '创意构想', label: '创意构想',
routeName: 'VlogCreative', routeName: 'VlogCreative',
parentValue: 1, parentValue: 1,
disabled: false disabled: false,
}, },
{ {
label: '脚本撰写', label: '脚本撰写',
routeName: 'VlogScriptWriting', routeName: 'VlogScriptWriting',
parentValue: 1, parentValue: 1,
disabled: false disabled: false,
}, },
{ {
label: '分镜制作', label: '分镜制作',
routeName: 'VlogStoryboard', routeName: 'VlogStoryboard',
parentValue: 1, parentValue: 1,
disabled: false disabled: false,
}, },
{ {
label: '设备准备', label: '设备准备',
routeName: 'VlogMidtermEquipment', routeName: 'VlogMidtermEquipment',
parentValue: 2, parentValue: 2,
disabled: false disabled: false,
}, },
{ {
label: '实地取景', label: '实地取景',
routeName: 'VlogMidtermShooting', routeName: 'VlogMidtermShooting',
parentValue: 2, parentValue: 2,
disabled: false disabled: false,
}, },
{ {
label: '现场拍摄', label: '现场拍摄',
routeName: 'VlogShooting', routeName: 'VlogShooting',
parentValue: 2, parentValue: 2,
disabled: false disabled: false,
}, },
// { // {
// label: '素材管理', // label: '素材管理',
@@ -83,20 +83,20 @@ const menuItems = ref([
label: '剪辑', label: '剪辑',
routeName: 'VlogEditing', routeName: 'VlogEditing',
parentValue: 3, parentValue: 3,
disabled: false disabled: false,
}, },
{ {
label: '调色', label: '调色',
routeName: 'VlogColor', routeName: 'VlogColor',
parentValue: 3, parentValue: 3,
disabled: false disabled: false,
}, },
{ {
label: '导出发布', label: '导出发布',
routeName: 'VlogExportPublish', routeName: 'VlogExportPublish',
parentValue: 3, parentValue: 3,
disabled: false disabled: false,
} },
]) ])
const props = defineProps<{ const props = defineProps<{
@@ -117,7 +117,9 @@ const currentParentValue = computed<number>(() => {
const currentItemValue = ref<string>('') // 当前选中的子级菜单项 const currentItemValue = ref<string>('') // 当前选中的子级菜单项
const getMenuItems = computed(() => { const getMenuItems = computed(() => {
return menuItems.value.filter(item => item.parentValue === currentParentValue.value) return menuItems.value.filter(
(item) => item.parentValue === currentParentValue.value,
)
}) })
const handleItemClick = (item: { routeName: string; disabled: boolean }) => { const handleItemClick = (item: { routeName: string; disabled: boolean }) => {
@@ -128,13 +130,20 @@ const handleItemClick = (item: { routeName: string; disabled: boolean }) => {
</script> </script>
<template> <template>
<div class="absolute left-20 top-0 bottom-0 w-[520px] pt-28" v-if="currentStep"> <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"> <div class="absolute top-3 left-24 flex items-center space-x-2">
<span class="text-4xl text-[#fbbf24] font-black"> <span class="text-4xl text-[#fbbf24] font-black">
{{ category[currentParentValue - 1].label }} {{ category[currentParentValue - 1].label }}
</span> </span>
<img class="filter-blue" :src="category[currentParentValue - 1].icon" alt="" /> <img
class="filter-blue"
:src="category[currentParentValue - 1].icon"
alt=""
/>
</div> </div>
<!-- 背景图片 --> <!-- 背景图片 -->
<div <div
@@ -142,12 +151,17 @@ const handleItemClick = (item: { routeName: string; disabled: boolean }) => {
:style="{ backgroundImage: getBackgroundImage('components/menu-bg.png') }" :style="{ backgroundImage: getBackgroundImage('components/menu-bg.png') }"
></div> ></div>
<div class="flex items-center flex-col pl-[60px]"> <div class="flex items-center flex-col pl-[60px]">
<template v-for="(item, index) in getMenuItems" :key="item.routeName"> <template
v-for="(item, index) in getMenuItems"
:key="item.routeName"
>
<div <div
class="relative w-[373px] h-[143px] pl-14 flex items-center justify-center group text-4xl font-black transition-colors" class="relative w-[373px] h-[143px] pl-14 flex items-center justify-center group text-4xl font-black transition-colors"
:class="[ :class="[
currentItemValue === item.routeName ? 'text-blue-400' : 'text-white', currentItemValue === item.routeName
item.disabled ? 'opacity-20 cursor-not-allowed' : 'cursor-pointer' ? 'text-blue-400'
: 'text-white',
item.disabled ? 'opacity-20 cursor-not-allowed' : 'cursor-pointer',
]" ]"
:style="{ marginLeft: -index * 40 + 'px' }" :style="{ marginLeft: -index * 40 + 'px' }"
@click="handleItemClick(item)" @click="handleItemClick(item)"
@@ -155,16 +169,21 @@ const handleItemClick = (item: { routeName: string; disabled: boolean }) => {
<div <div
v-if="!item.disabled" 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" 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') }" :style="{
backgroundImage: getBackgroundImage('components/menu-hover.png'),
}"
></div> ></div>
<span class="flex items-center gap-2" :class="[!item.disabled ? 'menu-glow-text' : '']"> <span
class="flex items-center gap-2"
:class="[!item.disabled ? 'menu-glow-text' : '']"
>
<span>{{ item.label }}</span> <span>{{ item.label }}</span>
<img <img
class="w-10" class="w-10"
:src=" :src="
isViewed(item.routeName) isViewed(item.routeName)
? '/src/assets/images/midterm/完成.png' ? getStaticImage('midterm/完成.png')
: '/src/assets/images/midterm/未完成.png' : getStaticImage('midterm/未完成.png')
" "
alt="" alt=""
/> />
@@ -177,8 +196,8 @@ const handleItemClick = (item: { routeName: string; disabled: boolean }) => {
<style scoped> <style scoped>
.filter-blue { .filter-blue {
filter: brightness(0) saturate(100%) invert(79%) sepia(29%) saturate(1190%) hue-rotate(330deg) filter: brightness(0) saturate(100%) invert(79%) sepia(29%) saturate(1190%)
brightness(102%) contrast(98%); hue-rotate(330deg) brightness(102%) contrast(98%);
} }
.menu-glow-text { .menu-glow-text {
+80 -36
View File
@@ -4,7 +4,7 @@ import { ImgButton } from '@/components/Button'
import { computed, ref, watch, type Ref } from 'vue' import { computed, ref, watch, type Ref } from 'vue'
import { ProgressBar, Overlay } from '@/components' import { ProgressBar, Overlay } from '@/components'
import { GuideSteps, type GuideStep } from '@/components/Guide' import { GuideSteps, GuideStepsItem, type GuideStep } from '@/components/Guide'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useVlogStore } from '@/stores' import { useVlogStore } from '@/stores'
import VlogMenu from './components/menu.vue' import VlogMenu from './components/menu.vue'
@@ -15,32 +15,43 @@ const vlog = useVlogStore()
const visible = computed({ const visible = computed({
get: () => vlog.vlogMenuVisible, get: () => vlog.vlogMenuVisible,
set: value => vlog.setVlogMenuVisible(value) set: (value) => vlog.setVlogMenuVisible(value),
}) })
const currentStep = computed(() => vlog.vlogUnlockProgress) const currentStep = computed(() => vlog.vlogUnlockProgress)
const progress = computed(() => vlog.progressPercent) const progress = computed(() => vlog.progressPercent)
const showReturnGuide = ref(route.query.guide === 'return' && vlog.isGuideVlogReturn) // 实战操作台一次性引导(currentStep === 4 时解锁)
const practiceGuideKey = 'vlog-practice-guide'
const hasShownPracticeGuide = computed(
() => vlog.guideShown[practiceGuideKey] === true,
)
const showPracticeGuide = computed(
() => currentStep.value === 4 && !hasShownPracticeGuide.value,
)
const showReturnGuide = ref(
route.query.guide === 'return' && vlog.isGuideVlogReturn,
)
const returnSteps: GuideStep[] = [ const returnSteps: GuideStep[] = [
{ {
id: 'return-step1', id: 'return-step1',
content: '通过前面的案例赏析,相信你已经初步了解了 Vlog 的表达方式。', content: '通过前面的案例赏析,相信你已经初步了解了 Vlog 的表达方式。',
audioSrc: getPublicPath('audio/py_2_006.MP3') audioSrc: getPublicPath('audio/py_2_006.MP3'),
}, },
{ {
id: 'return-step2', id: 'return-step2',
content: '接下来,我们将一起完成一支真实主题作品——《我的志愿者手记》。', content: '接下来,我们将一起完成真实主题作品——《我的志愿者手记》。',
audioSrc: getPublicPath('audio/py_2_007.MP3') audioSrc: getPublicPath('audio/py_2_007.MP3'),
}, },
{ {
id: 'return-step3', id: 'return-step3',
content: content:
'在记录社区志愿活动的过程中,学习 Vlog 从前期策划、中期拍摄到后期剪辑的完整流程,同时传递热心助人、服务社会的价值力量。', '在记录社区志愿活动的过程中,学习 Vlog 从前期策划、中期拍摄到后期剪辑的完整流程,同时传递热心助人、服务社会的价值力量。',
audioSrc: getPublicPath('audio/py_2_008.MP3') audioSrc: getPublicPath('audio/py_2_008.MP3'),
} },
] ]
const handleReturnGuideComplete = () => { const handleReturnGuideComplete = () => {
@@ -58,20 +69,20 @@ const stageGuideMap = {
{ {
id: 'stage1-step1', id: 'stage1-step1',
content: '一次顺利的拍摄,往往从充分准备开始。', content: '一次顺利的拍摄,往往从充分准备开始。',
audioSrc: getPublicPath('audio/py_2_009.MP3') audioSrc: getPublicPath('audio/py_2_009.MP3'),
}, },
{ {
id: 'stage1-step2', id: 'stage1-step2',
content: content:
'前期阶段,我们需要完成四项关键任务:【资源筹备】【创意构想】【脚本撰写】【分镜制作】', '前期阶段,我们需要完成四项关键任务:\n【资源筹备】【创意构想】【脚本撰写】【分镜制作】',
audioSrc: getPublicPath('audio/py_2_010.MP3') audioSrc: getPublicPath('audio/py_2_010.MP3'),
}, },
{ // {
id: 'stage1-step3', // id: 'stage1-step3',
content: '画面中高亮的按钮是我们可以展开学习的内容。点击进入学习吧!', // content: '画面中高亮的按钮是我们可以展开学习的内容。点击进入学习吧!',
audioSrc: getPublicPath('audio/py_2_011.MP3') // audioSrc: getPublicPath('audio/py_2_011.MP3'),
} // },
] ],
}, },
2: { 2: {
flagKey: 'isGuideVlogStage2', flagKey: 'isGuideVlogStage2',
@@ -80,10 +91,10 @@ const stageGuideMap = {
{ {
id: 'stage2-step1', id: 'stage2-step1',
content: content:
'拍摄过程可分为三个核心步骤:【设备准备】【实地取景】【现场拍摄】。稳妥的准备是顺利拍摄的前提。', '拍摄过程可分为三个核心步骤:\n【设备准备】【实地取景】【现场拍摄】。稳妥的准备是顺利拍摄的前提。',
audioSrc: getPublicPath('audio/py_2_012.MP3') audioSrc: getPublicPath('audio/py_2_012.MP3'),
} },
] ],
}, },
3: { 3: {
flagKey: 'isGuideVlogStage3', flagKey: 'isGuideVlogStage3',
@@ -91,28 +102,30 @@ const stageGuideMap = {
steps: [ steps: [
{ {
id: 'stage3-step1', id: 'stage3-step1',
content: '经过一系列的准备与拍摄,现在我们来到后期剪辑阶段。', content: '经过一系列的准备与拍摄,我们来到后期剪辑阶段。',
audioSrc: getPublicPath('audio/py_2_016.MP3') audioSrc: getPublicPath('audio/py_2_016.MP3'),
}, },
{ {
id: 'stage3-step2', id: 'stage3-step2',
content: '这个阶段需要做好四件事:【素材整理】【剪辑】【调色】【导出发布】。', content:
audioSrc: getPublicPath('audio/py_2_017.MP3') '这个阶段需要做好四件事:\n【素材整理】【剪辑】【调色】【导出发布】。',
audioSrc: getPublicPath('audio/py_2_017.MP3'),
}, },
{ {
id: 'stage3-step3', id: 'stage3-step3',
content: '画面中高亮的按钮,是可以展开深入学习的内容,点击就能开启学习啦!', content:
audioSrc: getPublicPath('audio/py_2_018.MP3') '画面中高亮的按钮,是可以展开深入学习的内容,点击就能开启学习啦!',
} audioSrc: getPublicPath('audio/py_2_018.MP3'),
] },
} ],
},
} as const } as const
type StageKey = keyof typeof stageGuideMap type StageKey = keyof typeof stageGuideMap
const stageGuideStep = ref<StageKey | null>(null) const stageGuideStep = ref<StageKey | null>(null)
const stageGuideSteps = computed(() => const stageGuideSteps = computed(() =>
stageGuideStep.value ? stageGuideMap[stageGuideStep.value].steps : [] stageGuideStep.value ? stageGuideMap[stageGuideStep.value].steps : [],
) as Ref<GuideStep[]> ) as Ref<GuideStep[]>
const triggerStageGuide = (step: number) => { const triggerStageGuide = (step: number) => {
@@ -152,6 +165,16 @@ const handleClose = () => {
router.push({ name: 'Action' }) router.push({ name: 'Action' })
} }
watch(
showPracticeGuide,
(val) => {
if (val) {
vlog.markGuideShown(practiceGuideKey)
}
},
{ immediate: true },
)
watch( watch(
[visible, currentStep], [visible, currentStep],
([isVisible, step]) => { ([isVisible, step]) => {
@@ -159,7 +182,7 @@ watch(
triggerStageGuide(step) triggerStageGuide(step)
} }
}, },
{ immediate: true } { immediate: true },
) )
</script> </script>
@@ -168,7 +191,9 @@ watch(
<!-- 背景图片-center --> <!-- 背景图片-center -->
<div <div
class="absolute inset-0 bg-cover bg-center bg-no-repeat" class="absolute inset-0 bg-cover bg-center bg-no-repeat"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.BG_CENTER) }" :style="{
backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.BG_CENTER),
}"
></div> ></div>
<!-- 进度数据 --> <!-- 进度数据 -->
@@ -202,22 +227,41 @@ watch(
<!-- 任务取景按钮 --> <!-- 任务取景按钮 -->
<div <div
class="absolute left-24 top-24" class="absolute left-24 top-24"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.TASK_CAMERA) }" :style="{
backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.TASK_CAMERA),
}"
> >
<ImgButton <ImgButton
:image="currentStep === 4 ? IMAGE_PATHS.VLOG.TASK_BTN_OK : IMAGE_PATHS.VLOG.TASK_BTN" :image="
currentStep > 0
? IMAGE_PATHS.VLOG.TASK_BTN_OK
: IMAGE_PATHS.VLOG.TASK_BTN
"
@click="handleWorkClick" @click="handleWorkClick"
/> />
</div> </div>
<GuideSteps
:visible="showPracticeGuide"
highlight-selector=".right-section"
position-offset-y="50%"
@complete="vlog.markGuideShown(practiceGuideKey)"
>
<GuideStepsItem>点击【实战操作台】进入实战操作!</GuideStepsItem>
</GuideSteps>
<!-- 引导人物 --> <!-- 引导人物 -->
<ImgButton <ImgButton
v-if="currentStep !== 4"
v-show="!showReturnGuide" v-show="!showReturnGuide"
class="absolute right-24 bottom-24" class="absolute right-24 bottom-24"
:image="IMAGE_PATHS.VLOG.TASK_PERSON" :image="IMAGE_PATHS.VLOG.TASK_PERSON"
/> />
<Overlay content-class="w-full h-full" :visible="visible && currentStep < 4"> <Overlay
content-class="w-full h-full"
:visible="visible && currentStep < 4"
>
<!-- 关闭按钮 --> <!-- 关闭按钮 -->
<ImgButton <ImgButton
class="absolute right-0 top-0 z-20" class="absolute right-0 top-0 z-20"
@@ -1,5 +1,10 @@
<script lang="tsx" setup> <script lang="tsx" setup>
import { getBackgroundImage, getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils' import {
getBackgroundImage,
getPublicPath,
getStaticImage,
IMAGE_PATHS,
} from '@/utils'
import { GuideSteps } from '@/components/Guide' import { GuideSteps } from '@/components/Guide'
import { onBeforeUnmount, ref, computed } from 'vue' import { onBeforeUnmount, ref, computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -16,8 +21,8 @@ const steps = ref([
id: 'step1', id: 'step1',
content: content:
'设备准备妥当后,我们就该走进真实场景,开启实地取景的环节啦,一起去捕捉那些适合拍摄的画面吧!', '设备准备妥当后,我们就该走进真实场景,开启实地取景的环节啦,一起去捕捉那些适合拍摄的画面吧!',
audioSrc: getPublicPath('audio/py_2_013.MP3') audioSrc: getPublicPath('audio/py_2_013.MP3'),
} },
]) ])
const showGuide = ref(false) const showGuide = ref(false)
@@ -28,9 +33,14 @@ const hiddenbar = ref(false)
const currentIndex = ref(0) // 当前显示的图片索引 const currentIndex = ref(0) // 当前显示的图片索引
const selectedMenuIndex = ref(0) // 当前选中的菜单项索引 const selectedMenuIndex = ref(0) // 当前选中的菜单项索引
type EquipmentCategoryId = 'phone' | 'phone-stabilizer' | 'wireless-lavalier-mic' type EquipmentCategoryId =
| 'phone'
| 'phone-stabilizer'
| 'wireless-lavalier-mic'
interface EquipmentProduct { interface EquipmentProduct {
img: string img: string
titleImg?: string
contentImg?: string
} }
interface EquipmentCategory { interface EquipmentCategory {
id: EquipmentCategoryId id: EquipmentCategoryId
@@ -49,9 +59,17 @@ const categories = ref<EquipmentCategory[]>([
titleImg: getStaticImage('equipment/标题-苹果.png'), titleImg: getStaticImage('equipment/标题-苹果.png'),
contentImg: getStaticImage('equipment/内容-苹果.png'), contentImg: getStaticImage('equipment/内容-苹果.png'),
products: [ products: [
{ img: getStaticImage('equipment/设备-手机1.png') }, {
{ img: getStaticImage('equipment/设备-手机2.png') } img: getStaticImage('equipment/设备-手机1.png'),
] titleImg: getStaticImage('equipment/标题-苹果.png'),
contentImg: getStaticImage('equipment/内容-苹果.png'),
},
{
img: getStaticImage('equipment/设备-手机2.png'),
titleImg: getStaticImage('equipment/标题-安卓小米.png'),
contentImg: getStaticImage('equipment/内容-安卓小米.png'),
},
],
}, },
{ {
id: 'phone-stabilizer', id: 'phone-stabilizer',
@@ -59,7 +77,7 @@ const categories = ref<EquipmentCategory[]>([
disabled: false, disabled: false,
titleImg: getStaticImage('equipment/手机稳定器标题.png'), titleImg: getStaticImage('equipment/手机稳定器标题.png'),
contentImg: getStaticImage('equipment/手机稳定器图文.png'), contentImg: getStaticImage('equipment/手机稳定器图文.png'),
products: [{ img: getStaticImage('equipment/手机稳定器.png') }] products: [{ img: getStaticImage('equipment/手机稳定器.png') }],
}, },
{ {
id: 'wireless-lavalier-mic', id: 'wireless-lavalier-mic',
@@ -67,13 +85,13 @@ const categories = ref<EquipmentCategory[]>([
disabled: false, disabled: false,
titleImg: getStaticImage('equipment/无线领夹麦克风标题.png'), titleImg: getStaticImage('equipment/无线领夹麦克风标题.png'),
contentImg: getStaticImage('equipment/无线领夹麦克风图文.png'), contentImg: getStaticImage('equipment/无线领夹麦克风图文.png'),
products: [{ img: getStaticImage('equipment/无线领夹麦克风.png') }] products: [{ img: getStaticImage('equipment/无线领夹麦克风.png') }],
} },
]) ])
categories.value = categories.value.map(c => ({ categories.value = categories.value.map((c) => ({
...c, ...c,
disabled: (c.products?.length ?? 0) === 0 disabled: (c.products?.length ?? 0) === 0,
})) }))
// 默认选中的菜单也算作已查看 // 默认选中的菜单也算作已查看
@@ -83,9 +101,11 @@ if (categories.value[0] && !categories.value[0].disabled) {
const maybeTriggerGuide = () => { const maybeTriggerGuide = () => {
if (guideTriggered.value) return if (guideTriggered.value) return
const enabledIds = categories.value.filter(c => !c.disabled).map(c => c.id) const enabledIds = categories.value
.filter((c) => !c.disabled)
.map((c) => c.id)
if (enabledIds.length === 0) return if (enabledIds.length === 0) return
const allViewed = enabledIds.every(id => viewedMenuKeys.value.has(id)) const allViewed = enabledIds.every((id) => viewedMenuKeys.value.has(id))
if (!allViewed) return if (!allViewed) return
guideTriggered.value = true guideTriggered.value = true
guideTimer.value = setTimeout(() => { guideTimer.value = setTimeout(() => {
@@ -102,17 +122,26 @@ onBeforeUnmount(() => {
}) })
const currentCategory = computed( const currentCategory = computed(
() => categories.value[selectedMenuIndex.value] ?? categories.value[0] () => categories.value[selectedMenuIndex.value] ?? categories.value[0],
) )
const currentProducts = computed(() => currentCategory.value?.products ?? []) const currentProducts = computed(() => currentCategory.value?.products ?? [])
const currentTitleImg = computed(() => currentCategory.value?.titleImg ?? '') const currentProduct = computed(() => currentProducts.value[currentIndex.value])
const currentContentImg = computed(() => currentCategory.value?.contentImg ?? '') const currentTitleImg = computed(
() => currentProduct.value?.titleImg ?? currentCategory.value?.titleImg ?? '',
)
const currentContentImg = computed(
() =>
currentProduct.value?.contentImg ?? currentCategory.value?.contentImg ?? '',
)
const hiddenbarclick = () => { const hiddenbarclick = () => {
hiddenbar.value = !hiddenbar.value hiddenbar.value = !hiddenbar.value
} }
// 选择菜单项 // 选择菜单项
const selectMenuItem = (index: number, item: { id: string; name: string; disabled: boolean }) => { const selectMenuItem = (
index: number,
item: { id: string; name: string; disabled: boolean },
) => {
if (item.disabled) { if (item.disabled) {
return return
} }
@@ -145,9 +174,16 @@ const prevPhone = () => {
></div> ></div>
<div class="equipment-bar"> <div class="equipment-bar">
<img src="@/assets/images/equipment/侧边导航栏.png" alt="" :hidden="hiddenbar" /> <img
src="@/assets/images/equipment/侧边导航栏.png"
alt=""
:hidden="hiddenbar"
/>
<!-- 内容列表 --> <!-- 内容列表 -->
<div class="equipment-item" :hidden="hiddenbar"> <div
class="equipment-item"
:hidden="hiddenbar"
>
<div class="menu-list"> <div class="menu-list">
<div <div
v-for="(item, index) in categories" v-for="(item, index) in categories"
@@ -155,7 +191,7 @@ const prevPhone = () => {
class="menu-item" class="menu-item"
:class="{ :class="{
'menu-item--active': selectedMenuIndex === index, 'menu-item--active': selectedMenuIndex === index,
'menu-item--disabled': item.disabled 'menu-item--disabled': item.disabled,
}" }"
@click="selectMenuItem(index, item)" @click="selectMenuItem(index, item)"
> >
@@ -164,7 +200,10 @@ const prevPhone = () => {
v-if="selectedMenuIndex === index && !item.disabled" v-if="selectedMenuIndex === index && !item.disabled"
></div> ></div>
<span class="menu-item__text">{{ item.name }}</span> <span class="menu-item__text">{{ item.name }}</span>
<div class="menu-item__status" v-if="item.disabled"> <div
class="menu-item__status"
v-if="item.disabled"
>
<!-- <span class="status-text">未解锁</span> --> <!-- <span class="status-text">未解锁</span> -->
</div> </div>
</div> </div>
@@ -173,10 +212,16 @@ const prevPhone = () => {
<!-- 设备准备按钮 --> <!-- 设备准备按钮 -->
<div class="equipment-bt"> <div class="equipment-bt">
<img src="@/assets/images/equipment/按钮选中-设备准备.png" alt="" /> <img
src="@/assets/images/equipment/按钮选中-设备准备.png"
alt=""
/>
</div> </div>
<div <div
:class="['cursor-pointer', hiddenbar ? 'equipment-unfold1' : 'equipment-unfold2']" :class="[
'cursor-pointer',
hiddenbar ? 'equipment-unfold1' : 'equipment-unfold2',
]"
@click="hiddenbarclick()" @click="hiddenbarclick()"
> >
<img <img
@@ -196,36 +241,61 @@ const prevPhone = () => {
@click="prevPhone" @click="prevPhone"
:style="{ visibility: currentIndex > 0 ? 'visible' : 'hidden' }" :style="{ visibility: currentIndex > 0 ? 'visible' : 'hidden' }"
> >
<img src="@/assets/images/equipment/按钮-左滑.png" alt="" /> <img
src="@/assets/images/equipment/按钮-左滑.png"
alt=""
/>
</div> </div>
<div class="equipment-phoneImg"> <div class="equipment-phoneImg">
<img :src="currentProducts[currentIndex]?.img" alt="" /> <img
:src="currentProducts[currentIndex]?.img"
alt=""
/>
</div> </div>
<div <div
class="equipment-rightbutton" class="equipment-rightbutton"
@click="nextPhone" @click="nextPhone"
:style="{ visibility: currentIndex < currentProducts.length - 1 ? 'visible' : 'hidden' }" :style="{
visibility:
currentIndex < currentProducts.length - 1 ? 'visible' : 'hidden',
}"
> >
<img src="@/assets/images/equipment/按钮-右滑.png" alt="" /> <img
src="@/assets/images/equipment/按钮-右滑.png"
alt=""
/>
</div> </div>
</div> </div>
<div class="equipment-purpose"> <div class="equipment-purpose">
<div class="plate"> <div class="plate">
<img src="@/assets/images/equipment/浮空屏.png" alt="" /> <img
src="@/assets/images/equipment/浮空屏.png"
alt=""
/>
</div> </div>
<div class="title"> <div class="title">
<img :src="currentTitleImg" alt="" /> <img
:src="currentTitleImg"
alt=""
/>
</div> </div>
<div class="plate-content"> <div class="plate-content">
<div class="text"> <div class="text">
<img :src="currentContentImg" alt="" /> <img
:src="currentContentImg"
alt=""
/>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<ImgButton class="absolute right-0 top-0" :image="IMAGE_PATHS.COMPONENTS.BACK" @click="back" /> <ImgButton
class="absolute right-0 top-0"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="back"
/>
<!-- 调试按钮组 --> <!-- 调试按钮组 -->
<!-- <div class="debug-controls"> <!-- <div class="debug-controls">
<button @click="enableAllMenuItems" class="debug-btn">启用全部</button> <button @click="enableAllMenuItems" class="debug-btn">启用全部</button>
@@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, ref } from 'vue' import { computed, nextTick, ref } from 'vue'
import { useRouter } from 'vue-router' import { onBeforeRouteLeave, useRouter } from 'vue-router'
import { ImgButton } from '@/components/Button' import { ImgButton } from '@/components/Button'
import { GuideSteps, GuideStepsItem } from '@/components/Guide' import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import StepNavigation from '@/components/Guide/StepNavigation.vue' import StepNavigation from '@/components/Guide/StepNavigation.vue'
import UnfinishedLearningPrompt from '@/components/UnfinishedLearningPrompt'
import { Video } from '@/components' import { Video } from '@/components'
import { getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils' import { getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
import { useVlogStore } from '@/stores/modules/vlog' import { useVlogStore } from '@/stores/modules/vlog'
@@ -18,25 +19,28 @@ if (showIntroGuide.value) {
vlogStore.markGuideShown(guideKey) vlogStore.markGuideShown(guideKey)
} }
const showStepNavigation = ref(false) const showStepNavigation = ref(false)
const showUnfinishedPrompt = ref(false)
const pendingLeavePath = ref<string | null>(null)
const allowRouteLeave = ref(false)
const currentVideoIndex = ref(0) const currentVideoIndex = ref(0)
const navigationSteps = ref([ const navigationSteps = ref([
{ {
id: 'opening-shot', id: 'vlog-onsite-opening-shot',
label: '拍摄执行', label: '拍摄执行',
content: '开场建立', content: '开场建立',
videoUrl: getPublicPath('video/vlog/on-site/Vlogmk_kcjl_batch.mp4'), videoUrl: getPublicPath('video/vlog/on-site/Vlogmk_kcjl_batch.mp4'),
type: 'shooting' as const, type: 'shooting' as const,
}, },
{ {
id: 'lens-language', id: 'vlog-onsite-lens-language',
label: '拍摄执行', label: '拍摄执行',
content: '镜头语言', content: '镜头语言',
videoUrl: getPublicPath('video/vlog/on-site/Vlogmk_jtyy_batch.mp4'), videoUrl: getPublicPath('video/vlog/on-site/Vlogmk_jtyy_batch.mp4'),
type: 'shooting' as const, type: 'shooting' as const,
}, },
{ {
id: 'motion-capture', id: 'vlog-onsite-motion-capture',
label: '拍摄执行', label: '拍摄执行',
content: '动态跟拍与抓拍', content: '动态跟拍与抓拍',
videoUrl: getPublicPath('video/vlog/on-site/Vlogmk_dtgpyzp_batch.mp4'), videoUrl: getPublicPath('video/vlog/on-site/Vlogmk_dtgpyzp_batch.mp4'),
@@ -44,6 +48,21 @@ const navigationSteps = ref([
}, },
]) ])
const completedNavigationSteps = computed(() =>
navigationSteps.value.map(
(step) => vlogStore.professionVideosWatched[step.id] === true,
),
)
const hasUnfinishedLearningVideos = computed(() =>
completedNavigationSteps.value.some((completed) => !completed),
)
const markCurrentVideoWatched = () => {
const videoKey = navigationSteps.value[currentVideoIndex.value]?.id
if (videoKey) vlogStore.markOnsiteVideoWatched(videoKey)
}
const currentVideo = computed( const currentVideo = computed(
() => navigationSteps.value[currentVideoIndex.value].videoUrl, () => navigationSteps.value[currentVideoIndex.value].videoUrl,
) )
@@ -64,6 +83,8 @@ const handleStepNavigationClose = () => {
} }
const handleEnded = () => { const handleEnded = () => {
markCurrentVideoWatched()
if (currentVideoIndex.value >= navigationSteps.value.length - 1) { if (currentVideoIndex.value >= navigationSteps.value.length - 1) {
playing.value = false playing.value = false
return return
@@ -80,6 +101,25 @@ const handleIntroGuideComplete = () => {
playing.value = true playing.value = true
}) })
} }
const handleContinueLearning = () => {
showUnfinishedPrompt.value = false
pendingLeavePath.value = null
}
const handleConfirmLeave = () => {
if (!pendingLeavePath.value) return
allowRouteLeave.value = true
showUnfinishedPrompt.value = false
router.push(pendingLeavePath.value)
}
onBeforeRouteLeave((to) => {
if (allowRouteLeave.value || !hasUnfinishedLearningVideos.value) return true
pendingLeavePath.value = to.fullPath
showUnfinishedPrompt.value = true
return false
})
</script> </script>
<template> <template>
@@ -127,10 +167,17 @@ const handleIntroGuideComplete = () => {
:background-image="getStaticImage('work/vlog背景全屏待定')" :background-image="getStaticImage('work/vlog背景全屏待定')"
:steps="navigationSteps" :steps="navigationSteps"
:current-step-index="currentVideoIndex" :current-step-index="currentVideoIndex"
:completed-steps="completedNavigationSteps"
@step-click="handleStepClick" @step-click="handleStepClick"
@close="handleStepNavigationClose" @close="handleStepNavigationClose"
/> />
<UnfinishedLearningPrompt
:visible="showUnfinishedPrompt"
@continue="handleContinueLearning"
@leave="handleConfirmLeave"
/>
<GuideSteps <GuideSteps
v-if="showIntroGuide" v-if="showIntroGuide"
position-offset-y="85%" position-offset-y="85%"
@@ -1,24 +1,15 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref, computed } from 'vue' import { ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { GuideSteps, GuideStepsItem } from '@/components/Guide' import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { Overlay } from '@/components/Overlay'
import { ImgButton } from '@/components/Button' import { ImgButton } from '@/components/Button'
import { Video } from '@/components/Video' import { getBackgroundImage, getPublicPath } from '@/utils'
import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils'
import shootingtitle from '@/assets/images/shooting/标题-实地取景.png' import shootingtitle from '@/assets/images/shooting/标题-实地取景.png'
import videoSrc from '/video/xinshou_jiuzhonggoutufa.mp4' import videoSrc from '/video/xinshou_jiuzhonggoutufa.mp4'
import KnowledgeCard from '@/components/KnowledgeCard/KnowledgeCard.vue' import KnowledgeCard from '@/components/KnowledgeCard/KnowledgeCard.vue'
import CompositionGuide from '@/components/CompositionGuide.vue'
import { useVlogStore } from '@/stores/modules/vlog' import { useVlogStore } from '@/stores/modules/vlog'
type Btn = {
key: string
name: string
btnImage: string
images: string[]
desc: string
}
const router = useRouter() const router = useRouter()
const vlogStore = useVlogStore() const vlogStore = useVlogStore()
const guideKey = 'vlog-shooting-intro' const guideKey = 'vlog-shooting-intro'
@@ -30,17 +21,7 @@ if (showIntroGuide.value) {
const showModal = ref(false) const showModal = ref(false)
const showModal2 = ref(false) const btnList = ref([
const showModal3 = ref(false)
const currentItem = ref<Btn>({
key: '',
name: '',
btnImage: '',
images: [],
desc: '',
})
const btnList = ref<Btn[]>([
{ {
key: '1', key: '1',
name: '居中构图', name: '居中构图',
@@ -64,30 +45,13 @@ const btnList = ref<Btn[]>([
}, },
]) ])
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 = () => { const handleClickA = () => {
// 跳转设备准备
router.push({ name: 'VlogFilmingLocationA' }) router.push({ name: 'VlogFilmingLocationA' })
} }
const handleClickB = () => { const handleClickB = () => {
// 跳转设备准备
router.push({ name: 'VlogFilmingLocationB' }) router.push({ name: 'VlogFilmingLocationB' })
} }
const back = () => { const back = () => {
// 跳转设备准备
router.back() router.back()
} }
</script> </script>
@@ -100,18 +64,20 @@ const back = () => {
backgroundImage: getBackgroundImage('vlog/shooting/实地取景底图'), backgroundImage: getBackgroundImage('vlog/shooting/实地取景底图'),
}" }"
> >
<div class="shooting-title"> <div class="absolute top-0 left-0">
<img <img
:src="shootingtitle" :src="shootingtitle"
alt="" alt=""
/> />
</div> </div>
<div class="shooting-return">
<div class="absolute top-0 right-0">
<ImgButton <ImgButton
image="shooting/返回.png" image="shooting/返回.png"
@click="back" @click="back"
/> />
</div> </div>
<div class="absolute left-[12%] top-1/2 -translate-y-[calc(50%-70px)]"> <div class="absolute left-[12%] top-1/2 -translate-y-[calc(50%-70px)]">
<ImgButton <ImgButton
image="vlog/shooting/热带植物馆-入口.png" image="vlog/shooting/热带植物馆-入口.png"
@@ -124,7 +90,7 @@ const back = () => {
@click="handleClickB" @click="handleClickB"
/> />
</div> </div>
<div class="shooting-composition btn-1"> <div class="absolute bottom-4 left-4 btn-1">
<ImgButton <ImgButton
image="shooting/构图指南.png" image="shooting/构图指南.png"
@click="showModal = true" @click="showModal = true"
@@ -145,118 +111,14 @@ const back = () => {
</GuideStepsItem> </GuideStepsItem>
</GuideSteps> </GuideSteps>
<!-- 保存图片弹窗 --> <!-- 构图指南弹窗 -->
<Overlay <CompositionGuide
:visible="showModal" :visible="showModal"
:clickable="false" bg-image="shooting/构图指南底图"
:customStyle="{ :btn-list="btnList"
backgroundImage: getBackgroundImage('shooting/构图指南底图'), :knowledge-card-component="KnowledgeCard"
}" :video-src="videoSrc"
customClass="inset-0 bg-cover bg-center bg-no-repeat" @close="showModal = false"
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
:volume="1"
:muted="false"
></Video>
<!-- 返回 -->
<ImgButton
class="absolute right-0 top-0 z-10"
:image="IMAGE_PATHS.COMPONENTS.CLOSE"
@click="showModal3 = false"
/>
</Overlay>
</Overlay>
</div> </div>
</template> </template>
<style lang="less" scoped>
.shooting-bg {
position: absolute;
.shooting-title {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
.shooting-return {
position: absolute;
right: -90%;
top: 0;
width: 100%;
height: 100%;
}
.shooting-left {
position: absolute;
left: 16%;
top: 50%;
width: 100%;
height: 100%;
}
.shooting-right {
position: absolute;
right: -55%;
top: 50%;
width: 100%;
height: 100%;
}
.shooting-composition {
position: absolute;
left: 24px;
bottom: -90%;
width: 100%;
height: 100%;
}
}
</style>
+12 -1
View File
@@ -68,6 +68,17 @@ const worksList = ref<WorkItem[]>([
}, },
]) ])
const getCachedWorkScore = (work: WorkItem): number => {
const scores = vlogStore.practiceWorkRatings[work.path]
const values = scores ? Object.values(scores) : []
if (values.length === 0) return work.stars
return Math.round(
values.reduce((total, score) => total + score, 0) / values.length,
)
}
/** 左滑动作品列表 */ /** 左滑动作品列表 */
const scrollWorksLeft = (): void => { const scrollWorksLeft = (): void => {
if (worksScrollIndex.value > 0) { if (worksScrollIndex.value > 0) {
@@ -291,7 +302,7 @@ onUnmounted(() => {
</h3> </h3>
<!-- 星星评分 --> <!-- 星星评分 -->
<StarRating <StarRating
:score="work.stars" :score="getCachedWorkScore(work)"
size="lg" size="lg"
readonly readonly
/> />
+50 -7
View File
@@ -1,19 +1,37 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref } from 'vue' import { computed, onBeforeUnmount, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { getBackgroundImage, IMAGE_PATHS } from '@/utils' import { getBackgroundImage, IMAGE_PATHS } from '@/utils'
import { ImgButton } from '@/components/Button' import { ImgButton } from '@/components/Button'
import PracticeRatingModal, { import PracticeRatingModal, {
type PracticeRatingItem, type PracticeRatingItem,
} from '@/components/PracticeRatingModal' } from '@/components/PracticeRatingModal'
import { useVlogStore } from '@/stores/modules/vlog'
const router = useRouter() const router = useRouter()
const store = useVlogStore()
type PracticeRatingScores = Record<string, number>
const applyScores = (
items: PracticeRatingItem[],
scores: PracticeRatingScores,
): PracticeRatingItem[] =>
items.map((item) => ({
...item,
score: scores[item.title] ?? item.score,
}))
const toScores = (items: PracticeRatingItem[]): PracticeRatingScores =>
Object.fromEntries(items.map((item) => [item.title, item.score]))
/** 笔记内容 */ /** 笔记内容 */
const noteContent = ref<string>('') const noteContent = ref<string>(store.practiceNoteContent)
/** 保存弹窗 */ /** 保存弹窗 */
const showModal = ref<boolean>(false) const showModal = ref<boolean>(false)
const showSuccess = ref<boolean>(false)
let successTimer: number | undefined
/** 返回上一页 */ /** 返回上一页 */
const handleBack = (): void => { const handleBack = (): void => {
@@ -23,7 +41,7 @@ const handleBack = (): void => {
const disabled = computed<boolean>(() => noteContent.value.trim() === '') const disabled = computed<boolean>(() => noteContent.value.trim() === '')
/** 评估项目列表 */ /** 评估项目列表 */
const evaluationItems = ref<PracticeRatingItem[]>([ const defaultEvaluationItems: PracticeRatingItem[] = [
{ {
title: '开场建立', title: '开场建立',
description: '能清晰交代时间、地点与事件开端,观众易于进入情境', description: '能清晰交代时间、地点与事件开端,观众易于进入情境',
@@ -55,11 +73,28 @@ const evaluationItems = ref<PracticeRatingItem[]>([
description: '视频完整、有连贯观看体验,能表达出一次真实记录或个人感受', description: '视频完整、有连贯观看体验,能表达出一次真实记录或个人感受',
score: 5, score: 5,
}, },
]) ]
const evaluationItems = ref<PracticeRatingItem[]>(
applyScores(defaultEvaluationItems, store.practiceSelfEvaluationScores),
)
const handleSave = (): void => { const handleSave = (): void => {
console.log('保存笔记') store.setPracticeNoteContent(noteContent.value)
handleBack() showSuccess.value = true
window.clearTimeout(successTimer)
successTimer = window.setTimeout(() => {
showSuccess.value = false
}, 1500)
} }
const handleSaveEvaluation = (items: PracticeRatingItem[]): void => {
store.setPracticeSelfEvaluationScores(toScores(items))
}
onBeforeUnmount(() => {
window.clearTimeout(successTimer)
})
</script> </script>
<template> <template>
@@ -82,6 +117,13 @@ const handleSave = (): void => {
placeholder="点击输入创作笔记" placeholder="点击输入创作笔记"
></textarea> ></textarea>
<div
v-if="showSuccess"
class="absolute left-1/2 top-1/2 z-[100] -translate-x-1/2 -translate-y-1/2 rounded-2xl bg-black/70 px-16 py-8 text-5xl font-bold text-white"
>
提交成功
</div>
<div class="absolute bottom-24 left-1/2 -translate-x-1/2 flex gap-60"> <div class="absolute bottom-24 left-1/2 -translate-x-1/2 flex gap-60">
<ImgButton <ImgButton
image="vlog/practice/保存" image="vlog/practice/保存"
@@ -96,10 +138,11 @@ const handleSave = (): void => {
<PracticeRatingModal <PracticeRatingModal
:visible="showModal" :visible="showModal"
:items="evaluationItems" v-model:items="evaluationItems"
background-image="vlog/practice/Vlog作品评分表" background-image="vlog/practice/Vlog作品评分表"
top-tip="评分时要关注6个维度哦~分别是开场建立、拍摄表达、真实捕捉、剪辑结构、音画关系、综合感受,每个维度满分5分,能打1-5分,记得对照作品表现来打分呀~" top-tip="评分时要关注6个维度哦~分别是开场建立、拍摄表达、真实捕捉、剪辑结构、音画关系、综合感受,每个维度满分5分,能打1-5分,记得对照作品表现来打分呀~"
bottom-tip="己评价的实战作品,从封面入口就能直接看到具体评分!打了多少分一目了然,不用再点进详情页翻找啦~" bottom-tip="己评价的实战作品,从封面入口就能直接看到具体评分!打了多少分一目了然,不用再点进详情页翻找啦~"
@save="handleSaveEvaluation"
@close="showModal = false" @close="showModal = false"
/> />
</div> </div>
+34 -4
View File
@@ -6,9 +6,11 @@ import PracticeRatingModal, {
type PracticeRatingItem, type PracticeRatingItem,
} from '@/components/PracticeRatingModal' } from '@/components/PracticeRatingModal'
import { IMAGE_PATHS, getPublicPath } from '@/utils' import { IMAGE_PATHS, getPublicPath } from '@/utils'
import { ref } from 'vue' import { useVlogStore } from '@/stores/modules/vlog'
import { computed, ref } from 'vue'
const router = useRouter() const router = useRouter()
const store = useVlogStore()
const props = defineProps({ const props = defineProps({
path: { path: {
@@ -17,8 +19,24 @@ const props = defineProps({
}, },
}) })
type PracticeRatingScores = Record<string, number>
const workKey = computed<string>(() => decodeURIComponent(props.path))
const applyScores = (
items: PracticeRatingItem[],
scores: PracticeRatingScores,
): PracticeRatingItem[] =>
items.map((item) => ({
...item,
score: scores[item.title] ?? item.score,
}))
const toScores = (items: PracticeRatingItem[]): PracticeRatingScores =>
Object.fromEntries(items.map((item) => [item.title, item.score]))
/** 评估项目列表 */ /** 评估项目列表 */
const evaluationItems = ref<PracticeRatingItem[]>([ const defaultEvaluationItems: PracticeRatingItem[] = [
{ {
title: '开场建立', title: '开场建立',
description: '能清晰交代时间、地点与事件开端,观众易于进入情境', description: '能清晰交代时间、地点与事件开端,观众易于进入情境',
@@ -50,7 +68,18 @@ const evaluationItems = ref<PracticeRatingItem[]>([
description: '视频完整、有连贯观看体验,能表达出一次真实记录或个人感受', description: '视频完整、有连贯观看体验,能表达出一次真实记录或个人感受',
score: 5, score: 5,
}, },
]) ]
const evaluationItems = ref<PracticeRatingItem[]>(
applyScores(
defaultEvaluationItems,
store.getPracticeWorkRating(workKey.value),
),
)
const handleSaveEvaluation = (items: PracticeRatingItem[]): void => {
store.setPracticeWorkRating(workKey.value, toScores(items))
}
/** 保存弹窗 */ /** 保存弹窗 */
const showModal = ref<boolean>(false) const showModal = ref<boolean>(false)
@@ -83,10 +112,11 @@ const handleBack = (): void => {
<PracticeRatingModal <PracticeRatingModal
:visible="showModal" :visible="showModal"
:items="evaluationItems" v-model:items="evaluationItems"
background-image="vlog/practice/Vlog作品评分表" background-image="vlog/practice/Vlog作品评分表"
top-tip="评分时要关注6个维度哦~分别是开场建立、拍摄表达、真实捕捉、剪辑结构、音画关系、综合感受,每个维度满分5分,能打1-5分,记得对照作品表现来打分呀~" top-tip="评分时要关注6个维度哦~分别是开场建立、拍摄表达、真实捕捉、剪辑结构、音画关系、综合感受,每个维度满分5分,能打1-5分,记得对照作品表现来打分呀~"
bottom-tip="己评价的实战作品,从封面入口就能直接看到具体评分!打了多少分一目了然,不用再点进详情页翻找啦~" bottom-tip="己评价的实战作品,从封面入口就能直接看到具体评分!打了多少分一目了然,不用再点进详情页翻找啦~"
@save="handleSaveEvaluation"
@close="showModal = false" @close="showModal = false"
/> />
</div> </div>
+82 -9
View File
@@ -1,9 +1,10 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, nextTick, onMounted, ref } from 'vue' import { computed, nextTick, onMounted, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { onBeforeRouteLeave, useRouter, useRoute } from 'vue-router'
import ImgButton from '@/components/Button/ImgButton.vue' import ImgButton from '@/components/Button/ImgButton.vue'
import { GuideSteps } from '@/components/Guide' import { GuideSteps } from '@/components/Guide'
import StepNavigation from '@/components/Guide/StepNavigation.vue' import StepNavigation from '@/components/Guide/StepNavigation.vue'
import UnfinishedLearningPrompt from '@/components/UnfinishedLearningPrompt'
import { Video } from '@/components' import { Video } from '@/components'
import { import {
getBackgroundImage, getBackgroundImage,
@@ -86,11 +87,46 @@ const navigationSteps = ref([
// 步骤导航相关状态 // 步骤导航相关状态
const showStepNavigation = ref(false) const showStepNavigation = ref(false)
const showUnfinishedPrompt = ref(false)
const pendingLeavePath = ref<string | null>(null)
const allowRouteLeave = ref(false)
const currentNavigationStep = ref(0) // 对应视频索引,从0开始 const currentNavigationStep = ref(0) // 对应视频索引,从0开始
// 视频列表 // 视频列表
const currentVideoIndex = ref(0) const currentVideoIndex = ref(0)
const professionVideoKeys = [
'vlog-profession-jingbie',
'vlog-profession-jiaoben',
'vlog-profession-sucaijianji',
'vlog-profession-yinyue',
'vlog-profession-shitingyuyan',
] as const
const completedNavigationSteps = computed(() =>
navigationSteps.value.map(
(_, index) =>
vlog.professionVideosWatched[professionVideoKeys[index]] === true,
),
)
const hasUnfinishedLearningVideos = computed(
() =>
key.value === 'profession' &&
completedNavigationSteps.value.some((completed) => !completed),
)
const markCurrentProfessionVideoWatched = () => {
const videoKey = professionVideoKeys[currentVideoIndex.value]
if (!videoKey) return
vlog.markProfessionVideoWatched(videoKey)
const isAllWatched = professionVideoKeys.every(
(key) => key === videoKey || vlog.professionVideosWatched[key] === true,
)
if (isAllWatched) vlog.markWorkVideoWatched('profession')
}
//按顺序播放 //按顺序播放
const currentVideo = computed( const currentVideo = computed(
() => navigationSteps.value[currentVideoIndex.value].videoUrl, () => navigationSteps.value[currentVideoIndex.value].videoUrl,
@@ -106,13 +142,14 @@ const handleVideoChange = () => {
currentVideoIndex.value++ currentVideoIndex.value++
currentNavigationStep.value = currentVideoIndex.value currentNavigationStep.value = currentVideoIndex.value
} else { } else {
// status.value = true status.value = true
// steps.value = [ steps.value = [
// { {
// id: 'step1', id: 'step1',
// content: '恭喜你,已完成专业剖析的学习,点击【查看脚本】回顾视频脚本吧!' content:
// } '恭喜你,已完成专业剖析的学习,点击【查看脚本】回顾视频脚本吧!',
// ] },
]
return return
} }
nextTick(() => { nextTick(() => {
@@ -123,6 +160,7 @@ const handleVideoChange = () => {
const handleClose = () => { const handleClose = () => {
if (key.value === 'idea') { if (key.value === 'idea') {
vlog.markWorkVideoWatched('idea')
if (vlog.vlogUnlockProgress === 0) vlog.setVlogUnlockProgress(1) if (vlog.vlogUnlockProgress === 0) vlog.setVlogUnlockProgress(1)
router.push({ router.push({
name: 'Vlog', name: 'Vlog',
@@ -150,11 +188,20 @@ const label = computed(() => route.query.label as string)
// ]) // ])
const onEnded = () => { const onEnded = () => {
if (key.value === 'profession') handleVideoChange() if (key.value === 'case') {
vlog.markWorkVideoWatched('case')
return
}
if (key.value === 'profession') {
markCurrentProfessionVideoWatched()
handleVideoChange()
}
} }
const handleComplete = () => { const handleComplete = () => {
if (key.value === 'profession') { if (key.value === 'profession') {
vlog.markWorkVideoWatched('profession')
if (!vlog.isVlogUnlock) vlog.setIsVlogUnlock(true) if (!vlog.isVlogUnlock) vlog.setIsVlogUnlock(true)
router.push({ router.push({
name: 'VlogWork', name: 'VlogWork',
@@ -175,6 +222,25 @@ const handleStepNavigationClose = () => {
showStepNavigation.value = false showStepNavigation.value = false
} }
const handleContinueLearning = () => {
showUnfinishedPrompt.value = false
pendingLeavePath.value = null
}
const handleConfirmLeave = () => {
if (!pendingLeavePath.value) return
allowRouteLeave.value = true
showUnfinishedPrompt.value = false
router.push(pendingLeavePath.value)
}
onBeforeRouteLeave((to) => {
if (allowRouteLeave.value || !hasUnfinishedLearningVideos.value) return true
pendingLeavePath.value = to.fullPath
showUnfinishedPrompt.value = true
return false
})
onMounted(() => { onMounted(() => {
if (key.value === 'idea') { if (key.value === 'idea') {
steps.value = ideaSteps steps.value = ideaSteps
@@ -325,9 +391,16 @@ onMounted(() => {
:background-image="getStaticImage('work/vlog背景全屏待定')" :background-image="getStaticImage('work/vlog背景全屏待定')"
:steps="navigationSteps" :steps="navigationSteps"
:current-step-index="currentNavigationStep" :current-step-index="currentNavigationStep"
:completed-steps="completedNavigationSteps"
@step-click="handleStepClick" @step-click="handleStepClick"
@close="handleStepNavigationClose" @close="handleStepNavigationClose"
/> />
<UnfinishedLearningPrompt
:visible="showUnfinishedPrompt"
@continue="handleContinueLearning"
@leave="handleConfirmLeave"
/>
</template> </template>
<style scoped> <style scoped>
+5 -2
View File
@@ -21,7 +21,10 @@ const vlog = useVlogStore()
type WorkTab = 'vlog' | 'case' | 'profession' | 'idea' type WorkTab = 'vlog' | 'case' | 'profession' | 'idea'
const selected = ref<WorkTab>('case') const selected = computed({
get: () => vlog.workSelectedTab as WorkTab,
set: (value: WorkTab) => vlog.setWorkSelectedTab(value),
})
const workSideSwitcherRef = ref<InstanceType<typeof WorkSideSwitcher>>() const workSideSwitcherRef = ref<InstanceType<typeof WorkSideSwitcher>>()
const isGuideVlogTask = computed(() => vlog.isGuideVlogTask) const isGuideVlogTask = computed(() => vlog.isGuideVlogTask)
@@ -45,7 +48,7 @@ const steps = [
{ {
id: 'step3', id: 'step3',
content: content:
'接下来,我们将通过一个具体案例,一起看看 Vlog 是如何发现生活中的美好、记录生活中的温度。', '接下来,我们通过一个具体案例,一起看看Vlog是如何记录生活中的美好的。',
audioSrc: getPublicPath('audio/py_2_003.MP3'), audioSrc: getPublicPath('audio/py_2_003.MP3'),
highlightSelector: '.img-switch-item--case', highlightSelector: '.img-switch-item--case',
}, },