forked from zhongyidian/super_video
feat: 完善视频学习进度缓存
完善新手导航和三大创作模块的视频完成缓存、未完成提示与学习进度计算,确保进度按实际学习资源完成情况更新。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
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 |
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -20,6 +20,7 @@ export * from './ImagePreview'
|
|||||||
|
|
||||||
// 弹窗组件
|
// 弹窗组件
|
||||||
export * from './Modal'
|
export * from './Modal'
|
||||||
|
export * from './UnfinishedLearningPrompt'
|
||||||
|
|
||||||
// 遮罩层组件
|
// 遮罩层组件
|
||||||
export * from './Overlay'
|
export * from './Overlay'
|
||||||
|
|||||||
@@ -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
@@ -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)) {
|
||||||
|
|||||||
@@ -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: '音效',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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%"
|
||||||
|
|||||||
@@ -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' : ''"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
<!-- 关闭 -->
|
<!-- 关闭 -->
|
||||||
|
|||||||
@@ -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
@@ -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>
|
|
||||||
@@ -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
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
File renamed without changes.
@@ -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=""
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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%"
|
||||||
|
|||||||
@@ -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
@@ -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>
|
|
||||||
@@ -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
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user